Skip to content

Watch mode: keep watcher setup and the readiness signature off the first interaction #1063

Description

@masonmcelvain

What do you want to change?

With watch = true, defer creating the filesystem observer until the first frame has reached idle, and make the readiness signature check (and the bootstrap's initial signature) run through the async git runners instead of spawnSync. On Linux, keep Git-pruned chokidar but move its directory walk off the main thread's first interaction.

Why?

Right after the first frame, useWatchedInput's mount effect builds the watch plan and starts the observer. On Linux that is chokidar walking every non-ignored directory to install per-directory watchers. It is synchronous main-thread work and lands exactly when the user starts pressing keys. Measured in a PTY on this repo (1.3k files, 212 dirs), key sent at first frame:

Config Key answered Main-thread stall right after first frame
watch = false +52ms none from watch
watch = true +180ms 130ms chokidar walk, then 11ms sync git signature

The walk scales with repo size. The #531 benchmark report (docs/watch-benchmark-final.md) recorded 858ms observer-ready wall time on its "Linux big repo" case and noted: "Concentrated setup can still affect the first interaction even when its CPU-equivalent cost is recovered within seconds." That benchmark measured time-to-first-frame, so work that runs after the first frame did not show up in its headline numbers.

History, for context:

How? (optional)

  1. In useWatchedInput, start the controller after the renderer's first idle (or a short setTimeout after mount) rather than in the mount effect. Keep initialWatchSignature captured before content load in loaders.ts so the startup race stays closed; the readiness check still runs once the observer reports ready.
  2. Chunk chokidar's directory walk so it yields to the event loop between directories (chokidar's own _addToNodeFs is recursive and synchronous per subtree; a wrapper can add directories incrementally, or the walk can move to a worker that reports paths back for fs.watch registration on the main thread). Measure with bun run bench:watch-style timing before choosing.
  3. Land the async watchSignature from perf(watch): stop watch-mode signature checks from stalling the review UI #677 on top of refactor(ui): keep history reviews in one renderer #992's async runners: string | Promise<string> with an AbortSignal, using runGitTextAsync, listGitUntrackedFilesAsync, and preventOptionalLocks unchanged. This also addresses the steady-state freeze in Watch mode is very slow #543.
  4. Keep existing invariants: 2s readiness deadline with polling fallback on ENOSPC/EMFILE, no per-directory inotify watches on ignored trees, --no-optional-locks on every watch-time git call (hunk diff —watch creates git locks #398), the Bun ≥ 1.3.14 guard (fix(watch): refuse deadlocking Bun runtimes #902).
  5. Add a PTY test in test/pty/watch.test.ts that sends a key at first paint with watch enabled and asserts it is answered within ~100ms on a fixture with a few hundred directories.

Activity

  1. benvinegar commented on Sep 8, 2026

    @benvinegar
    Member

    Ahhh good find

  2. elucid commented on Sep 9, 2026

    @elucid
    Member

    Landed in two PRs; closing.

    • Step 3 (async watchSignature) → fix(watch): check Git signatures asynchronously #1075. Sync signature stall on the main thread 48 → 3 ms (2k dirs), 185 → 3 ms (deep tree). Extension API 25 adds Promise-returning, cancellable hooks. Also fixed Watch mode is very slow #543.
    • Step 2 (chunk the chokidar walk) → fix(watch): register directory watchers in yielding batches on Linux #1076. Directories are registered in batches of 32 depth-0 watchers with a macrotask yield between batches (public chokidar API only). Worst setup stall 260–750 ms → 10–95 ms; the residual is one indivisible readdir+stat of the widest single directory. Ignored trees still get zero inotify watches. Time-to-ready rises (~1.6× on flat 2k, ~10% on deep trees), so the 2 s readiness deadline now measures time without a completed batch rather than total time; ENOSPC/EMFILE polling fallback is unchanged.
    • Step 1 (defer start to first idle) — dropped. Measured on Linux: a setTimeout(0) deferral moved the 300-dir first-key latency from 121/188 ms to 135/168 ms (median/max). It yields the mount turn but the traversal still lands on the first keypress; fix(watch): register directory watchers in yielding batches on Linux #1076 makes it moot.
    • Step 4 invariants preserved and verified (--no-optional-locks on every watch-time call, Bun ≥1.3.14 guard, no per-directory watches on ignored trees).
    • Step 5 → test/pty/watch-start-integration.test.ts: key sent at first paint on a nested fixture; fails on the previous main 5/5, passes 5/5.

    Whole-TUI, key pressed at first frame → response visible, Linux 4 vCPU, median (n=3):

    fixture watch=off main, watch=on now, watch=on
    2,000 flat dirs 362 ms 650 ms 395 ms
    2,000 dirs, 100 deep 378 ms 1,292 ms 387 ms
    hunk repo + ignored node_modules 377 ms 744 ms 399 ms

    Not addressed here: the ~370 ms watch-off floor itself (handler receives the key in ~14 ms; the rest is rendering), and two pre-existing Linux observation gaps (deleting the watched root; recreating a directory within ~1 ms of deleting it) — filed separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions