| // Copyright 2022 The Bazel Authors. All rights reserved. | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| package com.google.devtools.build.lib.skyframe; | |
| import static com.google.common.truth.Truth.assertThat; | |
| import static com.google.common.truth.Truth.assertWithMessage; | |
| import static com.google.common.truth.TruthJUnit.assume; | |
| import static org.junit.Assert.assertThrows; | |
| import static org.junit.Assert.fail; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.eventbus.Subscribe; | |
| import com.google.devtools.build.lib.actions.BuildFailedException; | |
| import com.google.devtools.build.lib.actions.ChangedFilesMessage; | |
| import com.google.devtools.build.lib.analysis.BlazeDirectories; | |
| import com.google.devtools.build.lib.buildtool.util.SkyframeIntegrationTestBase; | |
| import com.google.devtools.build.lib.cmdline.TargetParsingException; | |
| import com.google.devtools.build.lib.runtime.BlazeModule; | |
| import com.google.devtools.build.lib.runtime.BlazeRuntime; | |
| import com.google.devtools.build.lib.runtime.WorkspaceBuilder; | |
| import com.google.devtools.build.lib.vfs.DelegateFileSystem; | |
| import com.google.devtools.build.lib.vfs.FileStatus; | |
| import com.google.devtools.build.lib.vfs.FileSystem; | |
| import com.google.devtools.build.lib.vfs.FileSystemUtils; | |
| import com.google.devtools.build.lib.vfs.Path; | |
| import com.google.devtools.build.lib.vfs.PathFragment; | |
| import com.google.devtools.build.skyframe.NotifyingHelper; | |
| import com.google.devtools.common.options.OptionsBase; | |
| import java.io.IOException; | |
| import java.util.HashMap; | |
| import java.util.Map; | |
| import java.util.concurrent.atomic.AtomicBoolean; | |
| import java.util.concurrent.atomic.AtomicInteger; | |
| import org.junit.After; | |
| import org.junit.Before; | |
| import org.junit.Test; | |
| import org.junit.runner.RunWith; | |
| import org.junit.runners.JUnit4; | |
| /** | |
| * Tests for local diff awareness. A good place for general tests of Bazel's interactions with | |
| * "smart" filesystems, so that open-source changes don't break Google-internal features around | |
| * smart filesystems. | |
| */ | |
| @RunWith(JUnit4.class) | |
| public class LocalDiffAwarenessIntegrationTest extends SkyframeIntegrationTestBase { | |
| private final Map<PathFragment, IOException> throwOnNextStatIfFound = new HashMap<>(); | |
| @Override | |
| protected BlazeRuntime.Builder getRuntimeBuilder() throws Exception { | |
| return super.getRuntimeBuilder() | |
| .addBlazeModule( | |
| new BlazeModule() { | |
| @Override | |
| public void workspaceInit( | |
| BlazeRuntime runtime, BlazeDirectories directories, WorkspaceBuilder builder) { | |
| builder.addDiffAwarenessFactory( | |
| new LocalDiffAwareness.Factory( | |
| ImmutableList.of(), new FsEventsNativeDepsServiceImpl())); | |
| } | |
| @Override | |
| public Iterable<Class<? extends OptionsBase>> getCommandOptions(String commandName) { | |
| return ImmutableList.of(LocalDiffAwareness.Options.class); | |
| } | |
| }); | |
| } | |
| @Override | |
| public FileSystem createFileSystem() throws Exception { | |
| return new DelegateFileSystem(super.createFileSystem()) { | |
| @Override | |
| public FileStatus statIfFound(PathFragment path, boolean followSymlinks) throws IOException { | |
| IOException e = throwOnNextStatIfFound.remove(path); | |
| if (e != null) { | |
| throw e; | |
| } | |
| return super.statIfFound(path, followSymlinks); | |
| } | |
| }; | |
| } | |
| @Before | |
| public void addOptions() { | |
| addOptions("--watchfs", "--experimental_windows_watchfs"); | |
| } | |
| @After | |
| public void checkExceptionsThrown() { | |
| assertWithMessage("Injected exception(s) not thrown").that(throwOnNextStatIfFound).isEmpty(); | |
| } | |
| @Test | |
| public void changedFile_detectsChange() throws Exception { | |
| write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo hello > $@')"); | |
| buildTarget("//foo"); | |
| assertContents("hello", "//foo"); | |
| write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo there > $@')"); | |
| buildTargetWithRetryUntilSeesChange("//foo", "foo/BUILD"); | |
| assertContents("there", "//foo"); | |
| } | |
| @Test | |
| public void changedIgnoredFile_ignoresChange() throws Exception { | |
| String notIgnoredFilePath = "foo/BUILD"; | |
| String ignoredFilePath = "foo/ignored-dir/BUILD"; | |
| write(".bazelignore", "foo/ignored-dir"); | |
| write(ignoredFilePath, ""); | |
| write(notIgnoredFilePath, "genrule(name='foo', outs=['out'], cmd='echo hello > $@')"); | |
| buildTarget("//foo"); | |
| assertContents("hello", "//foo"); | |
| write(notIgnoredFilePath, "genrule(name='foo', outs=['out'], cmd='echo there > $@')"); | |
| write(ignoredFilePath, "A = 1"); | |
| AtomicBoolean ignoredFileChanged = new AtomicBoolean(); | |
| AtomicBoolean notIgnoredFileChanged = new AtomicBoolean(); | |
| runtimeWrapper.registerSubscriber( | |
| new Object() { | |
| @Subscribe | |
| private void onChangedFiles(ChangedFilesMessage changedFiles) { | |
| ignoredFileChanged.compareAndSet( | |
| false, changedFiles.changedFiles().contains(PathFragment.create(ignoredFilePath))); | |
| notIgnoredFileChanged.compareAndSet( | |
| false, | |
| changedFiles.changedFiles().contains(PathFragment.create(notIgnoredFilePath))); | |
| } | |
| }); | |
| // Work around the inherent raciness of LocalDiffAwareness where the FS events are | |
| // delivered asynchronously and fast running test can trigger an incremental build | |
| // before the change is observed. | |
| for (int attempt = 0; attempt < 10; ++attempt) { | |
| buildTarget("//foo"); | |
| if (notIgnoredFileChanged.get() && !ignoredFileChanged.get()) { | |
| assertContents("there", "//foo"); | |
| return; | |
| } | |
| } | |
| if (!notIgnoredFileChanged.get()) { | |
| fail("Didn't observe file change within allowed number of retries"); | |
| } | |
| if (ignoredFileChanged.get()) { | |
| fail("Observed ignored file change"); | |
| } | |
| } | |
| @Test | |
| public void changedFile_statFails_errorDeferredUntilBuild() throws Exception { | |
| write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo hello > $@')"); | |
| buildTarget("//foo"); | |
| assertContents("hello", "//foo"); | |
| Path buildFile = write("foo/BUILD", "genrule(name='foo', outs=['out'], cmd='echo there > $@')"); | |
| IOException injectedException = new IOException("oh no!"); | |
| throwOnNextStatIfFound.put(buildFile.asFragment(), injectedException); | |
| var e = | |
| assertThrows( | |
| TargetParsingException.class, | |
| () -> buildTargetWithRetryUntilSeesChange("//foo", "foo/BUILD")); | |
| assertThat(e).hasMessageThat().contains(injectedException.getMessage()); | |
| } | |
| @Test | |
| public void renameDirectory_thenRenameBackWithRemovedFile_glob() throws Exception { | |
| write( | |
| "foo/BUILD", | |
| """ | |
| genrule( | |
| name = "foo", | |
| srcs = glob(["*dir*/**"]), | |
| outs = ["out"], | |
| cmd = "echo $(SRCS) > $@", | |
| ) | |
| """); | |
| Path dir = write("foo/dir/file1.txt").getParentDirectory(); | |
| write("foo/dir/file2.txt"); | |
| buildTarget("//foo"); | |
| assertContents("foo/dir/file1.txt foo/dir/file2.txt", "//foo"); | |
| Path newDir = dir.getParentDirectory().getChild("new_dir"); | |
| dir.renameTo(newDir); | |
| buildTargetWithRetryUntilSeesChange("//foo", "foo/dir/file1.txt"); | |
| assertContents("foo/new_dir/file1.txt foo/new_dir/file2.txt", "//foo"); | |
| newDir.getChild("file2.txt").delete(); | |
| newDir.renameTo(dir); | |
| buildTargetWithRetryUntilSeesChange("//foo", "foo/new_dir/file1.txt"); | |
| assertContents("foo/dir/file1.txt", "//foo"); | |
| } | |
| @Test | |
| public void renameDirectory_thenRenameBackWithRemovedFile_inputs() throws Exception { | |
| write( | |
| "foo/BUILD", | |
| """ | |
| genrule( | |
| name = "foo", | |
| srcs = ["dir/file1.txt", "dir/file2.txt"], | |
| outs = ["out"], | |
| cmd = "echo $(SRCS) > $@", | |
| ) | |
| """); | |
| Path dir = write("foo/dir/file1.txt").getParentDirectory(); | |
| write("foo/dir/file2.txt"); | |
| buildTarget("//foo"); | |
| assertContents("foo/dir/file1.txt foo/dir/file2.txt", "//foo"); | |
| Path newDir = dir.getParentDirectory().getChild("new_dir"); | |
| dir.renameTo(newDir); | |
| write( | |
| "foo/BUILD", | |
| """ | |
| genrule( | |
| name = "foo", | |
| srcs = ["new_dir/file1.txt", "new_dir/file2.txt"], | |
| outs = ["out"], | |
| cmd = "echo $(SRCS) > $@", | |
| ) | |
| """); | |
| buildTargetWithRetryUntilSeesChange("//foo", "foo/dir/file1.txt"); | |
| assertContents("foo/new_dir/file1.txt foo/new_dir/file2.txt", "//foo"); | |
| newDir.getChild("file2.txt").delete(); | |
| newDir.renameTo(dir); | |
| write( | |
| "foo/BUILD", | |
| """ | |
| genrule( | |
| name = "foo", | |
| srcs = ["dir/file1.txt"], | |
| outs = ["out"], | |
| cmd = "echo $(SRCS) > $@", | |
| ) | |
| """); | |
| buildTargetWithRetryUntilSeesChange("//foo", "foo/new_dir/file1.txt"); | |
| assertContents("foo/dir/file1.txt", "//foo"); | |
| write( | |
| "foo/BUILD", | |
| """ | |
| genrule( | |
| name = "foo", | |
| srcs = ["dir/file2.txt"], | |
| outs = ["out"], | |
| cmd = "echo $(SRCS) > $@", | |
| ) | |
| """); | |
| var e = assertThrows(BuildFailedException.class, () -> buildTarget("//foo")); | |
| // It is important that the error message says "do not exist", not "are in error". The latter | |
| // would indicate that the corresponding FileStateValue still considers the file to exist. | |
| assertThat(e).hasMessageThat().contains("1 input file(s) do not exist"); | |
| } | |
| @Test | |
| public void fileWithTransientErrorIsFixedAndModifiedAfterUnrelatedBuild() throws Exception { | |
| // Builds garbage collect dirty nodes that they did not need by default, which would remove the | |
| // dirty file state node this test is about. Commands that do not run BuildTool, such as query, | |
| // never do so, and this option disables it for builds, too. | |
| addOptions("--version_window_for_dirty_node_gc=-1"); | |
| write( | |
| "sub/BUILD", | |
| """ | |
| genrule( | |
| name = "copy", | |
| srcs = ["inner/f.txt"], | |
| outs = ["copy.out"], | |
| cmd = "cp $< $@", | |
| tags = ["manual"], | |
| ) | |
| genrule( | |
| name = "other", | |
| outs = ["other.out"], | |
| cmd = "echo other > $@", | |
| ) | |
| """); | |
| Path file = write("sub/inner/f.txt", "v1"); | |
| Path inner = file.getParentDirectory(); | |
| // Populates the directory listing of sub/inner, which the diff processing below consults. | |
| buildTarget("//sub/..."); | |
| try { | |
| // Readable but not searchable: the listing stays valid, but stats of entries fail. | |
| inner.chmod(0444); | |
| assume() | |
| .withMessage("Directory permissions are not enforced for the current user") | |
| .that(statFails(file)) | |
| .isTrue(); | |
| // The file state of sub/inner/f.txt is evaluated to a transient error, which makes that node | |
| // depend on the error transience node. | |
| var e = assertThrows(BuildFailedException.class, () -> buildTarget("//sub:copy")); | |
| assertThat(e).hasMessageThat().contains("1 input file(s) are in error"); | |
| // An unrelated build invalidates the error transience node and thereby dirties the file state | |
| // node without re-evaluating it. | |
| buildTarget("//sub:other"); | |
| } finally { | |
| inner.chmod(0755); | |
| } | |
| // The user fixes the permissions and edits the file. The diff processing finds no usable old | |
| // value for the dirty file state node and injects a freshly computed one. | |
| write("sub/inner/f.txt", "v2"); | |
| buildTargetWithRetryUntilSeesChange("//sub:other", "sub/inner/f.txt"); | |
| buildTarget("//sub:copy"); | |
| assertContents("v2", "//sub:copy"); | |
| } | |
| private static boolean statFails(Path path) { | |
| try { | |
| path.stat(); | |
| return false; | |
| } catch (IOException e) { | |
| return true; | |
| } | |
| } | |
| /** | |
| * Runs {@link #buildTarget(String...)} repeatedly until we observe a change for the given path. | |
| * | |
| * <p>This allows to work around the inherent raciness of {@code LocalDiffAwareness} where the FS | |
| * events are delivered asynchronously and fast running test can trigger an incremental build | |
| * before the change is observed. | |
| */ | |
| private void buildTargetWithRetryUntilSeesChange(String target, String path) throws Exception { | |
| AtomicBoolean changed = new AtomicBoolean(); | |
| runtimeWrapper.registerSubscriber( | |
| new Object() { | |
| @Subscribe | |
| private void onChangedFiles(ChangedFilesMessage changedFiles) { | |
| changed.compareAndSet( | |
| false, changedFiles.changedFiles().contains(PathFragment.create(path))); | |
| } | |
| }); | |
| for (int attempt = 0; attempt < 10; ++attempt) { | |
| buildTarget(target); | |
| if (changed.get()) { | |
| return; | |
| } | |
| } | |
| fail("Didn't observe file change within allowed number of retries"); | |
| } | |
| // This test doesn't use --watchfs functionality, but if the source filesystem doesn't offer diffs | |
| // Bazel must scan the full Skyframe graph anyway, so a bug in checking output files wouldn't be | |
| // detected without --watchfs. | |
| @Test | |
| public void ignoreOutputFilesThenCheckAgainDoesCheck() throws Exception { | |
| if ("bazel".equals(this.getRuntime().getProductName())) { | |
| // Repository options only in Bazel. | |
| addOptions("--noexperimental_check_external_repository_files"); | |
| } | |
| Path buildFile = | |
| write( | |
| "foo/BUILD", | |
| "genrule(name = 'foo', outs = ['out'], cmd = 'cp $< $@', srcs = ['link'])"); | |
| Path outputFile = directories.getOutputBase().getChild("linkTarget"); | |
| FileSystemUtils.writeContentAsLatin1(outputFile, "one"); | |
| buildFile.getParentDirectory().getChild("link").createSymbolicLink(outputFile.asFragment()); | |
| buildTarget("//foo:foo"); | |
| assertContents("one", "//foo:foo"); | |
| addOptions("--noexperimental_check_output_files"); | |
| FileSystemUtils.writeContentAsLatin1(outputFile, "two"); | |
| buildTarget("//foo:foo"); | |
| assertContents("one", "//foo:foo"); | |
| addOptions("--experimental_check_output_files"); | |
| buildTarget("//foo:foo"); | |
| assertContents("two", "//foo:foo"); | |
| } | |
| @Test | |
| public void externalSymlink_doesNotTriggerFullGraphTraversal() throws Exception { | |
| addOptions("--symlink_prefix=/"); | |
| if ("bazel".equals(this.getRuntime().getProductName())) { | |
| // Repository options only in Bazel. | |
| addOptions("--noexperimental_check_external_repository_files"); | |
| } | |
| AtomicInteger calledGetValues = new AtomicInteger(0); | |
| skyframeExecutor() | |
| .getEvaluator() | |
| .injectGraphTransformerForTesting( | |
| NotifyingHelper.makeNotifyingTransformer( | |
| (key, type, order, context) -> { | |
| if (type == NotifyingHelper.EventType.GET_VALUES) { | |
| calledGetValues.incrementAndGet(); | |
| } | |
| })); | |
| write( | |
| "hello/BUILD", | |
| "genrule(name='target', srcs = ['external'], outs=['out'], cmd='/bin/cat $(SRCS) > $@')"); | |
| String externalLink = System.getenv("TEST_TMPDIR") + "/target"; | |
| write(externalLink, "one"); | |
| createSymlink(externalLink, "hello/external"); | |
| // Trivial build: external symlink is not seen, so normal diff awareness is in play. | |
| buildTarget("//hello:BUILD"); | |
| // New package path on first build triggers full-graph work. | |
| calledGetValues.set(0); | |
| // getValuesAndExceptions() called during output file checking (although if an output service is | |
| // able to report modified files in practice there is no iteration). | |
| buildTarget("//hello:BUILD"); | |
| assertThat(calledGetValues.getAndSet(0)).isEqualTo(1); | |
| // Now bring the external symlink into Bazel's awareness. | |
| buildTarget("//hello:target"); | |
| assertContents("one", "//hello:target"); | |
| assertThat(calledGetValues.getAndSet(0)).isEqualTo(1); | |
| // Builds that follow a build containing an external file don't trigger a traversal. | |
| buildTarget("//hello:target"); | |
| assertContents("one", "//hello:target"); | |
| assertThat(calledGetValues.getAndSet(0)).isEqualTo(1); | |
| write(externalLink, "two"); | |
| buildTarget("//hello:target"); | |
| // External file changes are tracked. | |
| assertContents("two", "//hello:target"); | |
| assertThat(calledGetValues.getAndSet(0)).isEqualTo(1); | |
| } | |
| } |