Skip to content

feat(watch): replace 250 ms watch polling with evented filesystem observation#531

Merged
benvinegar merged 16 commits into
mainfrom
elucid/file-watch
Jul 16, 2026
Merged

feat(watch): replace 250 ms watch polling with evented filesystem observation#531
benvinegar merged 16 commits into
mainfrom
elucid/file-watch

Conversation

@elucid

@elucid elucid commented Jul 13, 2026

Copy link
Copy Markdown
Member

Summary

Replace --watch's 250 ms Git polling loop with filesystem event hints plus the existing authoritative Git signature. Watch remains continuous—passively viewed sessions stay fresh—while idle sessions stop spawning several Git commands four times per second.

PR #520 identified the same important unattended-session cost and proposed pausing polling after UI inactivity. This PR takes a different approach that preserves watch semantics and keeps filesystem observation independent of keyboard and mouse activity.

Why a hybrid watcher

  • macOS and Windows: native recursive fs.watch, where the OS can observe a whole tree with a small constant number of registrations.
  • Linux and portable fallback: Chokidar recursion pruned with ignored roots reported by Git, avoiding a native inotify registration for every directory—including ignored dependency trees.

This is a standard shape for development tooling: Turbopack similarly uses efficient recursive observation on macOS/Windows and selective non-recursive observation on Linux. Git remains Hunk's source of truth; events only tell Hunk when it may be worth recomputing the signature.

A 2-second readiness deadline and ENOSPC/EMFILE handling close partial observers and fall back to a safe 2-second poll. A healthy observer retains a 10-second safety check for missed events or unusual filesystems.

macOS and Linux benchmark summary

The current benchmark report compares exact compiled base and candidate SHAs with Bun 1.3.14 across a little repo (1,260 subdirectories) and big repo (25,223 subdirectories).

Platform Startup change 120-second idle CPU 120-second Git calls
macOS arm64 +27 to +61 ms 4.4–6.4× less 35–36× fewer
Linux x64 +21 to +24 ms 1.8–5.4× less 36× fewer

All measured refresh trials were correct for tracked writes, atomic saves, and relevant untracked creation. Candidate p95 remained below 610 ms. The report contains the frozen provenance, raw-artifact checksums, full startup/idle/latency tables, and disclosed Linux recovery evidence.

Windows status

Windows unit and focused watch tests pass, and native/Chokidar observer probes both reach ready. We were not able to complete the full TUI benchmark matrix yet:

  • Windows ARM64 is blocked by Bun 1.3.14/OpenTUI FFI initialization.
  • Windows x64 GitHub runners are blocked by a surviving ConPTY child during headless teardown.

Windows performance is therefore not inferred from macOS, and no Windows startup, CPU, memory, or latency claim is made. Follow-up work will fix the Windows TUI harness and run the same two-fixture campaign for both the native production path and forced Chokidar comparison.

How it works

filesystem event (hint)
  -> debounce (200 ms quiet / 1 s maximum)
  -> recompute authoritative signature
  -> refresh only when it changed
  • Backend-neutral watch plans describe worktree, metadata, direct-file, patch, and sidecar targets.
  • Git plans handle ignored roots, linked-worktree metadata, refs, the index, and stash state.
  • A deterministic controller serializes checks and refreshes and guarantees one trailing check for events received during a refresh.
  • The old 250 ms setInterval path is removed from the UI.

Benchmark harness

The WIP harness lives on elucid/watch-benchmark-harness. It freezes source/binary provenance, builds portable directory-heavy fixtures, runs 120-second idle cohorts and TUI latency trials, and retains raw JSON/log artifacts. The campaign is intentionally too heavy for normal CI; once the Windows blockers are resolved, the branch will be adjusted for manual/ad-hoc runs rather than every PR push.

Scope and verification

  • Evented coverage: Git working-tree/staged/range/show/stash reviews, direct diffs, patch files, difftool inputs, and agent-context sidecars.
  • Jujutsu and Sapling currently use the 2-second polling fallback pending provider-specific plans.
  • Focused watch/controller/observer tests, source and compiled PTY regressions, typecheck, lint, packaging, and Nix dependency packaging were exercised during implementation.
  • Adds chokidar@^4.0.3 as a bundled runtime dependency and includes patch changesets.

@socket-security

socket-security Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedchokidar@​4.0.39910010081100

View full report

@benvinegar

Copy link
Copy Markdown
Member

Nice

@elucid
elucid force-pushed the elucid/file-watch branch 2 times, most recently from 20f5fc7 to fd00dd6 Compare July 14, 2026 18:27
@elucid

elucid commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Frozen watch benchmark campaign: watch-20260714T185115Z-b04ed2b0b-h4f35a0ef

  • base: 04ed2b0bd51aae633ab94b3a6d157d5d5e568dd0
  • candidate: 4f35a0ef67f9d8094b4b3ff5bd74c3b74940dd57
  • harness: bcfb232b4e83e3c8a07834c5e114e7f0f364e8d5
  • modem fixture: 85caaceeff750eeceb2e26a7336c8aef1d8372f5

The candidate merge-base equals the frozen base. Any revision change invalidates this campaign ID.

@elucid
elucid force-pushed the elucid/file-watch branch from 10f6017 to e19943f Compare July 15, 2026 14:55
@elucid
elucid marked this pull request as ready for review July 15, 2026 15:46
@greptile-apps

greptile-apps Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the --watch mode's 250 ms setInterval Git polling loop with a hybrid filesystem event system: native recursive fs.watch on macOS/Windows, and Git-ignored-root\u2013pruned Chokidar on Linux, with filesystem events serving only as hints while Git's authoritative signature remains the source of truth.

  • New modules \u2014 watchController.ts (deterministic debounce/serialization state machine), watchObserver.ts (backend-neutral multi-target observer with startup deadline and ENOSPC/EMFILE degradation), watchPlan.ts (backend-neutral plan builder deriving worktree, metadata, and sidecar targets from the active review input).
  • VCS integration \u2014 Git adapter gains watchPlan implementations covering working-tree diffs, git show, stash, linked-worktree metadata (gitDir/commonDir), and ignored-directory pruning via GIT_OPTIONAL_LOCKS=0; jj and sl adapters pass cwd context; all VCS signatures now carry preventOptionalLocks.
  • UI migration \u2014 App.tsx polling useEffect removed; replaced by useWatchedInput hook with an injectable WatchedInputRuntime seam for deterministic test control.

Confidence Score: 4/5

The core controller and observer are well-tested and structurally sound; the main concern is a coverage-override in watchPlan that silently slows jj/sl VCS change detection when an agent-context file is present.

The controller state machine is thoroughly exercised by deterministic unit tests, real-filesystem integration tests, and end-to-end PTY scenarios. The one functional gap is in watchPlan.ts: when a jj or sl adapter reports poll-only coverage but the user also supplies --agent-context, the unconditional coverage = hybrid override causes the WatchController to use the 10-second healthy safety interval for VCS change detection instead of the 2-second degraded poll interval.

src/core/watchPlan.ts — the coverage override at line 141 deserves a second look before merge.

Important Files Changed

