fix(terminal): keep native Windows diagnostics readable - #89468
fix(terminal): keep native Windows diagnostics readable#89468fangliquanflq wants to merge 5 commits into
Conversation
|
I have a ja-JP Windows box (ACP=932), so I ran the CP932 half of this. The numbers below are from that machine — harness and raw output: https://github.com/yoggydev/cp932-pipe-probe (MIT). Drafted with Claude; the measurements are mine. Same failure reproduces on ja-JP. A fixed 50-byte CP932 sequence pushed through a pipe, decoded two ways (Windows PowerShell 5.1):
The four backslashes are the ja-JP-specific part. CP932 trail bytes are GBK has Two things that may matter for the fix:
Happy to run anything else against ja-JP — that's the environment I have. |
|
Thank you for validating this on an ACP 932 machine and for documenting the
I additionally exercised This is therefore informational validation rather than a required code change, and it materially strengthens the cross-locale evidence for the fix. Thanks also for offering access to the ja-JP environment. |
|
That all matches what I see, and Agreed the U+FFFD count is not load-bearing for the fix. I raised it to rule out the tempting shortcut of detecting mangled output downstream by counting replacement characters, not to influence codec selection. Your six bytes are the right set to have picked — there are 50 in the full CP932 |
|
Thanks for confirming the |
monerostar
left a comment
There was a problem hiding this comment.
Native Win11 review (en-US ACP 1252, PYTHONUTF8=1)
Host: Windows 11 10.0.26200.9168, Hermes venv Python 3.11.15. Approve is blocked for this fork-scope token.
This box is the complementary locale to the existing zh-CN / ja-JP comments: ANSI code page 1252, and this process has PYTHONUTF8=1.
Codec selection (this is the useful bit)
PYTHONUTF8 = 1
locale.getencoding() = cp1252
locale.getpreferredencoding(False) = utf-8
_windows_output_encoding() = cp1252
So locale.getencoding() is the correct API here: under UTF-8 mode, getpreferredencoding(False) would report utf-8 and disable the fallback entirely. The PR already does this; this host confirms it.
Decoder probes (live, default host fallback = cp1252)
| input | result | U+FFFD |
|---|---|---|
caf\xe9\n (cp1252) |
café |
0 |
UTF-8 café\n |
café |
0 |
UTF-8 工具\n one byte at a time |
工具 |
0 |
error \x97 failed\n (cp1252 em dash) |
error — failed |
0 |
UTF-8 still wins when it is valid; host ACP is used only when it is not.
Tests
pytest tests/tools/test_base_environment.py tests/tools/test_process_registry.py -k "decoder or Incremental or windows_output or encoding or 936 or native" -o addopts=
8 passed, including the cp936 fixtures, mixed UTF-8/ACP records, split-chunk UTF-8, foreground wait, background reader, and byte-returning PTY path.
CI on the PR is already green (including Windows-only tests). I did not re-run a zh-CN native child through Git Bash; this host is ACP 1252.
Looks correct from the en-US + UTF-8-mode side.
|
Thank you for the native Windows ACP 1252 and |
|
@fangliquanflq - this PR and my #89465 are the same fix, opened two minutes apart (mine 2026-08-18T20:45Z, yours 20:47Z). Neither of us could have seen the other; I only found it today with a sweep that looks for PRs opened after mine that cite the same issue or touch the same files. Flagging it rather than letting two heads sit on I have read both diffs properly, and I think yours should be the base. Reasoning, so it is checkable rather than polite: Where yours is better, and it is not a small marginYou wired it into the background readers; I did not. And your ASCII fast-path is what makes that possible. This is the part I want to be explicit about, because it is the thing I got structurally wrong: ascii_end = 0
while ascii_end < len(self._buffer) and self._buffer[ascii_end] < 0x80:
ascii_end += 1My decoder buffers to a newline unconditionally. I justified that in the docstring on the grounds that Three things from mine worth folding inOffered as patches to your branch, not as a reason to prefer my head: 1. The fallback should be gated per environment, not on Mine handles it with a hook - 2. A NUL-byte guard before the fallback. If a record contains 3. What I would like to doClose #89465 as a duplicate of this one once you have had a chance to say whether you want those three folded in. I would rather send them as a patch against your branch than have two heads at the same seam - say the word and I will open it against your fork, or just take the descriptions above and write them yourself, whichever you prefer. Either way the tests in mine that might be useful to you are cc @maintainers for the disposition: two open PRs, same seam, same bug, opened two minutes apart. My recommendation is this one. |
|
Thanks for the careful comparison and for coordinating the duplicate. I agreed with all three suggested changes and pushed them to this branch:
The bounded decoder also retains one selected codec for an over-limit record, preventing a long ACP record from switching to UTF-8 after a flush. I added behavioral coverage for environment opt-in, CR boundaries, NUL records, split chunks, bounded flushes, and long-record codec consistency. Verification:
The full two-file run also exposed unrelated native-Windows failures in pre-existing /bin/bash and POSIX-only tests; all decoder and reader-loop tests relevant to this change pass. Your recommendation to use this PR as the shared base makes sense; no patch against the fork is needed now. |
yoggydev
left a comment
There was a problem hiding this comment.
Native ja-JP review of the four commits added since my last comment.
Host: Windows 11, ACP 932, OEMCP 932, locale.getencoding() = cp932, Python 3.12.10.
What I went after. _flush_long_buffer now pins the codec for the rest of an over-limit record:
if exc.end == len(raw) and exc.reason == "unexpected end of data" and exc.start:
...
self._record_encoding = "utf-8"CP932's two-byte lead range includes 0xE0-0xEF, which is exactly UTF-8's three-byte lead range — 2,353 characters in the table on this host. So a CP932 record ending mid-character in that range presents to a strict UTF-8 decode as truncated-but-valid, and if that branch fired at a flush it would pin the whole remainder of the record to UTF-8. That is the failure this commit's guard has to avoid, so it is where I pushed.
It cannot fire. decode() strips the leading ASCII run out of the buffer before it tests _PROBE_LIMIT, so the buffer at flush time always begins with a non-ASCII byte. exc.start is then 0 and the branch is skipped. I went in expecting to break this and found the ordering was already doing the work — I had not read that ASCII-strip carefully enough the first time.
Measured, driving _IncrementalOutputDecoder from this branch directly:
| cases | mismatches | |
|---|---|---|
| randomized CP932 records, 1-5 random chunk splits | 4,000 | 0 |
| 4096-boundary: every 0xE0-0xEF lead x trail samples x pad 4094/4095/4096/4097 | 256 | 0 |
Records were terminated with \n and \r at random, so the CR-boundary change in 6ded5ac is exercised rather than assumed. Character pool: 7,336 CP932 characters.
The other thing I checked. With _fallback_encoding now defaulting to None on BaseEnvironment (abc3a4e), self._record_encoding = self._fallback_encoding can leave it None, and the next statement is codecs.getincrementaldecoder(self._record_encoding). That path is unreachable: decode() early-returns to the plain UTF-8 incremental decoder when _fallback_encoding is falsy, and __init__ normalises a utf-8 fallback to None. Worth stating because the two commits that create the situation are separate.
What this does not cover. The harness forces fallback_encoding="cp932" and drives the decoder in isolation, so it exercises the codec logic and not the foreground / background / byte-PTY plumbing around it. The host line at the top is separate evidence that _windows_output_encoding() resolves correctly here.
Looks correct from the CP932 side. If you want a different fixture — a wider trail-byte set, or a live Git Bash child instead of the decoder alone — I can run it on this host.
(Measured on my ja-JP host. Drafted with Claude.)
What does this PR do?
Windows users whose native programs emit the system ANSI code page now receive readable localized terminal output instead of irreversible U+FFFD replacement characters. The terminal still preserves UTF-8 output from MSYS tools, including streams where multibyte characters cross pipe-read boundaries.
Symptom
On a zh-CN Windows host using code page 936, localized output from a native program invoked through Git Bash was decoded as UTF-8 with replacement enabled. The terminal result contained replacement characters instead of the original message.
Impact
Affected Windows users lose diagnostic text from native commands, so error messages can become unreadable before the agent receives them. The verified failure affects foreground local execution and the equivalent raw-byte background reader path.
Bug Cause
Trigger:
tools/environments/base.py:1164/BaseEnvironment._wait_for_processwhen Git Bash forwards bytes from a native Windows child.Causal chain:
Why it is wrong: Git Bash can carry both UTF-8 MSYS output and native Windows code-page output, so one fixed UTF-8 decoder cannot represent every valid stream.
Working sibling / contrast: UTF-8 output from MSYS tools already decodes correctly and must remain unchanged; pywinpty string output is already decoded by its provider and does not need byte decoding.
Ruled out: The native process does not emit damaged text. Captured raw bytes decode correctly as code page 936, which isolates the loss to Hermes' raw-byte decoding step.
Fix
Add a shared bounded incremental decoder that prefers strict UTF-8 for each complete record and falls back to the host Windows ANSI codec only when UTF-8 is invalid. Use it for foreground pipes, background process pipes, and byte-returning PTYs so mixed encodings and multibyte sequences split across chunks are preserved without changing already-decoded string output.
Related Issue
Closes #89442
Type of Change
Changes Made
tools/environments/base.py- detect the native Windows output codec, add bounded mixed-encoding incremental decoding, and use it for foreground raw-byte output.tools/process_registry.py- share the decoder across background pipe and byte-returning PTY readers.tests/tools/test_base_environment.py- cover UTF-8, code page 936, mixed records, prompt output, foreground execution, and chunk boundaries.tests/tools/test_process_registry.py- cover native Windows bytes and split multibyte sequences in background and PTY readers.How to Test
Checklist
Code
fix(scope):,feat(scope):, etc.)Documentation & Housekeeping
docs/, docstrings) - N/A, no user-facing configuration changedcli-config.yaml.exampleif I added/changed config keys - N/A, no configuration keys changedCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows - N/A, no architecture or workflow changed