P3-2: Filesystem watchers with debounce #31

Closed
opened 2026-09-10 17:43:26 +00:00 by Cordy · 3 comments
Owner

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

  • Create: internal/watch/watch.go, internal/watch/watch_test.go

Produces

type Watcher interface {
	Start(root string) error
	Events() <-chan string // canonical paths that changed
	Errors() <-chan error
	Close() error
}
func New() Watcher

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_watches cap (often 8192 or 65536). A large tree exhausts it. Detect the failure, log a clear message naming fs.inotify.max_user_watches, and fall back to polling rather than dying.

Steps

  • Write failing tests using t.TempDir():
    • creating a file emits its canonical path
    • ten rapid writes to one file produce one debounced event
    • a file created in a newly created subdirectory is reported
    • Close terminates cleanly with no goroutine leak (verify with goleak or a manual check)
  • Run; confirm failure.
  • Implement.
  • Commit: git commit -s -m "feat(watch): debounced recursive filesystem watcher"

Acceptance criteria

  • Rapid edits coalesce into a single event.
  • New subdirectories are watched automatically.
  • Watch-limit exhaustion degrades to polling with a clear log line, and never crashes.
  • The periodic full rescan remains in place regardless.
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 - Create: `internal/watch/watch.go`, `internal/watch/watch_test.go` ## Produces ```go type Watcher interface { Start(root string) error Events() <-chan string // canonical paths that changed Errors() <-chan error Close() error } func New() Watcher ``` 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_watches` cap (often 8192 or 65536). A large tree exhausts it. Detect the failure, log a clear message naming `fs.inotify.max_user_watches`, and **fall back to polling** rather than dying. ## Steps - [ ] Write failing tests using `t.TempDir()`: - creating a file emits its canonical path - ten rapid writes to one file produce **one** debounced event - a file created in a newly created subdirectory is reported - `Close` terminates cleanly with no goroutine leak (verify with `goleak` or a manual check) - [ ] Run; confirm failure. - [ ] Implement. - [ ] Commit: `git commit -s -m "feat(watch): debounced recursive filesystem watcher"` ## Acceptance criteria - Rapid edits coalesce into a single event. - New subdirectories are watched automatically. - Watch-limit exhaustion degrades to polling with a clear log line, and never crashes. - The periodic full rescan remains in place regardless.
Author
Owner

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

  • Ignore write events on paths whose FileInfo.Hydrated was false immediately before the
    event, when the resulting content matches what the server already has.
  • Better, where the platform offers it: the phase-4 providers know when they are hydrating.
    Windows CfAPI and macOS File Provider both signal hydration explicitly. Expose a suppression
    hook on the Watcher so a provider can mute events for a path it is currently hydrating, and
    prefer 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-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 - Ignore write events on paths whose `FileInfo.Hydrated` was `false` immediately before the event, when the resulting content matches what the server already has. - Better, where the platform offers it: **the phase-4 providers know when they are hydrating.** Windows CfAPI and macOS File Provider both signal hydration explicitly. Expose a suppression hook on the `Watcher` so a provider can mute events for a path it is currently hydrating, and prefer 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.
Author
Owner

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.

  • Paths (P3-R11). Events carry engine paths: sync.Normalised, relative to the sync root, with forward slashes. internal/watch may import internal/sync for Normalise, but internal/sync never imports internal/watch.
  • How #32 consumes events (P3-R12). #32's loop treats events as debounced triggers for a full 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.
  • Suppression hook. Its shape is the implementer's choice, for example Suppress(path) (release func()), reference-counted. It ships with the amendment's hydration test.
  • Dependencies (P3-R22). fsnotify, and any leak checker such as goleak, must build with CGO_ENABLED=0 for linux, darwin and windows. go.mod stays at go 1.25 with no toolchain line.
  • Closing (D4). The Windows ReadDirectoryChangesW backend 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.

