fix(llm): unblock subprocess reads when a worker escapes the process group#508
Conversation
…group A reviewer task that hits its per-task timeout has its context canceled, which cmd.Cancel turns into a SIGKILL of the claude child's process group. But claude's node workers make themselves process-group leaders, so they escape the group kill and survive, holding the subprocess stdout/stderr pipes open. A consumer's synchronous scanStdout/io.ReadAll then blocks on a read that never sees EOF, so cmd.Wait is never reached — the whole cr run wedges at 0% CPU holding the ledger until an external timeout. Slower (Opus) reviews that trip the per-task timeout hit this reliably; fast ones that finish before the timeout do not. Fix in LaunchProcess, shared by every subprocess adapter: a grace after the process context ends, force stdout/stderr closed so the pending read unblocks and the task fails cleanly. Also set cmd.WaitDelay as a backstop for cmd.Wait. The synchronous stdout drain is unchanged, so no output is lost on the happy path. Adds a regression test that wedges before this change and passes after.
piekstra-dev
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 042304242e4e
Profile: reviewer - Posting as: piekstra-dev
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 1 |
| structure:repo-health | 1 |
go:implementation-tests (1 finding)
Nits - internal/llm/subprocess_launch_unix_test.go:35
The Python worker's
time.sleep(30)far outlasts the 5-second test deadline. Because the worker has calledos.setsid()it escapes the process-group kill and becomes an orphan reparented to init; it will run for up to 30 seconds after the test exits. Across repeated or parallel test runs this accumulates stray Python processes. A 3–5 second sleep is enough to hold the pipe open through the 5-second test timeout and the 100 ms grace period without leaving long-lived orphans. Change the script totime.sleep(5)(or similar) to keep the scenario valid while bounding the orphan lifetime.
structure:repo-health (1 finding)
Minor - internal/llm/subprocess.go:120
The goroutine that force-closes
stdout/stderrfires on every context end — including normal successful completion — not only the stuck-pipe scenario it was added for.When a task finishes cleanly the adapter eventually calls the derived
cancel(), which immediately satisfies<-procCtx.Done(). The goroutine then sleepssubprocessWaitDelay(10 s) and closes pipes the process already closed. Every subprocess invocation adds a 10-second goroutine tail, and at the same momentcmd.WaitDelay(set to the same value) triggers Go's exec internals to also close those pipes — so both paths race to close identical*os.Filedescriptors. IndividualClose()errors are suppressed, so there is no crash, but concurrent double-close on the same fd is confusing to future readers, and the always-on goroutine pattern will compound as more subprocess adapter types are added.
cmd.WaitDelayand the goroutine sleep also share one variable (subprocessWaitDelay) for two semantically distinct timeouts: the exec-internal backstop forcmd.Waitvs. the grace period before a consumer-sideio.ReadAllis unblocked. Coupling them prevents tuning either independently.Concrete fix: Introduce a
pipesDrainedchannel that the consumer closes after its read loop exits, and gate the force-close goroutine on that signal:pipesDrained := make(chan struct{}) go func() { select { case <-pipesDrained: // consumer finished normally; nothing to force-close case <-procCtx.Done(): time.Sleep(subprocessWaitDelay) _ = stdout.Close() _ = stderr.Close() } }()Return
pipesDrainedfromLaunchProcessso the adapter can close it after its scan loop. This eliminates the 10-second goroutine tail on the happy path and makes the guard conditional rather than always-on. SplitsubprocessWaitDelayintosubprocessPipeGrace(goroutine) andsubprocessWaitDelay(cmd.WaitDelay) if different tuning is ever needed.
Reviewer Coverage
| Reviewer | Status | Inspected | Skipped | Constraints |
|---|---|---|---|---|
| go:implementation-tests | complete_broad | internal/llm/subprocess.go, internal/llm/subprocess_launch_unix_test.go | unavailable | unavailable |
| structure:repo-health | complete_broad | internal/llm/subprocess.go | unavailable | unavailable |
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 4m 32s | ~$0.67 (est.) | claude-sonnet-4-6 | cr 0.10.255
| Field | Value |
|---|---|
| Model | claude-sonnet-4-6 |
| Reviewers | go:implementation-tests, structure:repo-health |
| Engine | claude_cli · claude-sonnet-4-6 |
| Reviewed by | cr · piekstra-dev |
| Duration | 4m 32s wall · 7m 11s compute |
| Cost | ~$0.67 (est.) |
| Tokens | 25 in / 19.9k out |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost | Duration |
|---|---|---|---|---|---|---|---|
| orchestrator-selection | claude-sonnet-4-6 | 7 | 755 | 25.3k | 9.4k | ~$0.05 (est.) | 27s |
| go:implementation-tests | claude-sonnet-4-6 | 6 | 11.6k | 51.6k | 31.8k | ~$0.31 (est.) | 3m 38s |
| structure:repo-health | claude-sonnet-4-6 | 7 | 7.1k | 75.7k | 27.0k | ~$0.23 (est.) | 2m 25s |
| orchestrator-rollup | claude-sonnet-4-6 | 5 | 456 | 40.1k | 16.6k | ~$0.08 (est.) | 41s |
| // inherited stdout, then holds it open. Reading READY proves it has escaped | ||
| // and owns the pipe. The parent sleeps so it (and the group kill) can't | ||
| // close stdout on its own. | ||
| script := `python3 -c 'import os,sys,time; os.setsid(); print("READY"); sys.stdout.flush(); time.sleep(30)' & sleep 20` |
There was a problem hiding this comment.
The Python worker's time.sleep(30) far outlasts the 5-second test deadline. Because the worker has called os.setsid() it escapes the process-group kill and becomes an orphan reparented to init; it will run for up to 30 seconds after the test exits. Across repeated or parallel test runs this accumulates stray Python processes. A 3–5 second sleep is enough to hold the pipe open through the 5-second test timeout and the 100 ms grace period without leaving long-lived orphans. Change the script to time.sleep(5) (or similar) to keep the scenario valid while bounding the orphan lifetime.
Reply inline to this comment.
| // consumer's synchronous read blocks forever and cmd.Wait never runs. When | ||
| // the context ends, give the pipes a grace to EOF cleanly, then force them | ||
| // closed so any pending read unblocks and the task fails instead of wedging. | ||
| go func() { |
There was a problem hiding this comment.
The goroutine that force-closes stdout/stderr fires on every context end — including normal successful completion — not only the stuck-pipe scenario it was added for.
When a task finishes cleanly the adapter eventually calls the derived cancel(), which immediately satisfies <-procCtx.Done(). The goroutine then sleeps subprocessWaitDelay (10 s) and closes pipes the process already closed. Every subprocess invocation adds a 10-second goroutine tail, and at the same moment cmd.WaitDelay (set to the same value) triggers Go's exec internals to also close those pipes — so both paths race to close identical *os.File descriptors. Individual Close() errors are suppressed, so there is no crash, but concurrent double-close on the same fd is confusing to future readers, and the always-on goroutine pattern will compound as more subprocess adapter types are added.
cmd.WaitDelay and the goroutine sleep also share one variable (subprocessWaitDelay) for two semantically distinct timeouts: the exec-internal backstop for cmd.Wait vs. the grace period before a consumer-side io.ReadAll is unblocked. Coupling them prevents tuning either independently.
Concrete fix: Introduce a pipesDrained channel that the consumer closes after its read loop exits, and gate the force-close goroutine on that signal:
pipesDrained := make(chan struct{})
go func() {
select {
case <-pipesDrained:
// consumer finished normally; nothing to force-close
case <-procCtx.Done():
time.Sleep(subprocessWaitDelay)
_ = stdout.Close()
_ = stderr.Close()
}
}()Return pipesDrained from LaunchProcess so the adapter can close it after its scan loop. This eliminates the 10-second goroutine tail on the happy path and makes the guard conditional rather than always-on. Split subprocessWaitDelay into subprocessPipeGrace (goroutine) and subprocessWaitDelay (cmd.WaitDelay) if different tuning is ever needed.
Reply inline to this comment.
The claude/codex/pi adapters were constructed (app/runtime.go) without a Timeout, so every LLM task ran under a plain cancel context with no deadline. One hung CLI worker or a background job that never reaches a terminal state therefore hung the entire review indefinitely — observed repeatedly in production as reviews that write artifacts for a few minutes, then sit at 0% CPU forever until some external supervisor kills them, with no attributable error. Default SubprocessOptions.Timeout (and PiRPCOptions.Timeout) to 10 minutes when unset. Healthy reviewer tasks finish in a few minutes, so this converts "one worker hung" into a timely, clearly-attributed task failure that composes with the pipe-teardown fix (#508): deadline fires -> context ends -> pipes force-closed -> the task fails cleanly instead of wedging. Negative values opt out explicitly; explicit values are respected unchanged.
Problem
A
cr reviewcan wedge indefinitely at 0% CPU (holding the ledger) partway through. It reproduces reliably on slower (Opus) reviews and not on fast ones.Root cause
When a reviewer task hits its per-task timeout, its context is canceled and
cmd.CancelSIGKILLs the claude child's process group. But claude's node workers make themselves process-group leaders (pgid == pid), so they escape the group kill and survive, holding the subprocessstdout/stderrpipes open.LaunchProcessconsumers then read stdout synchronously (scanStdout/io.ReadAll) beforecmd.Wait()— deliberately, so no output is lost. With the pipe held open by the escaped worker, that read never sees EOF, socmd.Wait()is never reached and the run hangs forever (until some external timeout kills it). Fast reviews that finish before the per-task timeout never trigger the cancel path, so they're unaffected.Fix
In
LaunchProcess(shared by every subprocess adapter), once the process context ends (task timeout, abort, or normal completion), give the pipes a short grace to EOF cleanly and then forcestdout/stderrclosed, so a pending synchronous read unblocks and the task fails cleanly instead of wedging. Also setcmd.WaitDelayas a backstop forcmd.Waititself.The synchronous stdout drain is unchanged, so no output is lost on the happy path (the force-close only fires after the context ends).
Verification
TestLaunchProcessUnblocksReadOnCanceledContext): spawns a worker thatos.setsid()s out of the group and holds stdout, then cancels the context. It times out (wedges) without this change and passes with it.go test ./internal/llm/ ./internal/llmadapters/,make lint,make buildall green.