Filename Overview
src/core/watchPlan.ts New plan builder correctly groups file targets and routes VCS inputs to adapter plans. However, adding an agent-context sidecar unconditionally overrides poll-only coverage to hybrid, causing jj/sl users with agent context to get 10 s VCS detection instead of the stated 2 s fallback.
src/core/watchController.ts New deterministic controller that serializes debounce, check, and refresh phases; handles startup-deadline degradation, ENOSPC/EMFILE resource limits, duplicate-error rate limiting, and dirty-flag trailing checks. State machine is well-tested and guards are correct.
src/core/watchObserver.ts New hybrid observer selects native recursive (macOS/Windows) or Chokidar-pruned (Linux) backends per target. Correct lifecycle management, isClosed guards, and microtask-based ready signaling. Minor O(depth) cost in createIgnoredRootMatcher on the hot event path.
src/core/watch.ts Signature computation now uses a stable WatchSignatureContext (cwd + gitExecutable) and resolves relative input paths correctly; straightforward and correct refactoring.
src/ui/hooks/useWatchedInput.ts New hook owns the observer and controller lifecycle; uses refreshRef for stable refresh identity, handles plan resolution errors, and propagates pollOnly from plan coverage. Correctly closed and recreated when input/reloadContext change.
src/ui/App.tsx Replaces the 250 ms setInterval polling useEffect with useWatchedInput; removes direct computeWatchSignature import. Clean replacement with no logic drift.
src/core/vcs/git.ts Adds buildGitWatchPlan wiring worktree, metadata (gitDir/commonDir), and ignored-root targets; all watchSignature calls gain cwd + gitExecutable context and preventOptionalLocks. resolveGitMetadata falls back gracefully for old Git versions.
src/core/git.ts Adds GIT_OPTIONAL_LOCKS=0 support, resolveGitMetadata with --git-common-dir fallback, and listGitIgnoredDirectoryRoots with safe empty fallback. Changes are read-only and correct.
src/core/loaders.ts Captures initialWatchSignature before content loads to avoid a startup race, populates reloadContext, and threads repoRoot from VCS load results. Error handling around signature pre-computation is correct.
src/core/watchController.test.ts Comprehensive deterministic tests covering debounce, maximum delay, trailing checks, startup degradation, ENOSPC/EMFILE, close idempotency, and controller replacement. FakeWatchClock is well-designed.
src/core/watchObserver.fs.test.ts Real-filesystem integration tests covering write, atomic rename, delete/recreate, ignored-root filtering, close-and-suppress, and the worktree metadata churn case. Bounded timeouts prevent hanging on CI.
test/pty/watch.test.ts End-to-end PTY tests for direct-file atomic replacement and linked-worktree tracked-file detection; explicit HUNK_TEST_EXECUTABLE support for pre-compiled binary benchmarking.
src/hunk-session/sessionFileBounds.ts Session reload bounds now use reloadContext.repoRoot ?? reloadContext.cwd instead of changeset.sourceLabel, which is a more reliable and accurate source of truth.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant FS as Filesystem
    participant Obs as WatchObserver
    participant Ctrl as WatchController
    participant Sig as getSignature
    participant UI as App refresh

    Note over Obs,Ctrl: Startup
    Obs->>Ctrl: onReady
    Ctrl->>Sig: beginCheck
    Sig-->>Ctrl: signature matches, idle

    Note over FS,Ctrl: Event-driven path
    FS->>Obs: raw fs event
    Obs->>Obs: ignored-root filter
    Obs->>Ctrl: onEvent
    Ctrl->>Ctrl: debounce 200ms quiet / 1s max
    Ctrl->>Sig: beginCheck
    alt signature changed
        Sig-->>Ctrl: new signature
        Ctrl->>UI: refresh
        UI-->>Ctrl: done
        Ctrl->>Ctrl: update appliedSignature, idle
    else unchanged
        Sig-->>Ctrl: same signature, idle
    end

    Note over Ctrl: Safety poll path
    Ctrl->>Ctrl: safety timer 10s healthy / 2s degraded
    Ctrl->>Sig: beginCheck

    Note over Ctrl: Degradation
    Obs->>Ctrl: onError ENOSPC or EMFILE
    Ctrl->>Obs: close
    Ctrl->>Ctrl: degraded true, 2s safety poll
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant FS as Filesystem
    participant Obs as WatchObserver
    participant Ctrl as WatchController
    participant Sig as getSignature
    participant UI as App refresh

    Note over Obs,Ctrl: Startup
    Obs->>Ctrl: onReady
    Ctrl->>Sig: beginCheck
    Sig-->>Ctrl: signature matches, idle

    Note over FS,Ctrl: Event-driven path
    FS->>Obs: raw fs event
    Obs->>Obs: ignored-root filter
    Obs->>Ctrl: onEvent
    Ctrl->>Ctrl: debounce 200ms quiet / 1s max
    Ctrl->>Sig: beginCheck
    alt signature changed
        Sig-->>Ctrl: new signature
        Ctrl->>UI: refresh
        UI-->>Ctrl: done
        Ctrl->>Ctrl: update appliedSignature, idle
    else unchanged
        Sig-->>Ctrl: same signature, idle
    end

    Note over Ctrl: Safety poll path
    Ctrl->>Ctrl: safety timer 10s healthy / 2s degraded
    Ctrl->>Sig: beginCheck

    Note over Ctrl: Degradation
    Obs->>Ctrl: onError ENOSPC or EMFILE
    Ctrl->>Obs: close
    Ctrl->>Ctrl: degraded true, 2s safety poll
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/core/watchPlan.ts:133-142
**Sidecar overrides poll-only coverage, doubling VCS-change latency for jj/sl**

When a VCS adapter (jj or sl) returns `coverage: "poll-only"` but the user supplies an `--agent-context` file, the unconditional `coverage = "hybrid"` on line 141 causes `useWatchedInput` to pass `pollOnly: false` to `createWatchController`. The controller then uses `healthyCheckMs` (10 s) for its safety interval instead of `degradedCheckMs` (2 s). For a jj/sl user with an agent-context file, a committed or amended revision goes undetected for up to 10 s — five times longer than the "2-second polling fallback" the PR description promises for those adapters.

The fix is to preserve the adapter's coverage when overriding it toward hybrid, or to keep `pollOnly: true` so the controller continues using the 2 s interval even while the sidecar event source is active.

### Issue 2 of 2
src/core/watchObserver.ts:52-62
**Ancestor-walk in `createIgnoredRootMatcher` is O(depth) per event on the hot path**

