//go:build linux package hostfiles import ( "context" "os" "path/filepath" "sync/atomic" "testing" "time" "golang.org/x/sys/unix" "github.com/carlosframework/platform/internal/release" ) // realWatcher returns a working inotify watcher, or skips the test where // the kernel will not give one (a shared machine at its per-user limit). // CI must not skip: see the plan's Task 7, step 10. func realWatcher(t *testing.T, root string, skip func(string, bool) bool) watcher { t.Helper() w, err := newWatcher(root, skip) if err != nil { if os.Getenv("CARLOS_REQUIRE_INOTIFY") != "" { t.Fatalf("inotify is required here: %v", err) } t.Skipf("no inotify instance available: %v", err) } t.Cleanup(func() { w.Close() }) return w } // startReal runs the loop on a real watcher with scans and polling off, // and returns once the first full scan is done: everything after that is // found by an inotify event or not at all. func startReal(t *testing.T, s release.Store, dir string) { t.Helper() w := realWatcher(t, dir, nil) p := open(t, s) stop := startRun(t, p, dir, w, Options{Deadline: 50 * time.Millisecond, Scan: time.Hour, Poll: time.Hour}) t.Cleanup(func() { stop() }) } func TestWatcherPublishesAFileStillHeldOpen(t *testing.T) { s, dir := release.NewDirStore(t.TempDir()), t.TempDir() startReal(t, s, dir) f, err := os.Create(filepath.Join(dir, "open.log")) if err != nil { t.Fatal(err) } defer f.Close() // deliberately not closed before the assertion if _, err := f.WriteString("line one\n"); err != nil { t.Fatal(err) } eventually(t, "open.log published while still open", func() bool { return manifestHas(s, "open.log", sum([]byte("line one\n"))) }) // The first publication could have come from the file's creation. A // second write through the same descriptor produces no create, no // close and no rename: only IN_MODIFY can announce it. if _, err := f.WriteString("line two\n"); err != nil { t.Fatal(err) } eventually(t, "the second write published, still open", func() bool { return manifestHas(s, "open.log", sum([]byte("line one\nline two\n"))) }) } func TestWatcherSeesAFileInADirectoryCreatedAfterItStarted(t *testing.T) { s, dir := release.NewDirStore(t.TempDir()), t.TempDir() startReal(t, s, dir) write(t, dir, "new/deep/a.txt", "a") eventually(t, "new/deep/a.txt published", func() bool { return manifestHas(s, "new/deep/a.txt", sum([]byte("a"))) }) // And a later write inside the new directory is an event, not a scan. time.Sleep(150 * time.Millisecond) write(t, dir, "new/deep/b.txt", "b") eventually(t, "new/deep/b.txt published", func() bool { return manifestHas(s, "new/deep/b.txt", sum([]byte("b"))) }) } func TestWatcherPublishesARenameAsDeleteAndCreate(t *testing.T) { s, dir := release.NewDirStore(t.TempDir()), t.TempDir() write(t, dir, "tmp", "v") startReal(t, s, dir) if err := os.Rename(filepath.Join(dir, "tmp"), filepath.Join(dir, "final")); err != nil { t.Fatal(err) } eventually(t, "final present and tmp tombstoned", func() bool { m, _, _ := Load(context.Background(), s, pfx) return m.Entries["tmp"].Deleted && m.Entries["final"].SHA256 == sum([]byte("v")) }) } func TestWatcherDoesNotWatchASkippedDirectory(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "cache"), 0o755); err != nil { t.Fatal(err) } w := realWatcher(t, root, func(rel string, isDir bool) bool { return isDir && rel == "cache" }) write(t, root, "cache/x", "x") select { case ev := <-w.Events(): t.Fatalf("event from a skipped directory: %+v", ev) case <-time.After(150 * time.Millisecond): } } // unwatched builds a watcher with one directory that has no watch and no // event on its way: the state an overflow leaves when it swallows that // directory's creation. The directory exists before the watcher starts // and is excluded while the watcher is built, so the kernel never queues // an event that could install the watch later behind the test's back. func unwatched(t *testing.T) (w *inotifyWatcher, root string) { t.Helper() root = t.TempDir() if err := os.MkdirAll(filepath.Join(root, "lost"), 0o755); err != nil { t.Fatal(err) } var building atomic.Bool building.Store(true) w = realWatcher(t, root, func(rel string, isDir bool) bool { return building.Load() && isDir && rel == "lost" }).(*inotifyWatcher) building.Store(false) return w, root } // noEventFor fails if an event for rel arrives within 200ms. func noEventFor(t *testing.T, w *inotifyWatcher, rel string) { t.Helper() deadline := time.After(200 * time.Millisecond) for { select { case ev := <-w.Events(): if ev.rel == rel { t.Fatalf("an event for %s, which has no watch", rel) } case <-deadline: return } } } func eventFor(t *testing.T, w *inotifyWatcher, rel string) { t.Helper() deadline := time.After(2 * time.Second) for { select { case ev := <-w.Events(): if ev.rel == rel { return } case <-deadline: t.Fatalf("no event for %s", rel) } } } // Plan round 1 finding 8: a kernel overflow may have swallowed a // directory's creation, so coverage is uncertain until Repair has walked // the tree again. Only Repair can restore it here: remove the walk from // Repair and the last assertion fails. func TestWatcherOverflowMarksCoverageUncertainUntilRepaired(t *testing.T) { w, root := unwatched(t) if w.Degraded() { t.Fatal("degraded before anything went wrong") } write(t, root, "lost/before.txt", "b") noEventFor(t, w, "lost/before.txt") // proves the directory really is unwatched w.handle(0, unix.IN_Q_OVERFLOW, "") if !w.Degraded() { t.Fatal("an overflow left coverage marked complete") } if err := w.Repair(); err != nil { t.Fatal(err) } if w.Degraded() { t.Fatal("still degraded after a successful repair") } write(t, root, "lost/after.txt", "a") eventFor(t, w, "lost/after.txt") } // Plan round 2 finding 5: a failure that arrives while Repair is walking // may concern a directory the walk has already passed. That Repair must // not clear it. func TestWatcherRepairDoesNotEraseAFailureThatArrivedDuringItsWalk(t *testing.T) { w, _ := unwatched(t) w.handle(0, unix.IN_Q_OVERFLOW, "") orig := afterRepairWalk t.Cleanup(func() { afterRepairWalk = orig }) afterRepairWalk = func() { afterRepairWalk = func() {} w.handle(0, unix.IN_Q_OVERFLOW, "") // a second overflow, after the walk } if err := w.Repair(); err != nil { t.Fatal(err) } if !w.Degraded() { t.Fatal("the repair cleared a failure newer than its walk") } if err := w.Repair(); err != nil { t.Fatal(err) } if w.Degraded() { t.Fatal("a repair with nothing new during its walk left the watcher degraded") } } func TestAStoppedWatcherCannotBeRepairedAndClosesItsChannel(t *testing.T) { w := realWatcher(t, t.TempDir(), nil).(*inotifyWatcher) w.Close() select { case _, open := <-w.Events(): if open { t.Fatal("an event after Close") } case <-time.After(2 * time.Second): t.Fatal("the channel did not close") } if err := w.Repair(); err == nil { t.Fatal("a stopped watcher reported a successful repair") } if !w.Degraded() { t.Fatal("a stopped watcher is not degraded") } }