You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ocr review handles SIGINT — and SIGTERM once #1111 lands — by cancelling the context so the run closes through its normal defer chain. The core of that path is solid: finalizeManifest() and session.Finalize() run inside ag.Run (internal/agent/agent.go:390-396), before any cleanup that could stall, so --resume stays usable no matter what happens later in the shutdown.
Three narrower gaps remain in the shutdown tail. All three predate #1111 — that PR only widens the set of scenarios that reach them, from an interactive Ctrl-C to docker stop, CI cancellation and editor cancel buttons.
1. The --output report is not written atomically
lazyFileWriter opens the target with os.Create on the first write (cmd/opencodereview/shared.go:504), truncating in place. The type exists precisely so a run that produces no output leaves an existing file untouched, so the effective guarantee for a target path today is "complete report, or previous content". That guarantee disappears the moment the first byte lands: a write failure, or a SIGKILL arriving inside the emit window, leaves a truncated JSON or SARIF document at a path other tools read. A truncated SARIF is the worst case, since consumers treat one upload as the complete alert set.
Writing to a temp file in the same directory and renaming on Close restores the guarantee for every failure mode.
This also settles a user-visible question that is currently decided by the open mode rather than on purpose: a cancelled run's partial-coverage report replaces a previous complete report at the same path. That may well be the desired behavior — it should just be a decision.
2. Shutdown can take ~15s per stdio MCP server, sequentially
review_cmd.go closes MCP clients in a defer loop, one at a time. Each Close reaches the SDK's pipeRWC.Close (modelcontextprotocol/go-sdk@v1.7.0/mcp/cmd.go:69), which is bounded but generous: close stdin, wait TerminateDuration, send SIGTERM, wait again, send SIGKILL, wait again. internal/mcp/client.go:41 constructs CommandTransport without setting TerminateDuration, so the SDK default of 5s applies at each of the three stages — up to ~15s for one unresponsive server, multiplied by the number of configured stdio servers.
That overruns docker stop's 10s grace period and the VS Code extension's 3s SIGKILL escalation, so the process is hard-killed mid-cleanup. Nothing critical is lost when that happens — the manifest, session_end and the report are already on disk — but the MCP subprocesses are then orphaned, which is exactly the outcome the graceful path exists to prevent.
Options: set a short TerminateDuration, close clients concurrently, and/or put one overall deadline on the close loop.
3. A second Ctrl-C cannot interrupt a slow shutdown
signal.NotifyContext keeps the signal registered until stop() runs, so every signal after the first is delivered to a channel nobody reads and dropped — the default kill behavior stays suppressed for the whole shutdown. While gap 2 plays out the user has no way to abandon it. Owning the channel instead, cancelling on the first signal and exiting on the second, is the usual shape.
Scope
cmd/opencodereview/shared.go — lazyFileWriter: os.Create to temp file + rename on close
internal/mcp/client.go, cmd/opencodereview/review_cmd.go — bound the MCP close phase
The three items are independent and are easier to review as separate PRs.
Acceptance Criteria
--output writes land in a temp file in the target directory and are renamed into place on close; an interrupted or failed write leaves the previous file content intact
Total MCP shutdown time stays well inside 3s regardless of how many stdio servers are configured or how unresponsive they are
A second SIGINT/SIGTERM during shutdown terminates the process instead of being absorbed
Tests cover rename-on-close semantics, an unresponsive stdio MCP server, and second-signal exit
Tests pass (make test)
Code check passes (make check)
Context
Found while reviewing #1111, which extends graceful shutdown to SIGTERM and thereby makes this tail reachable from docker stop, CI job cancellation and editor cancel buttons rather than only from an interactive Ctrl-C. Related: #902 introduced the SIGINT-only NotifyContext; #996 adds the same handling to scan.
Deliberately out of scope, because it needs a maintainer decision before any implementation: a cancelled run exits 1, indistinguishable by exit code from a config error or a provider failure, because the process no longer dies by a signal (launcherExitCode in bin/ocr.js:21 only produces 128+N for signal death). The run manifest does record run_failure.classification = "cancelled", so machine-readable consumers can still tell the difference, and SIGINT has always exited 1 — so this may be working as intended. Restoring 130/143 by re-raising the signal after cleanup would be breaking for action.yml, the launcher and the VS Code extension, and deserves its own discussion.
Description
ocr reviewhandles SIGINT — and SIGTERM once #1111 lands — by cancelling the context so the run closes through its normal defer chain. The core of that path is solid:finalizeManifest()andsession.Finalize()run insideag.Run(internal/agent/agent.go:390-396), before any cleanup that could stall, so--resumestays usable no matter what happens later in the shutdown.Three narrower gaps remain in the shutdown tail. All three predate #1111 — that PR only widens the set of scenarios that reach them, from an interactive Ctrl-C to
docker stop, CI cancellation and editor cancel buttons.1. The
--outputreport is not written atomicallylazyFileWriteropens the target withos.Createon the first write (cmd/opencodereview/shared.go:504), truncating in place. The type exists precisely so a run that produces no output leaves an existing file untouched, so the effective guarantee for a target path today is "complete report, or previous content". That guarantee disappears the moment the first byte lands: a write failure, or a SIGKILL arriving inside the emit window, leaves a truncated JSON or SARIF document at a path other tools read. A truncated SARIF is the worst case, since consumers treat one upload as the complete alert set.Writing to a temp file in the same directory and renaming on
Closerestores the guarantee for every failure mode.This also settles a user-visible question that is currently decided by the open mode rather than on purpose: a cancelled run's partial-coverage report replaces a previous complete report at the same path. That may well be the desired behavior — it should just be a decision.
2. Shutdown can take ~15s per stdio MCP server, sequentially
review_cmd.gocloses MCP clients in adeferloop, one at a time. EachClosereaches the SDK'spipeRWC.Close(modelcontextprotocol/go-sdk@v1.7.0/mcp/cmd.go:69), which is bounded but generous: close stdin, waitTerminateDuration, send SIGTERM, wait again, send SIGKILL, wait again.internal/mcp/client.go:41constructsCommandTransportwithout settingTerminateDuration, so the SDK default of 5s applies at each of the three stages — up to ~15s for one unresponsive server, multiplied by the number of configured stdio servers.That overruns
docker stop's 10s grace period and the VS Code extension's 3s SIGKILL escalation, so the process is hard-killed mid-cleanup. Nothing critical is lost when that happens — the manifest,session_endand the report are already on disk — but the MCP subprocesses are then orphaned, which is exactly the outcome the graceful path exists to prevent.Options: set a short
TerminateDuration, close clients concurrently, and/or put one overall deadline on the close loop.3. A second Ctrl-C cannot interrupt a slow shutdown
signal.NotifyContextkeeps the signal registered untilstop()runs, so every signal after the first is delivered to a channel nobody reads and dropped — the default kill behavior stays suppressed for the whole shutdown. While gap 2 plays out the user has no way to abandon it. Owning the channel instead, cancelling on the first signal and exiting on the second, is the usual shape.Scope
cmd/opencodereview/shared.go—lazyFileWriter:os.Createto temp file + rename on closeinternal/mcp/client.go,cmd/opencodereview/review_cmd.go— bound the MCP close phasecmd/opencodereview/review_cmd.go(andscan_cmd.goonce fix(scan): propagate Ctrl-C cancellation to scan runs #996 lands) — signal registrationThe three items are independent and are easier to review as separate PRs.
Acceptance Criteria
--outputwrites land in a temp file in the target directory and are renamed into place on close; an interrupted or failed write leaves the previous file content intactmake test)make check)Context
Found while reviewing #1111, which extends graceful shutdown to SIGTERM and thereby makes this tail reachable from
docker stop, CI job cancellation and editor cancel buttons rather than only from an interactive Ctrl-C. Related: #902 introduced the SIGINT-onlyNotifyContext; #996 adds the same handling toscan.Deliberately out of scope, because it needs a maintainer decision before any implementation: a cancelled run exits 1, indistinguishable by exit code from a config error or a provider failure, because the process no longer dies by a signal (
launcherExitCodeinbin/ocr.js:21only produces 128+N for signal death). The run manifest does recordrun_failure.classification = "cancelled", so machine-readable consumers can still tell the difference, and SIGINT has always exited 1 — so this may be working as intended. Restoring 130/143 by re-raising the signal after cleanup would be breaking foraction.yml, the launcher and the VS Code extension, and deserves its own discussion.