Environment
- Windows 11 Pro, zh-TW locale, console codepage 950 (Big5)
- Plugin:
codex 1.0.4 (marketplace openai-codex)
- Claude Code 2.1.193, Node v24.15.0
Summary
On a non-English Windows system, cancelling a background task whose worker process has already died makes terminateProcessTree() throw, so handleCancel() never persists the cancelled state. The job stays "running" forever, and because status/resume trust the stored state without checking pid liveness, the dead job also blocks --resume-last resolution. Reproduced three times on 2026-07-03.
Reproduction
Real flow:
- On zh-TW Windows, start a background codex task (detached task worker, pid recorded in the job file).
- Kill the worker out-of-band (hard-close the session, reboot, or
taskkill it from another terminal). The job file still says "running" with the stale pid.
- Run
/codex:cancel <task-id>.
- The command fails with the error below; the job file and state are never updated.
Minimal script (run from the plugin root on any non-English Windows, with SHELL unset — i.e. the plugin's normal runtime):
import { spawnSync } from "node:child_process";
const { terminateProcessTree } = await import("./scripts/lib/process.mjs");
// spawnSync waits for exit, so this pid is dead by the next line
const dead = spawnSync("cmd.exe", ["/c", "exit"], { windowsHide: true });
terminateProcessTree(dead.pid); // throws
Observed:
Error: taskkill /PID 36564 /T /F: exit=128: ���~: �䤣��B�z�{�� "36564"�C
The mojibake is the cp950 rendering of 錯誤: 找不到處理程序 "36564"。 — Windows' localized "ERROR: The process "36564" not found."
Root cause
Three layers in scripts/lib/process.mjs (1.0.4):
- English-only message matching.
looksLikeMissingProcessMessage() (L53-55) only recognizes not found|no running instance|cannot find|does not exist|no such process. Localized taskkill output never matches, so the win32 branch of terminateProcessTree() falls through to throw new Error(formatCommandFailure(result)) (L97).
- The message can never be matched on most localized systems anyway.
runCommand() uses spawnSync(..., { encoding: "utf8" }) (L8), but taskkill emits the console codepage (cp950 here). The utf8 decode destroys the text into U+FFFD runs, so even adding localized patterns to the regex would not help — the exit code is the only locale-independent signal. (On the same machine we have also seen taskkill emit the English message under a different invocation context, so the message text is not even stable per-host — one more reason it cannot be the primary signal.) Empirically, taskkill exits with 128 when the target process does not exist (verified repeatedly on this machine; we found no official Microsoft documentation of taskkill exit codes, which is why keeping the message check as a fallback still makes sense).
- Crash-before-persist in
handleCancel(). codex-companion.mjs calls terminateProcessTree(job.pid ?? Number.NaN) (L943) before writing the cancelled state (L956-968), so the throw converts a routine "already dead" situation into a permanently stuck job.
Suggested fix
Treat exit code 128 as process-not-found in the win32 branch, keeping the message check as fallback:
if (!result.error && result.status === 0) {
return { attempted: true, delivered: true, method: "taskkill", result };
}
+
+ // taskkill exits 128 when the target process does not exist; its stderr is
+ // localized (and becomes mojibake when a non-UTF-8 codepage is decoded as
+ // utf8), so the exit code is the only locale-independent signal.
+ if (!result.error && result.status === 128) {
+ return { attempted: true, delivered: false, method: "taskkill", result };
+ }
We are running exactly this as a local patch; with it, the dead-pid case returns { attempted: true, delivered: false } and /codex:cancel completes and persists the cancelled state.
Two further hardening ideas, your call:
- In
handleCancel(), consider persisting the cancelled state even if terminateProcessTree() throws (try/catch, then rethrow or log). Termination failure on a job the user is abandoning anyway probably shouldn't leave the state machine stuck.
runCommand()'s shell: process.env.SHELL || true is fragile for this call in another way: if SHELL points at a POSIX bash (e.g. Git Bash), MSYS argument conversion mangles /PID into C:/Program Files/Git/PID, so taskkill fails with exit 1 (invalid argument) even for live processes. taskkill needs no shell at all — shell: false for this invocation would sidestep both that and the DEP0190 deprecation warning.
Impact
Any non-English Windows locale (zh/ja/ko/de/fr/...) where a background task's worker dies before cancellation: the job can never be cancelled through the plugin, stays "running" forever, and blocks resume-last. Recovery currently requires manually rewriting the plugin's state files.
Originally posted by @LKCoffee in openai/codex-plugin-cc#423
Environment
codex1.0.4 (marketplaceopenai-codex)Summary
On a non-English Windows system, cancelling a background task whose worker process has already died makes
terminateProcessTree()throw, sohandleCancel()never persists thecancelledstate. The job stays"running"forever, and because status/resume trust the stored state without checking pid liveness, the dead job also blocks--resume-lastresolution. Reproduced three times on 2026-07-03.Reproduction
Real flow:
taskkillit from another terminal). The job file still says"running"with the stale pid./codex:cancel <task-id>.Minimal script (run from the plugin root on any non-English Windows, with
SHELLunset — i.e. the plugin's normal runtime):Observed:
The mojibake is the cp950 rendering of
錯誤: 找不到處理程序 "36564"。— Windows' localized "ERROR: The process "36564" not found."Root cause
Three layers in
scripts/lib/process.mjs(1.0.4):looksLikeMissingProcessMessage()(L53-55) only recognizesnot found|no running instance|cannot find|does not exist|no such process. Localized taskkill output never matches, so the win32 branch ofterminateProcessTree()falls through tothrow new Error(formatCommandFailure(result))(L97).runCommand()usesspawnSync(..., { encoding: "utf8" })(L8), but taskkill emits the console codepage (cp950 here). The utf8 decode destroys the text intoU+FFFDruns, so even adding localized patterns to the regex would not help — the exit code is the only locale-independent signal. (On the same machine we have also seen taskkill emit the English message under a different invocation context, so the message text is not even stable per-host — one more reason it cannot be the primary signal.) Empirically, taskkill exits with 128 when the target process does not exist (verified repeatedly on this machine; we found no official Microsoft documentation of taskkill exit codes, which is why keeping the message check as a fallback still makes sense).handleCancel().codex-companion.mjscallsterminateProcessTree(job.pid ?? Number.NaN)(L943) before writing the cancelled state (L956-968), so the throw converts a routine "already dead" situation into a permanently stuck job.Suggested fix
Treat exit code 128 as process-not-found in the win32 branch, keeping the message check as fallback:
if (!result.error && result.status === 0) { return { attempted: true, delivered: true, method: "taskkill", result }; } + + // taskkill exits 128 when the target process does not exist; its stderr is + // localized (and becomes mojibake when a non-UTF-8 codepage is decoded as + // utf8), so the exit code is the only locale-independent signal. + if (!result.error && result.status === 128) { + return { attempted: true, delivered: false, method: "taskkill", result }; + }We are running exactly this as a local patch; with it, the dead-pid case returns
{ attempted: true, delivered: false }and/codex:cancelcompletes and persists the cancelled state.Two further hardening ideas, your call:
handleCancel(), consider persisting the cancelled state even ifterminateProcessTree()throws (try/catch, then rethrow or log). Termination failure on a job the user is abandoning anyway probably shouldn't leave the state machine stuck.runCommand()'sshell: process.env.SHELL || trueis fragile for this call in another way: ifSHELLpoints at a POSIX bash (e.g. Git Bash), MSYS argument conversion mangles/PIDintoC:/Program Files/Git/PID, so taskkill fails with exit 1 (invalid argument) even for live processes.taskkillneeds no shell at all —shell: falsefor this invocation would sidestep both that and theDEP0190deprecation warning.Impact
Any non-English Windows locale (zh/ja/ko/de/fr/...) where a background task's worker dies before cancellation: the job can never be cancelled through the plugin, stays
"running"forever, and blocks resume-last. Recovery currently requires manually rewriting the plugin's state files.Originally posted by @LKCoffee in openai/codex-plugin-cc#423