Skip to content

/codex:cancel throws and leaves the job stuck as "running" when the pid is already dead on non-English Windows #423

Open

Description

@LKCoffee

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:

  1. On zh-TW Windows, start a background codex task (detached task worker, pid recorded in the job file).
  2. 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.
  3. Run /codex:cancel <task-id>.
  4. 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):

  1. 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).
  2. 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).
  3. 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.

Activity

  1. ns0109 commented on Aug 12, 2026

    @ns0109

    Independent repro data point (ja-JP Windows 11, console codepage 932, no Git Bash —
    i.e. not going through the MSYS path #577 addresses):

    I ran taskkill /PID <deadPid> /T /F directly against an already-dead PID
    (outside this plugin, to isolate the taskkill behavior itself):

    • Exit code: 128 — matches the locale-independent signal both this issue and fix: make cancel work under Git Bash on a non-English Windows (#423) #577 rely on
    • stderr text: rendered in plain English (ERROR: The process "<pid>" not found.),
      not mojibake — unlike the zh-TW cp950 case in the original report. Possibly a difference
      in how the console codepage is inherited for this particular invocation path; I didn't
      dig further since the fix doesn't depend on the message text anyway.

    So exit-code-128 reproduces cleanly here, which is another data point in favor of treating
    the exit code as the primary signal rather than pattern-matching localized messages.

    #577 already does that, and additionally fixes the MSYS /PID→path corruption under Git Bash,
    which is a real blocker for anyone running Claude Code from Git Bash on Windows (matches the
    OP's toolchain). Since #424 only covers the exit-code fix and is closed/superseded, #577 looks
    like the more complete fix to land.

  2. LKCoffee commented on Aug 13, 2026

    @LKCoffee
    Author

    Thanks for the ja-JP data point — it lines up with what I re-measured on the original zh-TW host today.

    Environment: Windows 11, zh-TW, console codepage 950; node v24.15.0; plugin codex 1.0.4.

    Real taskkill /PID <freshly-dead-pid> /T /F:

    exit code 128
    stderr bytes decoded as cp950 錯誤: 找不到處理程序 "30952"。
    same bytes decoded as utf8 (what runCommand does) ���~: �䤣��B�z�{�� "30952"�C

    Replaying that real result into terminateProcessTree through its runCommandImpl seam: stock 1.0.4 throws taskkill /PID 30952 /T /F: exit=128: <mojibake>; with the exit-128 check it returns { attempted: true, delivered: false }.

    Your ja-JP host printing English while mine prints mojibake is itself the argument: the message text isn't dependable even within a single locale.

    On the MSYS half — SHELL is not a persistent environment variable here (User and Machine scopes are both empty), so the plugin's runtime spawns through cmd.exe and /PID arrives intact. Consistent with your no-Git-Bash setup: the two defects are independent, and #577 covers both.

    One gap, as of #577 @ f9d718d: handleCancel() still calls terminateProcessTree() outside a try/catch, before the cancelled state is written. A termination failure for any other reason therefore still strands the job at "running" — the original symptom by a different route.

    What makes that look like an oversight rather than a decision: this repo has three call sites of terminateProcessTree, and the other two already guard it —

    • scripts/session-lifecycle-hook.mjs L63-67 — "Ignore teardown failures during session shutdown."
    • scripts/lib/app-server.mjs L248-253 — "Best-effort cleanup inside an unref'd timer — swallow errors to avoid crashing the host process during shutdown."
    • scripts/codex-companion.mjs L943 — unguarded

    #424 carried that try/catch, but it is closed.

    One caveat for whoever adds it: unlike those two teardown paths, where swallowing is correct, handleCancel reports a result to its caller. A bare try/catch there turns "could not kill it" into a clean Cancelled <job>. while the process keeps running — and pid: null is written unconditionally a few lines below, so a retry has nothing left to kill. Testing that locally, it needed a failure flag on the payload plus the pid preserved for the failure case; without those it trades a visible stuck job for a silent orphan.

  3. CBEPX commented on Sep 29, 2026

    @CBEPX

    Fixed in CBEPX/codex-plugin-cc v1.4.1 (https://github.com/CBEPX/codex-plugin-cc/releases/tag/v1.4.1): /codex:cancel no longer parses taskkill output (locale-dependent) and no longer throws on a dead pid. A worker that is already gone is reported as process-missing. The job becomes cancelled only when no orphaned descendants are found and the worker had recorded a closed exit. Otherwise cancel answers cancellationPending, with the orphans as survivors. Upstream PR #577 used as reference, thanks @xoonjaeho. Limit planned for v1.4.2: a brokered cancel confirms the worker, not the turn.

    /plugin marketplace add CBEPX/codex-plugin-cc
    /plugin install codex@cbepx
    

    This is a fork release, not an upstream fix, so I'm leaving this issue open for the maintainers here.

  4. tatsumiyama commented on Oct 15, 2026

    @tatsumiyama

    Another data point for this, on Japanese Windows 11 (console code page 932) with plugin 1.0.1.

    Running the plugin's own terminateProcessTree against an unused pid: taskkill /PID 99998 /T /F exits with status 128 and prints the Japanese equivalent of "process not found". Read as UTF-8 (lib/process.mjs line 8) the text is garbled, so looksLikeMissingProcessMessage does not match, terminateProcessTree reaches its final throw (line 95), and, reading handleCancel, it aborts before writing the cancelled state (codex-companion.mjs line 923, before lines 927-948). The job stays running.

    From a summary of the file on main (we have not diffed the raw file), the check still looks English-only. Treating exit status 128 as "not found" (as proposed here), or checking existence first (process.kill(pid, 0)), would fix it. Recording the job as cancelled even when termination throws would also stop jobs from getting stuck.

  5. tiago-moraes commented on Nov 8, 2026

    @tiago-moraes

    Same issue confirmed on pt-BR Windows 11 (console codepage 850) with plugin v1.0.6.

    taskkill on a pid that no longer exists:

    > taskkill /PID 999999 /T /F
    ERRO: o processo "999999" não foi encontrado.
    exit code: 128
    

    None of the English patterns in looksLikeMissingProcessMessage match, so terminateProcessTree() throws. Because handleCancel() calls it before writeJobFile/upsertJob, the job stays running and every later task --resume-last in that workspace fails with "Task … is still running".

    How it happened: a foreground task was interrupted (turn_aborted) and the worker died, but the job stayed running. cancel then hit the error above. The workaround was to confirm the pid was gone and run the same state updates as handleCancel (writeJobFile + upsertJob, both needed) without the kill step.

    +1 for the exit-code-128 approach in #577, plus a guard in handleCancel() so the cancelled state is saved even when the kill step fails.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions