Skip to content

fix(review): stop contacting the git remote from the idle freshness poll; add PLANNOTATOR_GIT_REMOTE_CHECK opt-out and kill orphaned git transports on exit - #1585

Merged
backnotprop merged 9 commits into
mainfrom
fix/idle-remote-check
Sep 21, 2026
Merged

backnotprop merged 9 commits into
mainfrom
fix/idle-remote-check

Conversation

@backnotprop

@backnotprop backnotprop commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

Problem

Closes #1553.

During a code review session that is open but idle — page up, nobody
touching it — the server ran git ls-remote --symref origin HEAD about once a
minute, indefinitely. Closing the page stopped it. Where SSH authentication is
backed by a hardware token, each probe is a physical touch prompt, so the
reporter's YubiKey kept blinking beside an agent that correctly reported itself
as idle; nothing in the UI showed an operation in flight.

Two independent causes, both fixed here:

  1. The client polls GET /api/diff/fresh every 5s to keep the "Diff out of
    date" banner honest. That handler called maybeRefreshRemoteBaseInfo(),
    whose 60s rate limit was the only thing standing between a 5s poll and a
    network round trip — so an idle page produced one remote query per minute
    forever, and a remote that could never answer was retried on that same
    cadence for the life of the session.
  2. Git commands run with interaction: "forbid" are spawned detached in their
    own process group so a timeout can kill the whole git/ssh tree. That
    parent-side timer is the only thing that ever reaps them, and it dies with
    the parent — so a server stopped mid-probe orphaned the git/ssh pair.

Reproduction (before)

A repo whose origin is an unreachable SSH URL (git@192.0.2.1:x/y.git), with
GIT_SSH_COMMAND pointing at a script that logs each invocation with a
timestamp and then hangs. plannotator review from source,
PLANNOTATOR_BROWSER=/usr/bin/true, then /api/diff/fresh polled every 5s for
four minutes with no other interaction:

06:38:06  startup — detectRemoteDefaultCompareTarget → ls-remote
06:38:06  startup — /api/diff → maybeRefreshRemoteBaseInfo
06:38:11  startup — refreshRemoteBaseInfo after the compare-target promise
06:39:13  ← idle freshness poll
06:40:14  ← idle freshness poll
06:41:15  ← idle freshness poll

Six invocations in 4m08s, three of them purely from the idle poll, continuing
for as long as the page stays open. A separate run with no polling at all
isolates the startup window at three, and a stack-trace run identifies each
call site — including that detectRemoteDefaultCompareTarget itself runs an
ls-remote, which is why an opt-out has to skip the whole startup block.

Orphan check, with SIGTERM delivered while a probe is still hanging (the
fake ssh sleeps far past the server's own 5s transport timeout, so only an
exit-time reap could explain a dead child): the transport survives the server.

Change, per runtime

Both runtimes change together, as the "Server Runtimes" section requires: the
Bun server in packages/server/ and the node:http mirror in
apps/pi-extension/server/.

Where the remote probe lives. Removed from /api/diff/fresh; added to
/api/diff/switch, which backs the diff-type and base pickers and the "Diff
out of date · Refresh" button — the interactions where a reviewer is actually
asking for a fresh answer. Startup, the /api/diff page load and the explicit
/api/fetch-base are unchanged. The switch call is fire-and-forget, so a
hanging remote can never make a diff switch wait on the network: that response
carries the cached staleness and the next one carries the refreshed value.
baseBehindRemote still rides every /api/diff/fresh response from the
cached value, so the banner neither flickers nor drops out between refreshes.

Negative caching. A failed probe doubles the interval up to a 15-minute
cap; a success resets it to 60s. The policy is one shared helper
(nextRemoteBaseCheckInterval in packages/shared/review-core.ts) so the two
runtimes cannot drift.

Opt-out. plannotator review --no-git-remote-check, PLANNOTATOR_GIT_REMOTE_CHECK=0
(also false / disabled), or { "gitRemoteCheck": false } in
~/.plannotator/config.json; precedence flag > env > config, resolved by
resolveRemoteCheck in packages/shared/config.ts beside its sibling
resolvers. With it off the session issues zero ls-remote calls, startup
probes included — the startup block is skipped whole, because the compare-target
detection is itself an ls-remote and an opt-out that only silenced the
staleness probe would still prompt for authentication as the review opens. The
base stays whatever local ref discovery resolved, the banner never shows, and
POST /api/fetch-base stays reachable on its own predicate, because an explicit
Fetch is the user asking for the network.

The flag is parsed by the shared parseReviewArgs, so it works on every
host that forwards review arguments — Claude Code, OpenCode and Pi — not only
the Bun CLI. The reporter is on OpenCode, so a Bun-only flag would have missed
them.

Orphaned transports. Both runtimes track the process-group leaders of
in-flight isolated commands and SIGKILL each group from a synchronous
process.on("exit") hook, installed lazily on the first such spawn. "exit" is
the right hook: it runs on every process.exit(), which is where the CLI's
SIGINT/SIGTERM handling already routes. POSIX only, reusing the existing win32
carve-out.

Behavior kept

HTTPS and passwordless-SSH users lose nothing they would notice. The remote is
still queried at startup, on /api/diff, on every diff-type/base switch and on
Fetch, still rate limited to once a minute, and baseBehindRemote still rides
every diff payload and every freshness response. The banner, the one-click
Fetch, the base picker and the startup upgrade of a bare main to origin/main
all behave as before. Only plain local git sessions were ever affected; PR, jj,
GitButler, P4, workspace and static-patch sessions never ran the check.

Behavior changed

A push that lands on the base branch mid-review is no longer noticed within
the minute on its own. It appears on the next refresh, diff switch or page
reload. That is the deliberate trade: the old within-the-minute detection cost
a network round trip per minute per open tab, whether or not anyone was looking.

Also release-note-worthy: with the opt-out on, the compare target comes from
local refs only, so a repo whose origin/HEAD is unset may resolve a different
default than the remote would have reported.

Tests

All dual-runtime where the code is, and each new guard was confirmed to fail
against the previous behavior before being kept:

  • packages/server/review-remote-check.test.ts (new, Bun + Pi, 6 tests). Counts
    ls-remote invocations with a git shim on PATH that logs and then execs the
    real git, against a real local bare remote deliberately left ahead of the
    tracking ref so baseBehindRemote is genuinely true. Guards: an idle session
    issues nothing beyond the startup probes with the clock advanced past the
    interval
    (at real speed the old rate limit would have hidden the bug —
    verified: both runtimes fail this test against the pre-fix handler);
    baseBehindRemote rides all twelve freshness responses; a switch inside the
    interval stays quiet while a switch past it probes exactly once (fails when
    the new call is removed); the opt-out is network-free end to end (fails when
    the startup block is left ungated).
  • packages/server/git-background.test.ts (+1 per runtime): exit kills a
    still-hanging transport, with a command timeout far longer than the child's
    lifetime so only the exit hook can account for the kill. Fails with the hook
    disabled.
  • packages/shared/config.test.ts: a 13-row resolveRemoteCheck precedence
    table plus the shared config-coercion table.
  • packages/shared/review-core.test.ts: backoff doubling to the cap, reset on
    success, and no overshoot.
  • packages/shared/review-args.test.ts and
    apps/pi-extension/review-args-parity.test.ts: the flag parses, composes with
    the other selectors, is absent when not typed (so it cannot outrank the env
    var), and is present in the vendored copy Pi parses through.

Counts, on this branch:

  • bun test packages/server packages/shared apps/pi-extension — 2305 pass,
    1 skip, 0 fail
    , 7569 expect() calls, 152 files.
  • bun test (full) — 4994 pass, 1172 skip, 0 fail, 46594 expect() calls,
    534 files.
  • tsc --noEmit across all nine project configs — clean.

Reproduction (after)

Same fixture, same four-minute idle poll, rebuilt from this branch:

07:14:43  startup
07:14:43  startup
07:14:47  startup
(nothing for the remaining ~4 minutes of idle polling)

Three invocations, all inside the first five seconds. A diff switch past the
interval triggers exactly one more. SIGTERM during an in-flight probe leaves no
surviving child, where the same run with the exit hook disabled orphans it.

Three shared pieces the review servers and every host will use for #1553:

- `resolveRemoteCheck(cliNoRemoteCheck, config, env)` beside the sibling
  boolean resolvers, with the usual precedence — the `review
  --no-remote-check` flag beats `PLANNOTATOR_REMOTE_CHECK`, which beats
  `{ "remoteCheck": false }` in config.json — and the existing coercion for
  hand-edited config values.
- `--no-remote-check` in `parseReviewArgs`. Every host (Claude Code,
  OpenCode, Pi) parses review arguments through this one function, so the
  flag reaches all three rather than being a Bun-CLI-only escape hatch. The
  field is ABSENT when the flag is not typed: `true` would outrank the env
  var and config key and make those opt-outs unreachable.
- `nextRemoteBaseCheckInterval` plus the two interval constants, so the
  probe cadence and its failure backoff are one policy both runtimes share
  instead of duplicated arithmetic that can drift.
Closes the reported half of #1553. `/api/diff/fresh` is polled every 5s for
as long as the review page is open, and it carried the 60s remote probe, so
an untouched review kept running `git ls-remote --symref origin HEAD` once a
minute for the life of the tab. On a smartcard-backed SSH setup that is one
hardware-key touch prompt per minute with nothing on screen to explain it —
the reporter's YubiKey blinking beside an idle agent.

Both runtimes (Bun `packages/server/review.ts` and the Pi `node:http` mirror
in `apps/pi-extension/server/serverReview.ts`):

- The probe moves off `/api/diff/fresh` and onto `/api/diff/switch`, which
  backs the diff-type and base pickers AND the "Diff out of date · Refresh"
  button — the interactions where a reviewer is actually asking for a fresh
  answer. Startup, `/api/diff` and the explicit `/api/fetch-base` are
  unchanged. It is kicked off fire-and-forget so a hanging remote can never
  make a diff switch wait on the network.
- `baseBehindRemote` still rides EVERY freshness response from the cached
  value, so the "behind GitHub" banner neither flickers nor disappears.
- Failure backoff: a null probe doubles the interval up to 15 minutes and a
  success resets it to 60s, through the shared policy helper. Retrying an
  unreachable remote on the base cadence forever is what made the loop
  expensive rather than merely chatty.
- `--no-remote-check` / `PLANNOTATOR_REMOTE_CHECK` / `{ "remoteCheck": false }`
  make the session network-free. The startup block is skipped WHOLE, because
  `detectRemoteDefaultCompareTarget` runs an ls-remote of its own — an
  opt-out that silenced only the staleness probe would still prompt for
  authentication when the review opens. The base then stays whatever local
  ref discovery resolved. `/api/fetch-base` moves to its own predicate and
  stays reachable: an explicit Fetch is the user asking for the network.

Tests are dual-runtime and were each confirmed to fail against the previous
behavior: the idle-session guard advances a faked clock past the interval
before polling (at real speed the old rate limit would have hidden the bug),
the switch guard fails when the new call is removed, and the opt-out guard
fails when the startup block is left ungated.
The second half of #1553. Git commands run with `interaction: "forbid"` are
spawned detached in their own process group so a timeout can kill the whole
git/ssh tree — but that parent-side timer is the ONLY thing that ever reaps
them, and it dies with the parent. A server stopped while an ls-remote was
still waiting on authentication therefore left the pair orphaned, holding the
SSH agent busy long after the review window was gone.

Both runtimes now track the process-group leaders of in-flight isolated
commands and SIGKILL each group from a synchronous `process.on("exit")` hook,
installed lazily on the first such spawn. "exit" is the right hook: it runs
on every `process.exit()`, which is where the CLI's SIGINT/SIGTERM handling
already routes, and `process.kill` is synchronous. POSIX only, reusing the
existing win32 carve-out — the negative-pid group signal has no Windows
equivalent, and spawning taskkill from an exit handler is not worth it.

The new dual-runtime test gives the command a timeout far longer than the
child process's own lifetime, so only the exit hook can account for the
transport being dead; it fails with the hook disabled.
Claude Code (both the CLI and the opencode-review stdin path), the OpenCode
plugin, and Pi all forward the parsed bit to the review server, which
resolves it against PLANNOTATOR_REMOTE_CHECK and config.remoteCheck in one
place. The reporter is on OpenCode, so a Bun-CLI-only flag would have missed
them.

The flag is documented in the `review` subcommand help; the top-level usage
line stays the concise summary it already is (it omits --local/--no-local
and --json for the same reason).
- AGENTS.md: a PLANNOTATOR_REMOTE_CHECK env-table row spelling out exactly
  which interactions probe the remote, the 60s rate limit, the failure
  backoff, and what "off" means; plus the `/api/diff/fresh` row (now
  strictly local) and the `/api/diff/switch` row (now carries the probe).
- README.md: the sentence at line 153 said there is no opt-out. There is one
  now, and the paragraph also says when the probe runs, so "an idle review
  page makes no network calls" is checkable rather than implied.
- Marketing: an environment-variables row, the code-review guide's
  "Baseline is behind" section (including the trade-off — a mid-review push
  is noticed on the next refresh, switch, or reload rather than within the
  minute), and the blog line that described the ls-remote behavior.
- The plannotator skill's review reference, which the freshness test pins
  against cli.ts.
A rejected stdout/stderr read would otherwise leave the process-group
leader in the exit reaper's set, where a recycled pid could be signalled
at process exit. The node:http mirror already untracks on both close and
error.
The Fetch control is rendered only from the "Baseline is behind" banner
(packages/review-editor/App.tsx), and with the remote check off that
banner never appears, so "Fetch still works" read as a UI promise the
session cannot keep. The /api/fetch-base endpoint does stay reachable,
and its post-fetch refreshRemoteBaseInfo() is a no-op under the opt-out;
say both.
…eCheck / --no-git-remote-check so it cannot read as remote-session mode
@backnotprop backnotprop changed the title fix(review): stop contacting the git remote from the idle freshness poll; add PLANNOTATOR_REMOTE_CHECK opt-out and kill orphaned git transports on exit fix(review): stop contacting the git remote from the idle freshness poll; add PLANNOTATOR_GIT_REMOTE_CHECK opt-out and kill orphaned git transports on exit Sep 21, 2026
@backnotprop
backnotprop merged commit ad47a54 into main Sep 21, 2026
28 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.

Code review leaves git ls-remote running and repeatedly triggers hardware-backed SSH authentication

1 participant