blob: 911f55b7d7004a3430aef3664bb7d09f0a7da2eb [file]
// 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;
}
}
}