Skip to content

Kill the Codex probe's process group when its deadline expires - #694

Open
jeremy wants to merge 4 commits into
mainfrom
codex-probe-process-group
Open

Kill the Codex probe's process group when its deadline expires#694
jeremy wants to merge 4 commits into
mainfrom
codex-probe-process-group

Conversation

@jeremy

@jeremy jeremy commented Sep 9, 2026

Copy link
Copy Markdown
Member

queryCodexPlugin ran codex plugin list --available --json through cmd.Output with a WaitDelay. That bounded the call (#629) but not what it left behind: where codex is a wrapper that exits at once and backgrounds the real work (an npm exec launcher, a mise shim), the direct child was gone long before the five-second deadline, so the exec package had stopped watching the context, cmd.Cancel never ran, and the descendant holding the inherited stdout outlived every timed-out doctor — one resident process per run. #630 records why the obvious Setpgid + cmd.Cancel attempt could not work.

What

runCodexCommand now owns the lifecycle instead of Output():

  • The child starts as a process group leader (SysProcAttr{Setpgid: true}, procgroup_unix.go; a no-op with plain Process.Kill elsewhere).
  • Stdout is read through StdoutPipe in our own goroutine. On ctx.Done the whole group is killed before Wait is called — the group ID is the leader's PID and stays reserved only while a member of the group exists, so a kill issued before the leader is reaped can never land on a recycled PID, which is the safety property The Codex probe leaks a descendant when it times out #630 asked for. There is no cmd.Cancel override: once Wait has begun, a leader that closed stdout and lingers past the deadline is killed alone by the exec package's own cancel, and its descendants are out of reach in that shape — a group kill issued from Cancel would race the reap and could land on a recycled ID.
  • WaitDelay stays, and the read has its own bound: a descendant that leaves the group (setsid) is out of reach, so after codexWaitDelay our end of the pipe is closed and the read returns.
  • A deadline that expires returns ctx.Err() regardless of the exit status, so codexQueryFailure keeps rendering "Cannot query Codex plugins" with the same hint.

Verification

  • Failing first: TestRunCodexCommandOutlivingGrandchild now also asserts the grandchild is gone after the call returns (polled on kill(pid, 0)ESRCH, 5 s bound) and that the error is context.DeadlineExceeded. Against main's codex.go it fails with grandchild <pid> outlived the deadline: the process group was not killed; with this change it passes in 0.5 s. Cleanup reaps the grandchild only on a failing run, and only if kill(pid, 0) still finds it, so a passing run never signals a pid it has already watched disappear.
  • GOOS=windows go build ./internal/harness compiles the stub side.
  • bin/ci green on Linux (thelio, Go 1.26.7): fmt, vet, lint, unit, e2e, naming, surface, skill drift, bare groups, provenance, tidy.

Fixes #630


Summary by cubic

Kills the Codex probe's process group when its deadline expires, so timed-out doctor runs no longer leave orphaned processes holding the stdout pipe. Fixes #630.

queryCodexPlugin previously used cmd.Output with a WaitDelay, which bounded the call but not the descendants that outlived it. runCodexCommand now starts codex in its own process group, reads stdout directly, and kills the whole group on context expiry.

Bug Fixes

  • The group kill runs strictly before Wait reaps the leader, so it can never land on a recycled PID.
  • WaitDelay still bounds reads from descendants that escape the group (setsid); non-Unix platforms kill only the child.
  • A deadline expiry returns ctx.Err(), keeping the "Cannot query Codex plugins" failure and hint unchanged.

Written for commit c393039. Summary will update on new commits.

Review in cubic

The probe ran codex through cmd.Output with a WaitDelay, which bounded the
call but not what it left behind: where codex is a wrapper that exits at
once and backgrounds the real work, the deadline expired after the exec
package had stopped watching the context, so cmd.Cancel never ran and the
descendant survived every timed-out doctor run.

Start the child in its own process group, read its stdout directly, and
kill the group when the context expires — before Wait reaps the leader,
while the group ID is still ours. WaitDelay stays as the bound for a
descendant that leaves the group.

Fixes #630
Copilot AI balanced review requested due to automatic review settings September 9, 2026 23:30
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T03:46:47.500961Z c393039 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Process reaping can race with the group-killing cancellation callback, and the new test does not compile portably.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Prevents timed-out Codex diagnostics from leaking descendant processes by managing process groups and stdout explicitly.

Changes:

  • Adds platform-specific process-group lifecycle helpers.
  • Bounds stdout reads and returns context deadline errors consistently.
  • Expands timeout testing to verify descendant termination.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
internal/harness/procgroup_unix.go Adds Unix process-group creation and termination.
internal/harness/procgroup_other.go Adds non-Unix fallback behavior.
internal/harness/codex.go Implements explicit command, cancellation, and pipe lifecycle handling.
internal/harness/codex_test.go Verifies timeout errors and descendant termination.
Review details

Suppressed comments (1)

internal/harness/codex.go:73

  • The new test only backgrounds a child that remains in the wrapper's process group, so the group kill closes its pipe immediately and this timeout/Close fallback is never exercised. A regression here would still pass while allowing a setsid descendant to hang the probe—the failure mode this branch explicitly handles. Add a Unix-specific test whose descendant leaves the group while retaining stdout and verify that the call remains bounded (with explicit cleanup).
			case <-time.After(codexWaitDelay):
				// A descendant that left the group (setsid) is out of reach
				// and still holds the pipe; closing our end ends the read.
				_ = stdout.Close()
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/harness/codex.go Outdated
Comment thread internal/harness/codex_test.go Outdated
…ix alone

Installing the group kill as cmd.Cancel let the exec package's watcher
fire between Process.Wait reaping the leader and Wait synchronizing with
it — after the group ID could have been recycled, the race this change
exists to exclude. The kill now happens in one place, on this goroutine,
strictly before Wait; a leader still running past that point is killed
alone by the default cancel. The grandchild assertion uses syscall.Kill,
so the test moves behind a unix build tag instead of a runtime skip.
Copilot AI review requested due to automatic review settings September 9, 2026 23:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Test cleanup can kill a recycled PID, and the escaped-descendant timeout path remains untested.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread internal/harness/codex_unix_test.go Outdated
Comment thread internal/harness/codex.go
…he escaped one

The cleanup killed whatever held the recorded pid, which on a passing run
was a pid the test had just watched disappear. It now signals only a
process kill(pid, 0) still finds, and only where one is expected: a
failed group kill, or the setsid descendant the new test leaves behind on
purpose to prove the read gives up on its own.
Copilot AI review requested due to automatic review settings September 9, 2026 23:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The regression test assumes prompt orphan reaping and the PR description overstates process-group cancellation behavior.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/harness/codex.go:58

  • The implementation here contradicts the PR description's claim that cmd.Cancel kills the process group when the deadline expires while the leader is running: after stdout reaches EOF, execution enters Wait, whose default cancellation kills only the direct child, as this comment states. A leader that closes stdout and then lingers can therefore still leave descendants behind at timeout. Since the narrower behavior was intentional after the PID-reuse fix, please update the PR description/title-level guarantee to describe this limitation rather than promising a group kill for every deadline.
  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread internal/harness/codex_unix_test.go
revive's error-return rule wants the error last. The assertion message also says what a pid that is still found can be under a PID 1 that does not reap orphans: an uncollected zombie, not a survivor.
Copilot AI review requested due to automatic review settings September 10, 2026 03:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The lifecycle ordering addresses PID-reuse safety, preserves timeout behavior, and is covered across the relevant Unix edge cases.

Review details
  • Files reviewed: 5/5 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3930390ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"runCodexCommand blocked on a pipe held open by a surviving grandchild")
assert.ErrorIs(t, err, context.DeadlineExceeded)
assert.Eventually(t, func() bool {
return syscall.Kill(pid, 0) == syscall.ESRCH

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat killed zombies as terminated

In Unix containers where PID 1 does not reap orphans—the supplied review container uses tail as PID 1—the group-killed sleep remains a zombie, and kill(pid, 0) continues returning nil indefinitely. This makes the new test fail after five seconds even though the descendant can no longer execute, blocking bin/ci in those environments; inspect the process state or arrange for the descendant to be reaped rather than requiring ESRCH.

Useful? React with 👍 / 👎.

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

Labels

tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The Codex probe leaks a descendant when it times out

2 participants