P3-2: Filesystem watchers with debounce #31
Labels
No labels
data-integrity
engine
platform
procurement
remote
scaffold
ui
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/cairn-desktop#31
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Depends on P3-1.
Goal
React to local changes in seconds instead of waiting for the next poll, without re-walking the whole tree on every keystroke.
Files
internal/watch/watch.go,internal/watch/watch_test.goProduces
Use
github.com/fsnotify/fsnotify— it wraps inotify, FSEvents and ReadDirectoryChangesW behind one API and is the de facto standard.The rules that make this survivable
1. Watchers are a hint, never the source of truth. They miss events — inotify queues overflow, network drives lie, macOS coalesces. Keep the periodic full rescan from the engine as a backstop, at a longer interval (say 5 minutes). A watcher that silently drops an event must not mean a file is never synced. This is not belt-and-braces; it is the difference between "usually syncs" and "syncs".
2. Debounce aggressively. An editor saving a file can emit a dozen events. Collect paths into a set and only wake the engine after ~2 s of quiet. Without this the engine runs constantly and uploads half-written files.
3. Watch recursively, and add watches for new directories. inotify is not recursive; you must add a watch per directory and add new ones as they appear. Forgetting this means files inside newly created folders are invisible until the next full rescan.
4. Respect the OS watch limit. Linux has a per-user
max_user_watchescap (often 8192 or 65536). A large tree exhausts it. Detect the failure, log a clear message namingfs.inotify.max_user_watches, and fall back to polling rather than dying.Steps
t.TempDir():Closeterminates cleanly with no goroutine leak (verify withgoleakor a manual check)git commit -s -m "feat(watch): debounced recursive filesystem watcher"Acceptance criteria
Amendment — 2026-09-10: files-on-demand is now in scope
Virtual files moved to a planned phase (
phase-4-virtual-files). One rule to add here.Hydration is not a modification
When a user opens a placeholder, the OS hydrates it: content is written to disk, and the
filesystem emits write events — indistinguishable, to a naive watcher, from the user having
edited the file.
If the watcher treats those as modifications, the engine re-uploads every file the user merely
opens. On a large tree that is a self-inflicted denial of service against the customer's own
server, and it makes the feature actively worse than not having it.
What to do
FileInfo.Hydratedwasfalseimmediately before theevent, when the resulting content matches what the server already has.
Windows CfAPI and macOS File Provider both signal hydration explicitly. Expose a suppression
hook on the
Watcherso a provider can mute events for a path it is currently hydrating, andprefer that over inference.
Design the interface now with a suppression mechanism, even though nothing uses it until phase
4. Retrofitting it means threading state through the watcher after the fact.
Test to add
Simulate a hydration — placeholder becomes hydrated with content identical to the remote — and
assert the watcher emits no modification event for it.
Amendment — 2026-09-14: phase-3 pre-flight rulings for #31
These rulings come from the phase-3 pre-flight survey. The owner approved posting them and may veto any of them. They bind this issue, in addition to the 2026-09-10 suppression-hook amendment above.
sync.Normalised, relative to the sync root, with forward slashes.internal/watchmay importinternal/syncforNormalise, butinternal/syncnever importsinternal/watch.SyncOnce, alongside a periodic rescan, so the engine's API does not change. Events are hints only: the rescan catches any event that was missed.Suppress(path) (release func()), reference-counted. It ships with the amendment's hydration test.CGO_ENABLED=0for linux, darwin and windows. go.mod stays atgo 1.25with no toolchain line.ReadDirectoryChangesWbackend is cross-built and vetted, but it cannot run here until the Windows runner (#19) exists. kqueue is exercised on the Mac and inotify on the Pi CI. The closing comment records the Windows runtime check as an open checklist item.Posted by Claude on behalf of @Cordy: phase-3 pre-flight, owner-approved process; the owner may veto any point.
Done
What was built
internal/watch.Watcher{Start,Events,Errors,Close,Suppress}on fsnotify v1.10.1, emittingsync.Normalised, forward-slash paths relative to root.Suppress(path), reference-counted, mutes hydration writes through one settle period past the last release (2026-09-10 amendment).kern.maxfilesperprocon darwin) falls back to polling before EMFILE (F2).NOTE_ATTRIBalone) as a modification; Linux (inotify) is unaffected (F1).Tests
go test -race -count=1 ./internal/watch/: 13 top-level tests pass;-count=20: no flakes (22.4s).CGO_ENABLED=0;go mod tidy -diffclean.go vet,go test -coverprofile ./..., total coverage 90.1% (internal/watch 87.1%).Acceptance criteria
TestCreatingAFileEmitsItsCanonicalPath(NFD write, NFC path).TestRapidWritesCoalesceIntoOneEvent(fake clock).TestAFileInANewSubdirectoryIsReported.TestCloseTerminatesCleanly(goleak).TestWatchLimitExhaustionFallsBackToPolling+TestADescriptorBudgetFallsBackToPolling.TestHydrationEmitsNoModification.Suppressof normalised paths —TestSuppressIsCounted.Rulings
sync.Normalised, forward-slash paths; countedSuppress(path) (release func())— met.SyncOnce, plus a periodic rescan; no engine API change — met.go 1.25, no toolchain line,go mod tidy -diffclean — met.ReadDirectoryChangesWis vetted/cross-built but unverified at runtime until #19's runner exists; the first Windows host must rungo test ./internal/watch/, then check Explorer can rename/delete a folder with subfolders under a live watcher (recursive-root-watch remedy if Explorer refuses).Deferred
Eventsfor the consumer — carry into #32.remove(name, false)can leave descriptors open beyond what the budget counts.Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.