## 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. - **Paths (P3-R11).** Events carry engine paths: `sync.Normalise`d, relative to the sync root, with forward slashes. `internal/watch` may import `internal/sync` for `Normalise`, but `internal/sync` never imports `internal/watch`. - **How #32 consumes events (P3-R12).** #32's loop treats events as debounced triggers for a full `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. - **Suppression hook.** Its shape is the implementer's choice, for example `Suppress(path) (release func())`, reference-counted. It ships with the amendment's hydration test. - **Dependencies (P3-R22).** fsnotify, and any leak checker such as goleak, must build with `CGO_ENABLED=0` for linux, darwin and windows. go.mod stays at `go 1.25` with no toolchain line. - **Closing (D4).** The Windows `ReadDirectoryChangesW` backend 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._
Author
Owner

Done

  • addffa0 feat(watch): debounced recursive filesystem watcher
  • d719200 fix(watch): poll a tree too large for a kqueue descriptor each
  • 5ed3204 fix(watch): report a truncation that kqueue raises as NOTE_ATTRIB alone

What was built

  • internal/watch.Watcher{Start,Events,Errors,Close,Suppress} on fsnotify v1.10.1, emitting sync.Normalised, forward-slash paths relative to root.
  • Debounced batches (2s quiet period) and recursive folder watching; new folders are walked and watched, covering files that arrived before the watch existed.
  • OS watch-limit fallback to 30s polling with a log line naming the sysctl; never crashes on ENOSPC/EMFILE/ENFILE.
  • Suppress(path), reference-counted, mutes hydration writes through one settle period past the last release (2026-09-10 amendment).
  • Fix round: a kqueue descriptor budget (quarter of soft RLIMIT_NOFILE, capped by kern.maxfilesperproc on darwin) falls back to polling before EMFILE (F2).
  • Fix round: kqueue file-size/mtime stamps report a truncation (NOTE_ATTRIB alone) 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).
  • Cross-compiles/vets clean on linux/darwin/windows, amd64+arm64, CGO_ENABLED=0; go mod tidy -diff clean.
  • CI run #39 (ci.yml), arm64: green — go vet, go test -coverprofile ./..., total coverage 90.1% (internal/watch 87.1%).
  • CI run #40 (interop.yml), arm64: green — full integration suite against a live cairnd.

Acceptance criteria

  • Canonical path on create — TestCreatingAFileEmitsItsCanonicalPath (NFD write, NFC path).
  • Rapid edits coalesce — TestRapidWritesCoalesceIntoOneEvent (fake clock).
  • New subdirectories watched automatically — TestAFileInANewSubdirectoryIsReported.
  • Close terminates cleanly, no leak — TestCloseTerminatesCleanly (goleak).
  • Watch-limit exhaustion degrades to polling, never crashes — TestWatchLimitExhaustionFallsBackToPolling + TestADescriptorBudgetFallsBackToPolling.
  • Periodic full rescan stays the consumer's job (untouched, P3-R12).
  • Amendment: hydration emits no modification — TestHydrationEmitsNoModification.
  • Amendment: counted Suppress of normalised paths — TestSuppressIsCounted.

Rulings

  • P3-R11: sync.Normalised, forward-slash paths; counted Suppress(path) (release func()) — met.
  • P3-R12: watcher events are debounced triggers for #32's SyncOnce, plus a periodic rescan; no engine API change — met.
  • P3-R22: go 1.25, no toolchain line, go mod tidy -diff clean — met.
  • D4: closed with a caveat — Windows ReadDirectoryChangesW is vetted/cross-built but unverified at runtime until #19's runner exists; the first Windows host must run go test ./internal/watch/, then check Explorer can rename/delete a folder with subfolders under a live watcher (recursive-root-watch remedy if Explorer refuses).
  • F2 ruling: FSEvents remains the owner's long-term call for #32/#39; the kqueue descriptor budget is the mitigation shipped here.

Deferred

  • Muted-path map is never pruned (unbounded growth risk in phase 4).
  • Muted events still reset the tree-wide quiet timer, postponing unrelated flushes during a long hydration.
  • Root removal/rename/unmount silently deafens the watcher (no error, no fallback).
  • No batch boundary on Events for the consumer — carry into #32.
  • fsnotify's kqueue 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.

