Repository navigation
Conversation
…ad pids by exit code On Windows runCommand spawns through $SHELL, which is Git Bash when Claude Code runs from it. MSYS then rewrites taskkill's `/PID` flag into a path (`C:/Program Files/Git/PID`) and every cancel fails. Disable path conversion for that child only -- setting it on the node process itself breaks the `/c/...` paths node needs to resolve its own modules. A second defect surfaced once the flags survived: taskkill reports "no such process" with exit code 128 and a *localized* message, so the English-only regex never matched on a non-English Windows and an already-dead pid threw instead of resolving as already-stopped. Verified on ko-KR Windows: live pid -> exit 0, dead pid -> exit 128, and the English regex does not match the translated text. Fixes 3 previously-failing runtime tests (cancel x2, session-end cleanup) with no new failures.
`taskkill /PID <n> /T /F` walks the tree, and it exits non-zero when it killed the target but could not reap one descendant -- exit 255, "The operation attempted is not supported", which a console host or an already-exiting child produces. `terminateProcessTree` tolerated only exit 0 and exit 128, so that case threw, and the throw propagates out of `handleCancel`: `/codex:cancel` fails on Windows even though the process it was asked to kill is dead. The exit code cannot separate a partial kill from a real failure, and the message is localized -- keying on it is the bug the exit-128 check already exists to avoid. So probe the target with signal 0 instead: if it is gone, the kill did what the caller asked. A target that is still alive keeps throwing. Found by a test that had been dismissed as flaky: `tests/runtime.test.mjs:1740` fails ~1 run in 3 on this host, always here.
|
Pushed a second commit to this branch:
The exit code cannot separate a partial kill from a real failure, and that message is localized too, so keying on it would reintroduce exactly the bug the exit-128 check in this PR exists to avoid. It probes the target with signal 0 instead: gone ⇒ the kill did what the caller asked; still alive ⇒ throws as before. Two tests, each checked by breaking the line it protects. How it was found is worth a line: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d5c0b31c16
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!result.error && processIsGone(pid, killImpl)) { | ||
| return { attempted: true, delivered: true, method: "taskkill", result }; |
There was a problem hiding this comment.
Do not report tree termination after checking only parent
When taskkill exits non-zero because a descendant could not be killed (the case this branch is trying to tolerate), this branch probes only the root pid and then returns delivered: true. Callers such as cancellation and session teardown rely on terminateProcessTree to stop the whole job tree and do not make a second cleanup pass, so a surviving Codex/broker child can keep running after a reported cancellation; treat this as a partial failure unless the remaining tree is also gone.
Useful? React with 👍 / 👎.
`d5c0b31` probed the target once, the instant taskkill returned. A process that
is already terminating is still briefly alive at that instant, so the probe said
"alive" and the call threw anyway.
That is the case `/codex:cancel` actually hits: it interrupts the turn first, the
worker starts winding down, and the taskkill that follows loses the race and
reports "Access is denied" (exit 1, localized) rather than the exit 255 the
earlier commit handled. Poll for up to a second instead of once.
This is what `tests/runtime.test.mjs:1740` had really been failing on. It was
written off as a Windows flake, then attributed to exit 255; that was one of two
modes. Captured assertion:
taskkill /PID 1052 /T /F: exit=1: 오류: 액세스가 거부되었습니다.
1 !== 0
Four findings from the reviewer and two adversarial review legs on openai#577/openai#580. `terminateProcessTree` reported success after probing only the root pid, while `/T` was asked to take the tree, and callers make no second pass. `delivered` now answers only "did the target die"; the new `treeConfirmed` answers "is everything under it accounted for". Exit 0 confirms both; exit 128 means there was no process and so no tree in doubt; only the branch that tolerates a non-zero taskkill whose root turned out gone reports `treeConfirmed: false`. `handleCancel` logs that instead of reporting a clean cancellation -- without a consumer this would have been a relabel, not a fix. The stale-broker teardown killed whatever process owned a recorded pid. It now records the broker's OS process start time at spawn and requires it to still match; unverifiable identity skips the kill and only clears the stale files. `session-lifecycle-hook.mjs` killed by recorded pid with no such check at all -- the same defect on the path the first fix never reached -- and now shares the rule. The start-time probe shells out, so `runCommand` grew a `timeout` passthrough and the probe is bounded at 2 s; a timeout is just an unverifiable identity. macOS `ps -o lstart=` has only second granularity, which is recorded at the call site rather than papered over. Making the hook importable put `main()` behind an entry-point guard, so a test runs the file exactly as hooks.json does: get that guard wrong and the whole SessionEnd reaper becomes a silent no-op that nothing else would notice. Suite: 125 tests, 117 pass, 8 fail -- the 8 are the known platform and short-path failures that predate this work, and the `cancel` flake is gone.
…unted for" Review finding on this PR: after a non-zero taskkill the code probed only the root pid and then reported success, but `/T` was asked to take the tree, and callers make no second pass -- so a surviving codex or broker child kept running after a cancellation that reported clean. The two facts are now separate. `delivered` is about the target; `treeConfirmed` is about everything under it. Exit 0 confirms both. Exit 128 means there was no process, so no tree is in doubt. Only the branch that tolerates a non-zero taskkill whose root turned out to be gone -- exit 255's unreapable descendant, and exit 1's race with an already-exiting root -- cannot account for the tree, and it alone reports `treeConfirmed: false`. Windows offers no way to enumerate what survived once the root is gone, so reporting it honestly is as far as this goes. `handleCancel` now writes that to the job log instead of reporting a clean cancellation. Without a consumer this would have been a relabel, not a fix. Also adds a `timeout` passthrough to `runCommand`, which had none.
|
The finding on this PR — reporting tree termination after checking only the parent — is real and fixed in You are right that the branch conflated two different facts. They are separate now: Windows offers no way to enumerate what survived once the root is gone, so honest reporting is as far as this can go. To keep that from being a pure relabel, Also carries a |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bfdf8db271
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| !result.error && | ||
| (result.status === TASKKILL_PROCESS_NOT_FOUND || looksLikeMissingProcessMessage(combinedOutput)) | ||
| ) { | ||
| return { attempted: true, delivered: false, treeConfirmed: true, method: "taskkill", result }; |
There was a problem hiding this comment.
Keep tree confirmation false when the root is missing
When Windows taskkill reports the recorded PID as missing, this branch now returns treeConfirmed: true, but that is only safe if the stored worker PID could not have left descendants behind. In this repo the worker/broker paths can spawn detached, unref'd children, so a stale job PID can be gone while a Codex/broker child is still running; handleCancel only warns on !treeConfirmed and then clears the PID. In that stale-PID case cancellation reports cleanly while the orphan becomes invisible, so the missing-root branch should remain unconfirmed unless descendants are otherwise verified.
Useful? React with 👍 / 👎.
A root taskkill cannot find says nothing about the children it spawned before it died, and with the root gone `/T` has nothing left to walk from. `cancel` only reaches this branch for a job the state still calls running -- a worker that died mid-turn -- which is exactly when an orphaned `codex app-server` is left behind. The branch now reports `treeConfirmed: false`, so `handleCancel` warns instead of reporting a clean cancellation.
|
The finding on You were right that the previous round drew the line in the wrong place. One thing worth recording, since it also answers the obvious objection that this makes cancellation warn all the time: it does not, and the reason is upstream of The POSIX side keeps
|
…nverge Second review round: the previous fixes closed the reported paths but introduced two of their own, and left one Windows failure standing. - process: identify a process by pid AND start time. Comparing broker.json's pid against the pid file proved nothing -- both are stale artifacts written together, so after the broker died and its pid was recycled they still agreed while naming an unrelated process. isProcessAlive had the mirror problem: it answers "yes" about whatever now holds the number. Cancel and broker teardown now require the recorded start time to match before signalling, which matters because terminateProcessTree kills a whole process group. Records written before identities existed fall back to the old liveness check rather than becoming impossible to cancel. - broker: only signal the freshly spawned child when its handle still shows it running; an exited child's pid may already belong to something else. - stop gate: converge instead of skipping. Treating stop_hook_active as "allow" meant Codex's findings were never re-checked, so the gate passed anything on its second pass. The review target is now fingerprinted: an unchanged tree re-uses the stored verdict (no extra Codex run, and a block stays a block), a changed tree earns a fresh review, and the gate gives up after 3 rounds so a disagreement ends with the user instead of more usage. - stop gate: block via the documented top-level decision/reason pair only. The previous commit also emitted hookSpecificOutput and described it as the current format, which is backwards. - process: treat taskkill exit 128 as "already gone". The message-text check is English-only, so on a Japanese Windows an already-exited pid threw -- and handleCancel never caught it, so the job was left un-cancelled. The kill is now best-effort and the cancelled state is always recorded. - process: resolve the Windows spawn wrapper from SystemRoot instead of trusting SHELL or ComSpec. Wrapping a JSON-RPC stdio pipe in an arbitrary user shell is what hangs the handshake with no surfaced error; SHELL still reaches Codex through the inherited env, so its own command execution is unchanged. Verified against upstream: PRs openai#484, openai#577 and openai#580 do exist as open pull requests (fetched via refs/pull/N/head) and take these same approaches. The earlier claim that they could not be found was checked against upstream main, which by definition cannot contain unmerged work. Tests: new coverage for pid-reuse rejection, the legacy-record fallback, the taskkill exit code, the Windows shell resolution, and all four stop-gate outcomes (block, unchanged-reuse, fixed-and-cleared, give-up). The stop-gate tests fail against the pre-fix hook. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M5cuwDkeEdktKwmZQhu4ko
Draft one English comment per upstream issue closed by v1.4.0 (Windows spawn/taskkill cluster, windowsHide, stop-hook stdin deadline, EAGAIN retries), and update the triage doc's statuses/queue buckets to match: fixed-in v1.4.0 for those 9 issues plus their reference PRs and openai#326/openai#735, planned v1.4.1 for openai#336/openai#423/openai#577 (Windows process-identity work deferred from v1.4.0), and a note on openai#743 that the Windows kill-from-record refusal now targets v1.4.1. Docs only, nothing posted upstream. Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
Same fix as #423, shipped in CBEPX/codex-plugin-cc v1.4.1 (https://github.com/CBEPX/codex-plugin-cc/releases/tag/v1.4.1), used here as the reference; thanks @xoonjaeho. This is a fork release, not an upstream fix, so I'm leaving this issue open for the maintainers here. |
Problem
Two defects make
/codex:cancelunusable when Claude Code runs from Git Bash on a non-English Windows. The second one is invisible until the first is fixed, which is why they are in one PR.1. MSYS rewrites
taskkill's/PIDflag into a path.runCommandspawns through$SHELLon Windows (process.mjs:12), which is Git Bash in this setup. MSYS then converts the/PIDargument beforetaskkill.exesees it:Every cancel fails. This is the same defect as #411 and #220, and this PR uses #411's approach —
MSYS_NO_PATHCONV=1+MSYS2_ARG_CONV_EXCL=*scoped to thetaskkillchild only. Scoping matters: setting those on the node process instead breaks the/c/...path translation node needs to resolve its own modules.2. Dead-pid detection is English-only, so an already-stopped process throws.
Once the flags survive,
taskkillruns and reports "no such process" — butlooksLikeMissingProcessMessagematches English text only. On a localized Windows nothing matches, soterminateProcessTreefalls through tothrow new Error(formatCommandFailure(result))instead of returningdelivered: false. This is #423; #424 also addresses it but currently conflicts and carries unrelated changes.The fix uses the exit code, which is locale-independent.
Verification
Measured on ko-KR Windows 11, Git Bash, node v24.14.0:
0성공: PID 35580인 프로세스가 종료되었습니다128오류: 프로세스 "35580"을(를) 찾을 수 없습니다Before the env change, the same call fails with
Invalid argument/option - 'C:/Program Files/Git/PID'. After it, the flags arrive intact.Test suite on the same host: 92 tests / 83 pass / 9 fail, against a pre-change baseline of 91 / 79 / 12. No new failures, and three previously-failing tests now pass:
cancel stops an active background job and marks it cancelledcancel sends turn interrupt to the shared app-server before killing a brokered tasksession end fully cleans up jobs for the ending session(The 9 remaining failures are pre-existing on Windows — Unix-socket, temp-dir and real-CLI assertions. CI is ubuntu-only so they do not appear there.)
Both changes were mutation-checked: reverting either production line makes its test fail, and restoring makes it pass.
Tests
terminateProcessTree uses taskkill on Windowsto assert the spawn env (from fix: preserve taskkill flags under Git Bash #411)terminateProcessTree treats exit code 128 as already stopped on a non-English WindowsBoth inject
platform,runCommandImplandkillImpl, so they perform no real spawn and run identically on Linux CI.Relation to existing PRs
This supersedes #411 (defect 1 only) and #424 (defect 2 only, currently conflicting). Credit to #411 for the env approach — I verified it empirically and kept it as-is. Happy to rebase, split, or close this in favour of either if a maintainer prefers.
Fixes #423
Why this matters beyond
cancelterminateProcessTreeis not only reached from/codex:cancel. It is also the killersession-lifecycle-hook.mjs:105passes askillProcesswhen theSessionEndhook reaps abroker. On a Windows host running Claude Code from Git Bash, that reap is silently a no-op
today for the same MSYS reason —
taskkillnever receives/PID.That turns out to matter for the app-server broker leak.
broker-lifecycle.mjsspawns brokersdetached+unref, the broker has no idle timeout, and the stale-teardown path atbroker-lifecycle.mjs:120callsteardownBrokerSessionwithoutkillProcess, so a brokerthat misses the 150 ms endpoint probe is replaced while the old process keeps running and its
state files are deleted — invisible to the reaper forever. On this host that produced four live
brokers for a single
cwdwherebroker.jsonnamed one, and a per-orphan footprint of ~9processes.
Wiring
terminateProcessTreeinto that teardown is the natural fix for the leak — but onWindows + Git Bash it would still be a no-op without the patch in this PR. So this change is a
prerequisite for that fix landing effectively on Windows, not just a
cancelbug. Filing thebroker fix separately; flagging the dependency here so the two are not evaluated in isolation.