Fix the asynchronous cleaning of readonly directories.
Previously, clean --async would silently fail to remove files in read-only
directories. Such directories arise from tree artifacts or unsandboxed actions
writing junk into the output tree. This change makes sure all directories can be
edited before executing the "rm -rf".
The limits of shelling out come into relief in this CL. A more solid approach
would be daemonizing a native utility that executes the code backing the VFS's
Path.deleteTreesBelow method. That would ensure synchronous and asynchronous
cleans have the same erasure capabilities and pave the way for cross-platform
--async support. This CL takes a small step in that direction by replacing
arcane shell daemonization with Bazel's daemonize tool.
Fixes https://github.com/bazelbuild/bazel/issues/12125.
Closes #12452.
PiperOrigin-RevId: 342869437
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/CleanCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/CleanCommand.java
index 5c68499..78f8e77 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/CleanCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/CleanCommand.java
@@ -35,12 +35,12 @@
import com.google.devtools.build.lib.server.FailureDetails.CleanCommand.Code;
import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
import com.google.devtools.build.lib.shell.CommandException;
+import com.google.devtools.build.lib.shell.CommandResult;
import com.google.devtools.build.lib.util.CommandBuilder;
import com.google.devtools.build.lib.util.InterruptedFailureDetails;
import com.google.devtools.build.lib.util.OS;
import com.google.devtools.build.lib.util.Pair;
import com.google.devtools.build.lib.util.ProcessUtils;
-import com.google.devtools.build.lib.util.ShellEscaper;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionDocumentationCategory;
@@ -203,22 +203,27 @@
env.getReporter()
.handle(Event.info(null, pathItemName + " moved to " + tempPath + " for deletion"));
- // Daemonize the shell and use the double-fork idiom to ensure that the shell
- // exits even while the "rm -rf" command continues.
String command =
String.format(
- "exec >&- 2>&- <&- && (/usr/bin/setsid /bin/rm -rf %s &)&",
- ShellEscaper.escapeString(tempPath.getPathString()));
+ "/usr/bin/find %s -type d -not -perm -u=rwx -exec /bin/chmod -f u=rwx {} +; /bin/rm"
+ + " -rf %s",
+ tempBaseName, tempBaseName);
+ logger.atInfo().log("Executing daemonic shell command %s", command);
- logger.atInfo().log("Executing shell command %s", ShellEscaper.escapeString(command));
-
- // Doesn't throw iff command exited and was successful.
- new CommandBuilder()
- .addArg(command)
- .useShell(true)
- .setWorkingDir(tempPath.getParentDirectory())
- .build()
- .execute();
+ // Daemonize the shell to ensure that the shell exits even while the "rm
+ // -rf" command continues.
+ CommandResult result =
+ new CommandBuilder()
+ .addArg(
+ env.getBlazeWorkspace().getBinTools().getEmbeddedPath("daemonize").getPathString())
+ .addArgs("-l", "/dev/null")
+ .addArgs("-p", "/dev/null")
+ .addArg("--")
+ .addArgs("/bin/sh", "/bin/sh", "-c", command)
+ .setWorkingDir(tempPath.getParentDirectory())
+ .build()
+ .execute();
+ logger.atInfo().log("Shell command status: %s", result.getTerminationStatus());
}
private BlazeCommandResult actuallyClean(
diff --git a/src/test/py/bazel/bazel_clean_test.py b/src/test/py/bazel/bazel_clean_test.py
index f8a775c..dcfdc85 100644
--- a/src/test/py/bazel/bazel_clean_test.py
+++ b/src/test/py/bazel/bazel_clean_test.py
@@ -14,6 +14,7 @@
import os
import re
+import time
import unittest
from src.test.py.bazel import test_base
@@ -70,7 +71,7 @@
self.AssertExitCode(exit_code, 0, stderr)
matcher = self._findMatch(' moved to (.*) for deletion', stderr)
self.assertTrue(matcher, stderr)
- first_temp = matcher.group(0)
+ first_temp = matcher.group(1)
self.assertTrue(first_temp, stderr)
# Now do it again (we need to build to recreate exec root).
self.RunBazel(['build'])
@@ -78,11 +79,35 @@
self.AssertExitCode(exit_code, 0, stderr)
matcher = self._findMatch(' moved to (.*) for deletion', stderr)
self.assertTrue(matcher, stderr)
- second_temp = matcher.group(0)
+ second_temp = matcher.group(1)
self.assertTrue(second_temp, stderr)
# Two directories should be different.
self.assertNotEqual(second_temp, first_temp, stderr)
+ @unittest.skipIf(not test_base.TestBase.IsLinux(),
+ 'Async clean only supported on Linux')
+ def testBazelAsyncCleanWithReadonlyDirectories(self):
+ self.ScratchFile('WORKSPACE')
+ exit_code, _, stderr = self.RunBazel(['build'])
+ self.AssertExitCode(exit_code, 0, stderr)
+ exit_code, stdout, stderr = self.RunBazel(['info', 'execution_root'])
+ self.AssertExitCode(exit_code, 0, stderr)
+ execroot = stdout[0]
+ readonly_dir = os.path.join(execroot, 'readonly')
+ os.mkdir(readonly_dir)
+ open(os.path.join(readonly_dir, 'somefile'), 'wb').close()
+ os.chmod(readonly_dir, 0o555)
+ exit_code, _, stderr = self.RunBazel(['clean', '--async'])
+ matcher = self._findMatch(' moved to (.*) for deletion', stderr)
+ self.assertTrue(matcher, stderr)
+ temp = matcher.group(1)
+ for _ in range(50):
+ if not os.path.isdir(temp):
+ break
+ time.sleep(.1)
+ else:
+ self.fail('temporary directory not removed: {!r}'.format(stderr))
+
def _findMatch(self, pattern, items):
r = re.compile(pattern)
for line in items: