| // 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 org.junit.Assert.assertThrows; |
| |
| import com.google.common.base.Preconditions; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.devtools.build.lib.cmdline.IgnoredSubdirectories; |
| import com.google.devtools.build.lib.skyframe.DiffAwareness.View; |
| import com.google.devtools.build.lib.skyframe.LocalDiffAwareness.SequentialView; |
| import com.google.devtools.build.lib.testing.common.FakeOptions; |
| import com.google.devtools.build.lib.util.OS; |
| import com.google.devtools.build.lib.vfs.DigestHashFunction; |
| import com.google.devtools.build.lib.vfs.FileSystem; |
| import com.google.devtools.build.lib.vfs.FileSystemUtils; |
| import com.google.devtools.build.lib.vfs.ModifiedFileSet; |
| import com.google.devtools.build.lib.vfs.Path; |
| import com.google.devtools.build.lib.vfs.PathFragment; |
| import com.google.devtools.build.lib.vfs.Root; |
| import com.google.devtools.build.lib.vfs.util.FileSystems; |
| import com.google.devtools.common.options.Options; |
| import com.google.devtools.common.options.OptionsProvider; |
| import com.google.errorprone.annotations.CanIgnoreReturnValue; |
| import java.io.IOException; |
| import java.nio.file.Paths; |
| import java.util.HashSet; |
| import java.util.Set; |
| import org.junit.After; |
| import org.junit.Assume; |
| import org.junit.Before; |
| import org.junit.Rule; |
| import org.junit.Test; |
| import org.junit.rules.TemporaryFolder; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| /** Integration tests for LocalDiffAwareness. */ |
| @RunWith(JUnit4.class) |
| public class LocalDiffAwarenessTest { |
| |
| /** Try this many times to pick up file changes. Inotify needs some nanoseconds of patience. */ |
| private static final int MAX_RETRY_COUNT = 20; |
| |
| private OptionsProvider watchFsEnabledProvider; |
| private LocalDiffAwareness localDiff; |
| private DiffAwareness.View oldView; |
| private Path testCaseRoot; |
| private Path testCaseIgnoredDir; |
| |
| @Rule public final TemporaryFolder tmp = new TemporaryFolder(); |
| |
| @Before |
| public final void initializeSettings() throws Exception { |
| LocalDiffAwareness.Factory factory = |
| new LocalDiffAwareness.Factory( |
| ImmutableList.<String>of(), new FsEventsNativeDepsServiceImpl()); |
| FileSystem fileSystem = FileSystems.getNativeFileSystem(DigestHashFunction.SHA256); |
| testCaseRoot = fileSystem.getPath(tmp.getRoot().getAbsolutePath()); |
| testCaseIgnoredDir = testCaseRoot.getChild("ignored-dir"); |
| testCaseIgnoredDir.createDirectoryAndParents(); |
| |
| LocalDiffAwareness.Options localDiffOptions = |
| Options.getDefaults(LocalDiffAwareness.Options.class); |
| localDiffOptions.setWatchFS(true); |
| watchFsEnabledProvider = FakeOptions.of(localDiffOptions); |
| |
| localDiff = |
| (LocalDiffAwareness) |
| factory.maybeCreate( |
| Root.fromPath(testCaseRoot), |
| IgnoredSubdirectories.of( |
| ImmutableSet.of(testCaseIgnoredDir.asFragment().toRelative())), |
| watchFsEnabledProvider); |
| |
| // Ignore test failures when run on a Mac. |
| // |
| // On a Mac, LocalDiffAwareness.Factory#maybeCreate will produce a MacOSXFsEventsDiffAwareness. |
| // There's a known issue with the underlying implementation |
| // (https://github.com/bazelbuild/bazel/issues/10776); basically all the test cases in here |
| // consistently fail, presumably due to the same underlying issue with FSEvents. Also, |
| // MacOSXFsEventsDiffAwareness is already unit-tested separately anyway (although not very well |
| // because of the bug). |
| Assume.assumeFalse(OS.DARWIN.equals(OS.getCurrent())); |
| } |
| |
| @After |
| public final void closeLocalDiff() throws Exception { |
| localDiff.close(); |
| } |
| |
| @After |
| public final void deleteTestCaseRoot() throws Exception { |
| testCaseRoot.deleteTree(); |
| } |
| |
| private void captureFirstView(OptionsProvider options) throws BrokenDiffAwarenessException { |
| oldView = localDiff.getCurrentView(options); |
| Preconditions.checkNotNull(localDiff); |
| } |
| |
| @Test |
| public void areInSequenceWithEverythingModifiedShouldAlwaysReturnFalse() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| SequentialView old = (SequentialView) oldView; |
| SequentialView everythingMod = (SequentialView) LocalDiffAwareness.EVERYTHING_MODIFIED; |
| assertThat(LocalDiffAwareness.areInSequence(old, everythingMod)).isFalse(); |
| assertThat(LocalDiffAwareness.areInSequence(everythingMod, old)).isFalse(); |
| assertThat(LocalDiffAwareness.areInSequence(everythingMod, everythingMod)).isFalse(); |
| } |
| |
| @Test |
| public void testFileAdded() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } |
| |
| @Test |
| public void testSymlink() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| touch("a"); |
| symlink("b", "a"); |
| new ModifiedFileSetChecker().modify("a").modify("b").check(); |
| } |
| |
| @Test |
| public void testSymlinkBroken() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| symlink("b", "a"); |
| new ModifiedFileSetChecker().modify("b").check(); |
| } |
| |
| @Test |
| public void testFileModified() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| |
| new ModifiedFileSetChecker().check(); |
| |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } |
| |
| @Test |
| public void testIgnoredFileModified() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| touch(testCaseIgnoredDir.getRelative("foo").getPathString()); |
| new ModifiedFileSetChecker().check(); |
| } |
| |
| @Test |
| public void testFileRemoved() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| |
| rm("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } |
| |
| @Test |
| public void testFileAddAndRemove() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| touch("foo.txt"); |
| touch("bar.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").modify("bar.txt").check(); |
| |
| rm("foo.txt"); |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } |
| |
| @Test |
| public void testAddDirectory() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| mkdir("equestria"); |
| touch("equestria/foo.txt"); |
| new ModifiedFileSetChecker().modify("equestria").modify("equestria/foo.txt").check(); |
| } |
| |
| @Test |
| public void testRemoveDirectory() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| mkdir("equestria"); |
| touch("equestria/foo.txt"); |
| new ModifiedFileSetChecker().modify("equestria").modify("equestria/foo.txt").check(); |
| |
| rm("equestria"); |
| new ModifiedFileSetChecker().modify("equestria").modify("equestria/foo.txt").check(); |
| } |
| |
| @Test |
| public void testMoveDirectory() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| mkdir("equestria"); |
| touch("equestria/foo.txt"); |
| new ModifiedFileSetChecker().modify("equestria").modify("equestria/foo.txt").check(); |
| |
| testCaseRoot.getRelative("equestria").renameTo(testCaseRoot.getRelative("equestria2")); |
| // The contents of a moved directory are *not* reported as modified. |
| new ModifiedFileSetChecker() |
| .modify("equestria") |
| .modify("equestria2") |
| .modify("equestria2/foo.txt") |
| .check(); |
| } |
| |
| @Test |
| public void testLotsOfChanges() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| |
| mkdir("pre"); |
| touch("pre/pre.txt"); |
| new ModifiedFileSetChecker().modify("pre").modify("pre/pre.txt").check(); |
| |
| mkdir("a"); |
| touch("a/a1.txt"); |
| touch("a/a2.txt"); |
| rm("a"); |
| mkdir("a"); |
| touch("a/a1.txt"); |
| touch("a/a2.txt"); |
| mkdir("a/b"); |
| touch("a/b/b1.txt"); |
| new ModifiedFileSetChecker() |
| .modify("a") |
| .modify("a/a1.txt") |
| .modify("a/a2.txt") |
| .modify("a/b") |
| .modify("a/b/b1.txt") |
| .check(); |
| |
| rm("a/b/b1.txt"); |
| touch("a/b/b2.txt"); |
| rm("a/b"); |
| mkdir("a/b"); |
| rm("a/b"); |
| mkdir("a/b"); |
| touch("a/b/b3.txt"); |
| new ModifiedFileSetChecker() |
| .modify("a/b") |
| .modify("a/b/b1.txt") |
| .modify("a/b/b2.txt") |
| .modify("a/b/b3.txt") |
| .check(); |
| |
| rm("a/b/b3.txt"); |
| new ModifiedFileSetChecker().modify("a/b/b3.txt").check(); |
| } |
| |
| private static OptionsProvider createWatchFsDisabledProvider() { |
| final LocalDiffAwareness.Options localDiffOptions = |
| Options.getDefaults(LocalDiffAwareness.Options.class); |
| localDiffOptions.setWatchFS(false); |
| return FakeOptions.of(localDiffOptions); |
| } |
| |
| @Test |
| public void testEnableWatchFs() throws Exception { |
| OptionsProvider watchFsDisabledProvider = createWatchFsDisabledProvider(); |
| captureFirstView(watchFsDisabledProvider); |
| |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsDisabledProvider); |
| |
| touch("a.txt"); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsDisabledProvider); |
| |
| touch("b.txt"); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| touch("c.txt"); |
| new ModifiedFileSetChecker().modify("c.txt").check(); |
| } |
| |
| @Test |
| public void testDisableWatchFs() throws Exception { |
| OptionsProvider watchFsDisabledProvider = createWatchFsDisabledProvider(); |
| captureFirstView(watchFsEnabledProvider); |
| |
| new ModifiedFileSetChecker().check(); |
| |
| assertThrows( |
| BrokenDiffAwarenessException.class, |
| () -> localDiff.getCurrentView(watchFsDisabledProvider)); |
| } |
| |
| @Test |
| public void testOverflow() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| for (int i = 0; i < 600; i++) { |
| touch("file" + i + ".txt"); |
| } |
| // Give inotify and JVM poller a moment to queue events |
| Thread.sleep(200); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| // After recovering from overflow, incremental diffing works immediately on the next view. |
| touch("single.txt"); |
| new ModifiedFileSetChecker().modify("single.txt").check(); |
| } |
| |
| @Test |
| public void testDirectoryCreatedDuringOverflow() throws Exception { |
| captureFirstView(watchFsEnabledProvider); |
| mkdir("newdir"); |
| for (int i = 0; i < 600; i++) { |
| touch("file" + i + ".txt"); |
| } |
| Thread.sleep(200); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| // Subdirectories created during overflow are discovered and registered, |
| // so subsequent modifications inside them are tracked incrementally. |
| touch("newdir/inner.txt"); |
| new ModifiedFileSetChecker().modify("newdir/inner.txt").check(); |
| } |
| |
| @Test |
| public void testDirectoryDeletedDuringOverflow() throws Exception { |
| mkdir("deldir"); |
| touch("deldir/f.txt"); |
| captureFirstView(watchFsEnabledProvider); |
| |
| rm("deldir"); |
| for (int i = 0; i < 600; i++) { |
| touch("file" + i + ".txt"); |
| } |
| Thread.sleep(200); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| // Subsequent modifications to other files work incrementally without stale key issues. |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } |
| |
| @Test |
| public void testDirectoryReplacedDuringOverflow() throws Exception { |
| mkdir("dir"); |
| touch("dir/old.txt"); |
| captureFirstView(watchFsEnabledProvider); |
| |
| // Overflow the watch key of the root directory first, so that the move and the re-creation |
| // below are among the events that are dropped. |
| for (int i = 0; i < 600; i++) { |
| touch("file" + i + ".txt"); |
| } |
| // Move the watched directory out of sight and put a different directory in its place. The |
| // watch key of the old directory stays valid, but it no longer watches what is at "dir" now. |
| move("dir", "ignored-dir/moved"); |
| mkdir("dir"); |
| Thread.sleep(200); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| // Overflow recovery must have registered the replacement directory. |
| touch("dir/new.txt"); |
| new ModifiedFileSetChecker().modify("dir/new.txt").check(); |
| } |
| |
| @Test |
| public void testDirectoryMovedAndReplacedDuringOverflow() throws Exception { |
| mkdir("dir1"); |
| touch("dir1/old.txt"); |
| captureFirstView(watchFsEnabledProvider); |
| |
| // Overflow the watch key of the root directory first, so that the move and the re-creation |
| // below are among the events that are dropped. |
| for (int i = 0; i < 600; i++) { |
| touch("file" + i + ".txt"); |
| } |
| // Both the moved directory and its replacement remain under the watch root, so the watch key |
| // of the moved directory must be rebound to its new path instead of being cancelled. |
| move("dir1", "dir2"); |
| mkdir("dir1"); |
| Thread.sleep(200); |
| new ModifiedFileSetChecker().checkEverythingModified(watchFsEnabledProvider); |
| |
| touch("dir1/new.txt"); |
| touch("dir2/moved.txt"); |
| new ModifiedFileSetChecker().modify("dir1/new.txt").modify("dir2/moved.txt").check(); |
| } |
| |
| @Test |
| public void testUnreadableDirectory() throws Exception { |
| mkdir("unreadable"); |
| Path unreadable = testCaseRoot.getRelative("unreadable"); |
| unreadable.chmod(0); |
| try { |
| boolean readable = true; |
| try { |
| var unused = unreadable.getDirectoryEntries(); |
| } catch (IOException e) { |
| readable = false; |
| } |
| // Running as root bypasses directory permissions, in which case there is nothing to test. |
| Assume.assumeFalse(readable); |
| |
| // A directory that cannot be watched must be reported instead of being silently skipped, |
| // which would make all subsequent diffs miss changes under it. |
| assertThrows( |
| BrokenDiffAwarenessException.class, |
| () -> localDiff.getCurrentView(watchFsEnabledProvider)); |
| } finally { |
| unreadable.chmod(0755); |
| } |
| } |
| |
| @Test |
| public void testUnreadableIgnoredDirectory() throws Exception { |
| testCaseIgnoredDir.chmod(0); |
| try { |
| boolean readable = true; |
| try { |
| var unused = testCaseIgnoredDir.getDirectoryEntries(); |
| } catch (IOException e) { |
| readable = false; |
| } |
| // Running as root bypasses directory permissions, in which case there is nothing to test. |
| Assume.assumeFalse(readable); |
| |
| // Not being able to watch a directory that we were told to ignore is fine. |
| captureFirstView(watchFsEnabledProvider); |
| touch("foo.txt"); |
| new ModifiedFileSetChecker().modify("foo.txt").check(); |
| } finally { |
| testCaseIgnoredDir.chmod(0755); |
| } |
| } |
| |
| @Test |
| public void modifiedPathIsntUnderWatchRoot() { |
| java.nio.file.Path otherRootDirectoryNioPath = Paths.get("/notundertestroot"); |
| assertThat(otherRootDirectoryNioPath.startsWith(Paths.get(testCaseRoot.getPathString()))) |
| .isFalse(); |
| |
| View oldView = |
| new LocalDiffAwareness.SequentialView( |
| localDiff, |
| /* position= */ 0, |
| /* modifiedAbsolutePaths= */ ImmutableSet.of(), |
| /* isOverflow= */ false); |
| View newView = |
| new LocalDiffAwareness.SequentialView( |
| localDiff, |
| /* position= */ 1, |
| /* modifiedAbsolutePaths= */ ImmutableSet.of( |
| otherRootDirectoryNioPath.resolve("foo.txt")), |
| /* isOverflow= */ false); |
| Throwable throwable = |
| assertThrows(BrokenDiffAwarenessException.class, () -> localDiff.getDiff(oldView, newView)); |
| assertThat(throwable) |
| .hasMessageThat() |
| // Do a round-trip through PathFragment to deal with Windows path separators. |
| .contains( |
| PathFragment.create("/notundertestroot/foo.txt is not under ").getPathString() |
| + PathFragment.create(testCaseRoot.getPathString()).getPathString()); |
| } |
| |
| private void touch(String pathString) throws IOException { |
| Path path = testCaseRoot.getRelative(pathString); |
| FileSystemUtils.createEmptyFile(path); |
| FileSystemUtils.writeIsoLatin1(path, "Sunshine, sunshine, ladybugs awake!"); |
| } |
| |
| private void mkdir(String pathString) throws IOException { |
| Path path = testCaseRoot.getRelative(pathString); |
| path.createDirectoryAndParents(); |
| } |
| |
| private void rm(String pathString) throws IOException { |
| Path path = testCaseRoot.getRelative(pathString); |
| path.deleteTree(); |
| } |
| |
| private void move(String from, String to) throws IOException { |
| testCaseRoot.getRelative(from).renameTo(testCaseRoot.getRelative(to)); |
| } |
| |
| private void symlink(String from, String to) throws IOException { |
| Path fromPath = testCaseRoot.getRelative(from); |
| Path toPath = testCaseRoot.getRelative(to); |
| FileSystemUtils.ensureSymbolicLink(fromPath, toPath); |
| } |
| |
| private class ModifiedFileSetChecker { |
| private final Set<PathFragment> modified = new HashSet<>(); |
| |
| void check() throws Exception { |
| // Unfortunately, inotify needs a few milliseconds (more than a few in the worst case) |
| // after a change to pick up a list of changed files. Trying a few times to make sure. |
| for (int i = 0; i < MAX_RETRY_COUNT; i++) { |
| Thread.sleep(150); |
| DiffAwareness.View newView = localDiff.getCurrentView(watchFsEnabledProvider); |
| ModifiedFileSet modifiedFileSet = localDiff.getDiff(oldView, newView); |
| oldView = newView; |
| assertThat(modifiedFileSet.treatEverythingAsModified()).isFalse(); |
| if (modifiedFileSet.modifiedSourceFiles().isEmpty()) { |
| continue; |
| } |
| assertThat(modifiedFileSet.modifiedSourceFiles()).isEqualTo(modified); |
| return; |
| } |
| // If we never received any changes, make sure this is what we actually expect. |
| assertThat(modified).isEmpty(); |
| } |
| |
| void checkEverythingModified(OptionsProvider options) throws Exception { |
| DiffAwareness.View newView = localDiff.getCurrentView(options); |
| ModifiedFileSet modifiedFileSet = localDiff.getDiff(oldView, newView); |
| oldView = newView; |
| assertThat(modifiedFileSet.treatEverythingAsModified()).isTrue(); |
| } |
| |
| @CanIgnoreReturnValue |
| ModifiedFileSetChecker modify(String filename) { |
| modified.add(PathFragment.create(filename)); |
| return this; |
| } |
| } |
| } |