**Done** - [addffa0](http://192.168.10.245/Cordy/cairn-desktop/commit/addffa02984cbcc6854faff9f402a02b1fe25bf7) feat(watch): debounced recursive filesystem watcher - [d719200](http://192.168.10.245/Cordy/cairn-desktop/commit/d719200d0657955bb7f59d02b23b9632785fec7c) fix(watch): poll a tree too large for a kqueue descriptor each - [5ed3204](http://192.168.10.245/Cordy/cairn-desktop/commit/5ed3204163723736dcb5ffb0959ffed54f0447a7) fix(watch): report a truncation that kqueue raises as NOTE_ATTRIB alone **What was built** - `internal/watch.Watcher{Start,Events,Errors,Close,Suppress}` on fsnotify v1.10.1, emitting `sync.Normalise`d, forward-slash paths relative to root. - Debounced batches (2s quiet period) and recursive folder watching; new folders are walked and watched, covering files that arrived before the watch existed. - OS watch-limit fallback to 30s polling with a log line naming the sysctl; never crashes on ENOSPC/EMFILE/ENFILE. - `Suppress(path)`, reference-counted, mutes hydration writes through one settle period past the last release (2026-09-10 amendment). - Fix round: a kqueue descriptor budget (quarter of soft RLIMIT_NOFILE, capped by `kern.maxfilesperproc` on darwin) falls back to polling before EMFILE (F2). - Fix round: kqueue file-size/mtime stamps report a truncation (`NOTE_ATTRIB` alone) 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). - Cross-compiles/vets clean on linux/darwin/windows, amd64+arm64, `CGO_ENABLED=0`; `go mod tidy -diff` clean. - CI run [#39](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/39) (ci.yml), arm64: green — `go vet`, `go test -coverprofile ./...`, total coverage 90.1% (internal/watch 87.1%). - CI run [#40](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/40) (interop.yml), arm64: green — full integration suite against a live cairnd. **Acceptance criteria** - Canonical path on create — `TestCreatingAFileEmitsItsCanonicalPath` (NFD write, NFC path). - Rapid edits coalesce — `TestRapidWritesCoalesceIntoOneEvent` (fake clock). - New subdirectories watched automatically — `TestAFileInANewSubdirectoryIsReported`. - Close terminates cleanly, no leak — `TestCloseTerminatesCleanly` (goleak). - Watch-limit exhaustion degrades to polling, never crashes — `TestWatchLimitExhaustionFallsBackToPolling` + `TestADescriptorBudgetFallsBackToPolling`. - Periodic full rescan stays the consumer's job (untouched, P3-R12). - Amendment: hydration emits no modification — `TestHydrationEmitsNoModification`. - Amendment: counted `Suppress` of normalised paths — `TestSuppressIsCounted`. **Rulings** - P3-R11: `sync.Normalise`d, forward-slash paths; counted `Suppress(path) (release func())` — met. - P3-R12: watcher events are debounced triggers for #32's `SyncOnce`, plus a periodic rescan; no engine API change — met. - P3-R22: `go 1.25`, no toolchain line, `go mod tidy -diff` clean — met. - D4: closed with a caveat — Windows `ReadDirectoryChangesW` is vetted/cross-built but unverified at runtime until #19's runner exists; the first Windows host must run `go test ./internal/watch/`, then check Explorer can rename/delete a folder with subfolders under a live watcher (recursive-root-watch remedy if Explorer refuses). - F2 ruling: FSEvents remains the owner's long-term call for #32/#39; the kqueue descriptor budget is the mitigation shipped here. **Deferred** - Muted-path map is never pruned (unbounded growth risk in phase 4). - Muted events still reset the tree-wide quiet timer, postponing unrelated flushes during a long hydration. - Root removal/rename/unmount silently deafens the watcher (no error, no fallback). - No batch boundary on `Events` for the consumer — carry into #32. - fsnotify's kqueue `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._
Cordy closed this issue 2026-09-14 18:17:18 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-desktop#31
No description provided.