`createIgnoredRootMatcher` returns a closure that climbs the directory tree on every raw filesystem event (one `dirname` per path segment). For a typical monorepo with 15 levels of nesting and `node_modules` ignored, every event under `src/` results in ~15 string comparisons and allocations. On Linux/portable backends the ignored matcher is called by Chokidar's own traversal as well, so events within deep trees are charged twice. Depth is bounded in practice, but the allocation-per-level cost is worth noting if high-frequency saves in deeply nested directories prove noisy. A path-prefix check (`current.startsWith(root + sep)`) would avoid the inner loop entirely for roots that share a common prefix with the event path.

Reviews (1): Last reviewed commit: "docs(watch): preserve preliminary benchm..." | Re-trigger Greptile

Comment thread src/core/watchPlan.ts
Comment on lines +133 to +142

if (input.options.agentContext) {
fileTargets.push({ path: input.options.agentContext, source: "sidecar" });
coverage = "hybrid";
}

return {
coverage,
targets: [...adapterTargets, ...groupFileTargets(fileTargets, context)],
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Sidecar overrides poll-only coverage, doubling VCS-change latency for jj/sl

When a VCS adapter (jj or sl) returns coverage: "poll-only" but the user supplies an --agent-context file, the unconditional coverage = "hybrid" on line 141 causes useWatchedInput to pass pollOnly: false to createWatchController. The controller then uses healthyCheckMs (10 s) for its safety interval instead of degradedCheckMs (2 s). For a jj/sl user with an agent-context file, a committed or amended revision goes undetected for up to 10 s — five times longer than the "2-second polling fallback" the PR description promises for those adapters.

The fix is to preserve the adapter's coverage when overriding it toward hybrid, or to keep pollOnly: true so the controller continues using the 2 s interval even while the sidecar event source is active.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/watchPlan.ts
Line: 133-142

Comment:
**Sidecar overrides poll-only coverage, doubling VCS-change latency for jj/sl**

When a VCS adapter (jj or sl) returns `coverage: "poll-only"` but the user supplies an `--agent-context` file, the unconditional `coverage = "hybrid"` on line 141 causes `useWatchedInput` to pass `pollOnly: false` to `createWatchController`. The controller then uses `healthyCheckMs` (10 s) for its safety interval instead of `degradedCheckMs` (2 s). For a jj/sl user with an agent-context file, a committed or amended revision goes undetected for up to 10 s — five times longer than the "2-second polling fallback" the PR description promises for those adapters.

The fix is to preserve the adapter's coverage when overriding it toward hybrid, or to keep `pollOnly: true` so the controller continues using the 2 s interval even while the sidecar event source is active.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/core/watchObserver.ts
Comment on lines +52 to +62
const absolute = resolve(path);
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
}

/** Build an ancestor-set matcher whose cost depends on path depth, not ignored-root count. */
function createIgnoredRootMatcher(ignoredRoots: string[]) {
const roots = new Set(ignoredRoots.map(normalizedPath));
return (path: string) => {
let current = normalizedPath(path);
for (;;) {
if (roots.has(current)) return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Ancestor-walk in createIgnoredRootMatcher is O(depth) per event on the hot path

createIgnoredRootMatcher returns a closure that climbs the directory tree on every raw filesystem event (one dirname per path segment). For a typical monorepo with 15 levels of nesting and node_modules ignored, every event under src/ results in ~15 string comparisons and allocations. On Linux/portable backends the ignored matcher is called by Chokidar's own traversal as well, so events within deep trees are charged twice. Depth is bounded in practice, but the allocation-per-level cost is worth noting if high-frequency saves in deeply nested directories prove noisy. A path-prefix check (current.startsWith(root + sep)) would avoid the inner loop entirely for roots that share a common prefix with the event path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/watchObserver.ts
Line: 52-62

Comment:
**Ancestor-walk in `createIgnoredRootMatcher` is O(depth) per event on the hot path**

`createIgnoredRootMatcher` returns a closure that climbs the directory tree on every raw filesystem event (one `dirname` per path segment). For a typical monorepo with 15 levels of nesting and `node_modules` ignored, every event under `src/` results in ~15 string comparisons and allocations. On Linux/portable backends the ignored matcher is called by Chokidar's own traversal as well, so events within deep trees are charged twice. Depth is bounded in practice, but the allocation-per-level cost is worth noting if high-frequency saves in deeply nested directories prove noisy. A path-prefix check (`current.startsWith(root + sep)`) would avoid the inner loop entirely for roots that share a common prefix with the event path.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@benvinegar

Copy link
Copy Markdown
Member

I think a follow up would be to document the watch* files more (like at the top of the file). And maybe some guidance for how other VCSs can tap in.

@benvinegar
benvinegar merged commit 2458366 into main Jul 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants