Skip to content

Fix subprocess encoding crash on non-UTF-8 locales (e.g. Windows cp932) - #11

Open
glayht96-ctrl wants to merge 3 commits into
Cjbuilds:mainfrom
glayht96-ctrl:fix/cp932-subprocess-encoding
Open

glayht96-ctrl wants to merge 3 commits into
Cjbuilds:mainfrom
glayht96-ctrl:fix/cp932-subprocess-encoding

Conversation

@glayht96-ctrl

@glayht96-ctrl glayht96-ctrl commented Jul 15, 2026

Copy link
Copy Markdown

Fix subprocess encoding crash on non-UTF-8 locales (e.g. Windows cp932)

Summary

  • subprocess.run(..., text=True, ...) calls that read/write Codex CLI or
    Claude Code CLI output rely on Python's locale-preferred encoding instead
    of a fixed one. On Windows with a Japanese system locale (cp932), any
    character outside cp932 in the child process's stdout/stderr (e.g. an em
    dash in a Claude Fable 5 response) raises an uncaught UnicodeDecodeError
    inside CPython's internal reader/writer thread for the pipe.
  • That exception happens in a background thread, not the caller, so it is
    not caught by this codebase's existing try/except blocks (including
    the except UnicodeDecodeError already present around two of these calls
    in inspect_models.py/configure_orchestration.py — that clause is dead
    code for this failure mode). The MCP server process crashes and the stdio
    transport closes with no JSON-RPC response.
  • From Codex CLI's side, this is observed as a bare "Transport closed"
    error with no further detail — it looks like a Codex-side MCP transport
    problem, but it is not; the child process itself crashed.
  • When the corrupt byte sequence happens to fall inside a valid cp932
    double-byte range instead of an invalid one, no exception is raised at
    all, and the text is silently mis-decoded (mojibake) instead of
    crashing — a worse failure mode than a visible error.

Repro

Calling fable_advisor_mcp.py's review_plan directly over stdio on
Windows 11 (ja-JP, cp932), with natural (non-adversarial) prompts to
Claude Fable 5:

  • 2 of 4 calls crashed the server with UnicodeDecodeError: 'cp932' codec can't decode byte 0x94 ... inside subprocess.py's _readerthread,
    because the review text happened to contain an em dash.
  • 1 of 4 calls did not crash but silently corrupted the em dash into
    unrelated CJK characters in the returned JSON.
  • Setting PYTHONUTF8=1 before the same calls dropped the crash rate to
    0/4, confirming encoding as the root cause. Codex does not set that
    variable when it spawns this plugin's MCP server, so the workaround does
    not apply during normal use — hence this fix.

Fix

Pass encoding="utf-8", errors="replace" explicitly on every subprocess
call whose output can contain arbitrary model- or CLI-generated text.
This now covers 18 call sites across 8 files (up from the original 11
across 4 files):

Original 11 (unchanged from before rebase):

  • fable_advisor_mcp.py: _run_json, _invoke_fable
  • inspect_models.py: inspect_version, load_catalog
  • configure_native_routing.py: binary_version, supports_native_policy,
    select_fable_server, verify_fable_prerequisites, and the
    AppServer subprocess.Popen launch of codex app-server --stdio
    (added in a follow-up commit after the same class of report surfaced
    on this long-lived, central subprocess)
  • configure_orchestration.py: catalog_source, load_catalog

New in this update — 7 more, rebased onto upstream/main (521f3de):

0.8.0 added external model integration (PR #14/#15/#16/#23), which
introduced 7 more subprocess.run call sites with the identical
text=True + stdout/stderr=PIPE shape as the ones above, and
therefore the same cp932 crash risk:

  • external_auth_helper.py: _run_capture (1)
  • external_cli_trust.py: version (1)
  • external_configurator.py: _user_helper_ready, the two Gate 0 CLI
    contract checks (exec --help / features list), and the
    invoke-CLI-contract help check (4)
  • external_credentials.py: credential_ready (1)

Left untouched, with reasons (same principle as the original PR —
only touch sites where output is actually captured and decoded as text):

  • external_auth_helper.py _run_interactive: text=False,
    stdout=None/stderr=None — nothing is captured, so nothing is
    decoded.
  • external_configurator.py _run_invoke_process
    (subprocess.Popen): stdout=DEVNULL, stderr=DEVNULL — same
    reason.
  • external_configurator.py's Gate 0 config-write call: text=False
    and stdout/stderr=DEVNULL — doubly excluded.
  • macOS-only code paths (os.name == "posix" branches) are still not
    touched anywhere in this PR, for the same reason as the original:
    they never run on Windows, and macOS's default locale is UTF-8.

This branch has been rebased onto upstream/main at 521f3de
(Harden Kimi external model invocation (#23)) to pick up the 0.8.0
changes before extending the fix to them.

Testing

The manual/real-environment verification below applies to the
original 11 call sites only
(the four files listed under "Original
11" above), from before this PR was extended to cover the new external
model integration files:

  • tests/test_fable_advisor_mcp.py + tests/test_inspect_models.py:
    23/23 passed (PYTHONUTF8=1 python -m pytest ...).
  • tests/test_native_routing.py + tests/test_configure_orchestration.py:
    65 failures both before and after this change, identical set — all
    pre-existing POSIX-only assumptions (hardlinks, xattr, fchmod) that don't
    hold on Windows, unrelated to this fix. No new failures introduced.
  • ruff check passes on all four modified files.
  • Manual repro, patched code, without PYTHONUTF8: 4/4 natural
    review_plan calls succeeded, including one response containing an em
    dash that decoded correctly (previously this exact scenario crashed 2/4
    times on the unpatched code).

The 7 new call sites (external model integration files) have not been
verified the same way.
They were identified by inspecting every
subprocess.run call in the four new files and patched only where the
shape is structurally identical to an already-verified call
(text=True with stdout=subprocess.PIPE and stderr=subprocess.PIPE,
so its output is actually decoded as text). This is a mechanical
application of the same, already-proven fix pattern to structurally
identical call sites — not an independent real-environment reproduction
of a crash on those 7 specific call sites. Verification so far for the
new sites is limited to python -m py_compile (no syntax errors) on
all four touched files; no crash was reproduced or re-tested against
those 7 sites specifically on a cp932 locale.

Environment

  • Windows 11, Python 3.11.9 and 3.14.4 (both exhibit the bug/fix
    identically), system locale ja-JP (cp932).
  • Not tested on macOS/Linux, but the fix is a no-op behavioral change there
    (UTF-8 is typically already the default locale encoding on those
    platforms, and errors="replace" only changes behavior for bytes that
    were already undecodable).

@glayht96-ctrl
glayht96-ctrl requested a review from Cjbuilds as a code owner July 15, 2026 11:51
@glayht96-ctrl

Copy link
Copy Markdown
Author

Pushed an additional commit (f9d7b4e) to this branch.

The original audit for this fix searched only for subprocess.run(..., text=True, ...) calls, so it missed the one subprocess.Popen(..., text=True, ...) call in configure_native_routing.py: AppServer.__init__'s launch of codex app-server --stdio. That subprocess's stdout pipe is read for the full lifetime of the app-server process, so it is exposed to the same cp932 UnicodeDecodeError failure mode described above, on a longer-lived and more central subprocess than the ten call sites already patched.

f9d7b4e adds errors="replace" alongside the encoding="utf-8" already present on that call, consistent with the rest of this PR. No other functional change.

Re-verified after this commit:

  • Manual repro (fable_advisor_mcp.py review_plan called 4x over stdio, PYTHONUTF8 unset, cp932 locale): 0/4 encoding crashes.
  • End-to-end codex exec smoke test with the configured Advisor/Executor routes (Claude Fable 5 review -> gpt-5.6-luna implementation of a small dummy function): completed with no Transport closed or UnicodeDecodeError.

gqrshy added a commit to gqrshy/Codex-Orchestration that referenced this pull request Jul 22, 2026
Strip inherited endpoint and default-model overrides before Claude Fable calls. Force UTF-8 on MCP stdio so Windows locale settings cannot corrupt JSON-RPC payloads. Subprocess encoding remains covered by Cjbuilds#11.
All subprocess.run(..., text=True, ...) calls that read/write Codex or
Claude Code CLI output relied on Python's locale-preferred encoding
(locale.getpreferredencoding()) instead of a fixed one. On Windows with
a Japanese system locale (cp932), any non-cp932 character in the child
process's stdout/stderr (e.g. an em dash in a Claude Fable 5 response)
raises an uncaught UnicodeDecodeError inside CPython's internal
reader/writer thread for the pipe. That exception is not visible to the
surrounding try/except in this codebase (it fires in a background
thread, not the caller), so the MCP server process crashes and the
stdio transport closes without a JSON-RPC response. From the Codex CLI
side this is observed as a bare "Transport closed" error with no
further detail. When the corrupt byte happens to fall in a CP932
double-byte lead/trail range instead of an invalid one, no exception is
raised at all and the text is silently mis-decoded (mojibake) instead.

Reproduced on Windows 11 (ja-JP, cp932) by calling
fable_advisor_mcp.py's review_plan directly over stdio: 2 of 4 natural
(non-adversarial) Claude Fable 5 responses containing an em dash
crashed the server with the UnicodeDecodeError described above; a
third case corrupted the em dash into unrelated CJK characters without
raising anything. Setting PYTHONUTF8=1 before the same 4 calls dropped
the crash rate to 0/4, confirming the encoding is the root cause -
but Codex does not set that variable when it spawns this plugin's MCP
server, so the workaround does not apply in normal use.

Fix: pass encoding="utf-8", errors="replace" explicitly everywhere
text=True is used for a subprocess whose output can contain arbitrary
model-generated or CLI-generated text:
  - fable_advisor_mcp.py: _run_json (claude auth status) and
    _invoke_fable (claude -p ... --output-format json), the two calls
    that read Claude Fable 5's own output and previously crashed.
  - inspect_models.py: inspect_version and load_catalog. The existing
    `except UnicodeDecodeError` around these calls does not actually
    catch this failure mode, since the decode happens in a background
    thread; the except clause is left in place as harmless dead code.
  - configure_native_routing.py: binary_version, supports_native_policy,
    select_fable_server, verify_fable_prerequisites.
  - configure_orchestration.py: catalog_source, load_catalog.

Not changed: the macOS-only `cp -a` metadata clone in
configure_orchestration.py's stage_existing_file (guarded by
`os.name == "posix"`), since it never runs on Windows and macOS's
default locale is UTF-8; left out to keep this change minimal.

Testing:
  - tests/test_fable_advisor_mcp.py and tests/test_inspect_models.py:
    23/23 passed (PYTHONUTF8=1 python -m pytest ...).
  - tests/test_native_routing.py and tests/test_configure_orchestration.py:
    65 failures both before and after this change (identical set),
    all pre-existing POSIX-only assumptions (hardlinks, xattr, fchmod)
    unrelated to this fix; no new failures introduced.
  - ruff check: passes on all four modified files.
  - Manual repro: 4x natural review_plan call without PYTHONUTF8 (2
    crashes) vs. 4x with this patch applied, still without PYTHONUTF8
    (0 crashes, including responses containing an em dash).
The prior commit (8d0a02d) fixed every subprocess.run(..., text=True, ...)
call but only searched for subprocess.run, so it missed the one
subprocess.Popen call: AppServer's launch of `codex app-server --stdio`
in configure_native_routing.py. That process's stdout pipe is read over
the process's full lifetime, so it is exposed to the same cp932
UnicodeDecodeError described in 8d0a02d, on a much longer-lived and more
central subprocess than the ones already patched.

Fix: add errors="replace" alongside the encoding="utf-8" already present
on that call, matching the rest of the codebase.
0.8.0 added external model integration (PR Cjbuilds#14/Cjbuilds#15/Cjbuilds#16/Cjbuilds#23), which
introduced 7 more subprocess.run call sites with the same
text=True + stdout/stderr=PIPE shape as the ones already patched in
64da77f/262a86c, and therefore the same cp932 UnicodeDecodeError risk
on non-UTF-8 locales (e.g. Windows). This adds encoding="utf-8",
errors="replace" to all 7:

  - external_auth_helper.py: _run_capture (1)
  - external_cli_trust.py: version (1)
  - external_configurator.py: _user_helper_ready, the two Gate 0 CLI
    contract checks ("exec --help" / "features list"), and the
    invoke-CLI-contract help check (4)
  - external_credentials.py: credential_ready (1)

Left untouched, with reasons:
  - external_auth_helper.py _run_interactive: text=False, stdout/stderr
    not captured (stdout=None, stderr=None) — nothing to decode.
  - external_configurator.py _run_invoke_process (subprocess.Popen):
    stdout=DEVNULL, stderr=DEVNULL — nothing captured, nothing to
    decode.
  - external_configurator.py the Gate 0 config-write call: text=False
    AND stdout/stderr=DEVNULL — doubly excluded.
  - macOS-only code paths (os.name == "posix" branches) were not
    touched anywhere in this change, consistent with 64da77f, since
    macOS's default locale is UTF-8 and this class of crash is
    Windows/cp932-specific.
@glayht96-ctrl
glayht96-ctrl force-pushed the fix/cp932-subprocess-encoding branch from f9d7b4e to cbcd3e7 Compare July 23, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant