Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
perf(watch): stop watch-mode signature checks from stalling the revie…
…w UI

Watch mode re-runs `watchSignature` on every debounced file event and every
safety poll. The Git backend's implementation ran three `Bun.spawnSync`
calls — a full `git diff`, a `rev-parse`, and `ls-files --others` — so during
active editing, which is exactly when events fire most, the TUI froze once a
second for as long as Git took on the repo.

A render-loop proxy ticking at 10ms across five signature checks saw 5 ticks
before and 12 after over the same wall time. Total time is unchanged: this
does not make the check faster, it stops it freezing the terminal.

Three changes, all Effect-independent findings from the migration spike:

- Add async Git runners alongside the sync ones. Only the spawn differs;
  argument building, exit-code policy, and stderr translation stay in shared
  helpers so the two paths cannot drift. `watchSignature` widens to
  `string | Promise<string>` — backward compatible, an existing synchronous
  implementation still satisfies it — and `ExtensionVcsLoadContext` gains an
  optional `signal`.

- Funnel the controller's cancellation checks. `beginCheck` had four
  `isClosed()` guards and two identical catch blocks, so safety depended on
  remembering a guard at every new await site. One `runCheckStep` helper now
  answers both questions in one place, and closing aborts the signal handed
  to `getSignature`/`refresh` so in-flight work stops rather than running to
  completion for a result nobody reads.

- Extract the named-deadline scheduler. Four deadlines collapsed into one
  chained timer moves to `watchDeadlines.ts` with its own tests, leaving the
  controller to talk about phases instead of timer handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YLF3qLZdxVvT87YXBESEib
  • Loading branch information
claude committed Aug 4, 2026
commit 3032534f56e329add6f2e1d93e2bccddd5c335df
5 changes: 5 additions & 0 deletions .changeset/async-watch-signatures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Watch mode no longer freezes the review UI while it checks for changes, and VCS extensions can now return a promise from `watchSignature`.
5 changes: 4 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,10 @@ checkout some other adapter finds.
`--watch` works through extension adapters. Each operation may add:

- `watchSignature(input, ctx)` — a cheap fingerprint of the reviewed state.
Hunk polls it and reloads when it changes.
Hunk polls it and reloads when it changes. It may return a promise, and
should when it shells out: this runs on every debounced file event and every
safety poll, so a blocking implementation stalls the review UI each time.
`ctx.signal` aborts when the watcher closes.
- `watchPlan(input, ctx)` — the filesystem targets that cover that state, so
Hunk reacts to events instead of polling on a timer.

Expand Down
4 changes: 2 additions & 2 deletions src/core/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe("loadAppBootstrap", () => {

expect(bootstrap.reloadContext.cwd).toBe(dir);
expect(bootstrap.reloadContext.initialWatchSignature).toBeDefined();
expect(computeWatchSignature(bootstrap.input, bootstrap.reloadContext)).not.toBe(
expect(await computeWatchSignature(bootstrap.input, bootstrap.reloadContext)).not.toBe(
bootstrap.reloadContext.initialWatchSignature,
);
} finally {
Expand Down Expand Up @@ -346,7 +346,7 @@ describe("loadAppBootstrap", () => {
);
expect(bootstrap.changeset.files[0]?.path).toBe("example.ts");
expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1);
expect(computeWatchSignature(bootstrap.input, bootstrap.reloadContext)).toBe(
expect(await computeWatchSignature(bootstrap.input, bootstrap.reloadContext)).toBe(
bootstrap.reloadContext.initialWatchSignature!,
);
});
Expand Down
6 changes: 5 additions & 1 deletion src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,11 @@ export async function loadAppBootstrap(
let initialWatchSignature: string | undefined;
if (input.options.watch) {
try {
initialWatchSignature = computeWatchSignature(input, { cwd, gitExecutable, vcsAdapters });
initialWatchSignature = await computeWatchSignature(input, {
cwd,
gitExecutable,
vcsAdapters,
});
} catch {
// A transient signature failure must not prevent an otherwise valid initial review.
}
Expand Down
Loading
Loading