Skip to content

fix(review): make cancellation shutdown atomic and time-bounded #1141

Description

@lizhengfeng101

Description

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.golazyFileWriter: os.Create to temp file + rename on close
  • internal/mcp/client.go, cmd/opencodereview/review_cmd.go — bound the MCP close phase
  • cmd/opencodereview/review_cmd.go (and scan_cmd.go once fix(scan): propagate Ctrl-C cancellation to scan runs #996 lands) — signal registration

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.

Activity

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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestgoPull requests that update go codehelp wantedExtra attention is needed

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions