Skip to content

Commit 4d3e4d5

Browse files
committed
fix: read command output from a UTF-16 log on multibyte code pages
On a multibyte system code page (Chinese 936, Japanese 932, Korean 949, or the "Use Unicode UTF-8" setting 65001) the debugger truncates its text output over a pipe: it writes each line's character count as a byte count, so the tail of any line with non-ASCII text, its newline included, is dropped. This lost the end of du strings and other Unicode output, and a line cut in the middle of a character wedged the session. It affects every command, not just du, and no debugger output mode over the pipe avoids it. .logopen /u writes a UTF-16 log that is complete and flushes per command. So on a multibyte code page the session now opens such a log at startup and reads each command's output from it, keyed by the same .echo markers the pipe already uses to synchronize. The pipe stays the control channel; only the returned content comes from the log. Single-byte code pages (e.g. Western 1252), where the pipe is lossless, are detected via GetCPInfo and keep the pipe path, byte-for-byte unchanged. The pipe is also decoded with the debugger's own ANSI code page and with errors="replace", so a multibyte sequence split across reads cannot raise in the reader thread on the paths that still read the pipe (a live target's asynchronous output). Verified on a Windows 11 VM with cdb 10.0.26100.1742 under code pages 1252 (passthrough), 936 and 65001 (all commands return complete Unicode). Fixes #102.
1 parent d3d1fd0 commit 4d3e4d5

5 files changed

Lines changed: 348 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to the MCP Server for WinDbg Crash Analysis project will be
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- **Unicode command output on Chinese, Japanese and Korean Windows** ([#102](https://github.com/svnscha/mcp-windbg/issues/102)) - on a multibyte system code page (936, 932, 949, or the "Use Unicode UTF-8" setting 65001) the debugger truncates its text output over a pipe, dropping the tail of any line that contains non-ASCII text; a `du` of a Chinese string came back empty or partial, other Unicode output was cut short, and a line split in the middle of a character could leave the session unresponsive. On these code pages the session now mirrors output to a UTF-16 log and reads each command's output from it, keyed by the markers it already uses to synchronize; the result is complete for every command. Single-byte code pages (such as Western 1252), where the pipe is lossless, are detected and left on the pipe path unchanged.
13+
814
## [1.2.1] - 2026-08-27
915

1016
### Changed

src/mcp_windbg/cdb_session.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ def __init__(
8383
self.dump_path = dump_path
8484
self.remote_connection = remote_connection
8585
self.is_live_session = bool(remote_connection)
86+
# A -remote client drives a debug engine on the server, so .logopen would
87+
# open the Unicode log on the server (a path/lifecycle we do not own).
88+
# The log-output transport is only for sessions whose engine is ours.
89+
self._engine_is_local = remote_connection is None
8690

8791
cdb_path = find_executable(DEFAULT_CDB_PATHS, cdb_path)
8892
if not cdb_path:

src/mcp_windbg/debug_session.py

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,14 @@
2828

2929
from __future__ import annotations
3030

31+
import locale
3132
import os
3233
import re
3334
import signal
3435
import subprocess
36+
import tempfile
3537
import threading
38+
import time
3639
from typing import List, Optional
3740

3841
# Detects a CDB/KD prompt line such as ``0:000>`` or ``3: kd>``.
@@ -132,6 +135,69 @@ def find_executable(paths: List[str], custom_path: Optional[str] = None) -> Opti
132135
return None
133136

134137

138+
def _debugger_output_encoding() -> str:
139+
"""The code page cdb/kd write their output in - the process ANSI code page,
140+
which ``locale.getencoding`` reports (``getpreferredencoding`` on 3.10)."""
141+
if hasattr(locale, "getencoding"):
142+
return locale.getencoding()
143+
return locale.getpreferredencoding(False)
144+
145+
146+
# A prompt at the head of a logged line: ``0:000> ``, ``0: kd> ``, ``1:001:x86> ``,
147+
# the local-kernel ``lkd> ``/``kd> `` forms, and the remote form that carries a
148+
# ``[server (tcp ...)]`` banner first. In the Unicode log these prefix the
149+
# command echo and the .echo marker line; the debugger's own output lines never
150+
# start with one.
151+
_LOGGED_PROMPT = re.compile(r"^(?:\[.*\]\s*)?(?:\d+:[^>]*|l?kd)>")
152+
153+
154+
def _acp_is_multibyte() -> bool:
155+
"""True when this machine's ANSI code page is multibyte (DBCS or UTF-8).
156+
157+
That is exactly when the debugger truncates its text output over a pipe, so
158+
it is the gate for reading output from the Unicode log instead. Uses
159+
``GetCPInfo(GetACP()).MaxCharSize`` - 1 for a single-byte page such as
160+
Western 1252, greater for 932/936/949/950/65001. False where the call is
161+
unavailable, so a single-byte or non-Windows host keeps the pipe path.
162+
"""
163+
try:
164+
import ctypes
165+
166+
class _CPINFO(ctypes.Structure):
167+
_fields_ = [
168+
("MaxCharSize", ctypes.c_uint),
169+
("DefaultChar", ctypes.c_char * 2),
170+
("LeadByte", ctypes.c_char * 12),
171+
]
172+
173+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
174+
info = _CPINFO()
175+
if kernel32.GetCPInfo(kernel32.GetACP(), ctypes.byref(info)):
176+
return info.MaxCharSize > 1
177+
except Exception:
178+
pass
179+
return False
180+
181+
182+
def _extract_log_output(segment: str) -> List[str]:
183+
"""The debugger's own output lines from one command's Unicode-log segment.
184+
185+
The segment runs from just after the previous command's marker up to (not
186+
including) the line that echoes this command's ``.echo <marker>``. Its first
187+
line is the echo of the command itself; both it and any other prompt-prefixed
188+
line are the transcript's scaffolding, not output, and are dropped. Leading
189+
and trailing blank lines (a bare prompt writes one) are trimmed so a command
190+
that prints nothing yields ``[]``, as the pipe path does.
191+
"""
192+
lines = [ln.rstrip("\r") for ln in segment.split("\n")]
193+
kept = [ln for ln in lines if not _LOGGED_PROMPT.match(ln)]
194+
while kept and kept[0] == "":
195+
kept.pop(0)
196+
while kept and kept[-1] == "":
197+
kept.pop()
198+
return kept
199+
200+
135201
class DebuggerSession:
136202
"""A debugger subprocess plus the marker protocol used to drive it.
137203
@@ -145,6 +211,12 @@ class DebuggerSession:
145211
#: break in) and are detached with CTRL+B instead of quit with ``q``.
146212
is_live_session: bool = False
147213

214+
#: Whether this session's debug engine is our own subprocess (a dump or a
215+
#: kernel target on the wire), rather than a remote server we are only a
216+
#: client of. The Unicode-log transport needs the engine local, since it
217+
#: opens and reads a log file on this machine. A -remote client sets False.
218+
_engine_is_local: bool = True
219+
148220
def __init__(
149221
self,
150222
*,
@@ -185,16 +257,29 @@ def __init__(
185257
stdout=subprocess.PIPE,
186258
stderr=subprocess.STDOUT,
187259
text=True,
260+
# cdb/kd write output in the process ANSI code page (GetACP),
261+
# regardless of PYTHONUTF8; decode with the same one, and never
262+
# strictly, so a byte a multibyte code page split across reads
263+
# cannot raise in the reader thread and wedge the session.
264+
encoding=_debugger_output_encoding(),
265+
errors="replace",
188266
bufsize=1,
189267
creationflags=creationflags,
190268
)
191269
except Exception as e: # pragma: no cover - Popen rarely fails once the exe is located
192270
raise DebuggerError(f"Failed to start debugger process: {e}")
193271

272+
#: Unicode-log content channel (see _enable_unicode_log). Inactive until
273+
#: the log is open, and only ever opened on a multibyte code page.
274+
self._log_path: Optional[str] = None
275+
self._log_offset = 0
276+
self._log_active = False
277+
194278
self.reader_thread = threading.Thread(target=self._read_output, daemon=True)
195279
self.reader_thread.start()
196280

197281
self._startup()
282+
self._enable_unicode_log()
198283

199284
# -- Subclass hooks ---------------------------------------------------
200285

@@ -438,7 +523,94 @@ def _send_marked(
438523
f"Command timed out after {cmd_timeout} seconds: {command}{detail}{lost}"
439524
)
440525

441-
return self._take_output()
526+
pipe_output = self._take_output()
527+
if self._log_active:
528+
# The pipe truncates multibyte output; the log does not. Prefer the
529+
# log segment for this command, falling back to the pipe if the log
530+
# has not caught up (it always should, the marker just landed).
531+
logged = self._read_log_segment(marker)
532+
if logged is not None:
533+
return logged
534+
return pipe_output
535+
536+
# -- Unicode log content channel --------------------------------------
537+
538+
def _enable_unicode_log(self) -> None:
539+
"""On a multibyte code page, mirror output to a UTF-16 log and read
540+
command output from it instead of the truncating ANSI pipe.
541+
542+
cdb/kd write pipe (and ANSI-log) output short by each line's multibyte
543+
expansion, losing the tail of any non-ASCII line; ``.logopen /u`` writes
544+
a UTF-16 log that is complete and flushes per command. The pipe stays the
545+
sync channel - the ``.echo`` markers are logged too, so each command's
546+
output is the log slice ahead of its marker. Best effort: any failure
547+
leaves the session on the pipe path exactly as before.
548+
"""
549+
if not _acp_is_multibyte() or not self._engine_is_local:
550+
return
551+
try:
552+
fd, path = tempfile.mkstemp(prefix="mcp_windbg_", suffix=".ulog")
553+
os.close(fd)
554+
os.remove(path) # cdb creates it; a pre-existing file would be appended to
555+
self._send_marked(f".logopen /u {path}", self.timeout)
556+
if not os.path.exists(path):
557+
return
558+
self._log_path = path
559+
self._log_offset = 0
560+
self._log_active = True
561+
# The log opens mid-command, so it starts with its banner and the
562+
# logopen echo. One throwaway marked command drains all of that and
563+
# leaves the offset at a clean boundary - robust to log-flush timing
564+
# in a way that trusting the post-open file size is not.
565+
self._send_marked(".echo", self.timeout)
566+
except Exception:
567+
self._cleanup_log()
568+
569+
def _read_log_segment(self, marker: str) -> Optional[List[str]]:
570+
"""This command's output from the Unicode log, or None if not ready.
571+
572+
The marker appears twice in the log: first in the echoed ``.echo
573+
<marker>`` command, then as that command's output. Everything before the
574+
first is this command's transcript; consuming through the second leaves
575+
the offset at a clean boundary for the next command.
576+
"""
577+
deadline = time.time() + max(2.0, self.timeout / 10)
578+
while True:
579+
try:
580+
with open(self._log_path, "rb") as handle:
581+
handle.seek(self._log_offset)
582+
text = handle.read().decode("utf-16-le", errors="replace")
583+
except OSError:
584+
return None
585+
first = text.find(marker)
586+
second = text.find(marker, first + len(marker)) if first != -1 else -1
587+
if first != -1 and second != -1:
588+
line_start = text.rfind("\n", 0, first) + 1
589+
end_nl = text.find("\n", second)
590+
end = len(text) if end_nl == -1 else end_nl + 1
591+
self._log_offset += len(text[:end].encode("utf-16-le"))
592+
return _extract_log_output(text[:line_start])
593+
if time.time() >= deadline:
594+
return None
595+
time.sleep(0.02)
596+
597+
def _cleanup_log(self) -> None:
598+
self._log_active = False
599+
if not self._log_path:
600+
return
601+
# A detached remote client is force-killed rather than quit, so the OS
602+
# may still be releasing its handle on the log file when we get here; a
603+
# dump/kernel session that quit cleanly releases it at once. Retry
604+
# briefly so the temp file is not leaked in the remote case.
605+
for _ in range(20):
606+
try:
607+
os.remove(self._log_path)
608+
break
609+
except FileNotFoundError:
610+
break
611+
except OSError:
612+
time.sleep(0.05)
613+
self._log_path = None
442614

443615
def _marker_landed(self) -> bool:
444616
"""True if the pending marker arrived just as the deadline expired."""
@@ -710,6 +882,7 @@ def shutdown(self) -> None:
710882
print(f"Error during shutdown: {e}")
711883
finally:
712884
self.process = None
885+
self._cleanup_log()
713886

714887
def _terminate_process(self) -> None:
715888
"""Kill the debugger process. On Windows use a tree kill: cdb.exe/kd.exe

src/mcp_windbg/tests/test_debug_session.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from __future__ import annotations
1313

14+
import os
1415
import queue
1516
import threading
1617
import time
@@ -88,6 +89,10 @@ def __init__(
8889
#: test hang one specific command without also hanging the break-in
8990
#: probe that precedes it.
9091
self.answer_budget: "int | None" = None
92+
#: When a ``.logopen /u <path>`` is seen, the fake mirrors a UTF-16
93+
#: transcript to this file the way cdb/kd do, so the log-content channel
94+
#: can be exercised without a real multibyte debugger.
95+
self._log_path = None
9196

9297
def _feed(self, text: str):
9398
for line in text.splitlines():
@@ -96,7 +101,20 @@ def _feed(self, text: str):
96101
else:
97102
self._handle(line)
98103

104+
def _open_log(self, path: str):
105+
self._log_path = path
106+
with open(path, "wb") as handle:
107+
handle.write(b"\xff\xfe") # UTF-16LE BOM, as cdb writes
108+
self._log_write("Opened log file\r\n")
109+
110+
def _log_write(self, text: str):
111+
with open(self._log_path, "ab") as handle:
112+
handle.write(text.encode("utf-16-le"))
113+
99114
def _handle(self, line: str):
115+
logging = self._log_path is not None and not line.startswith(".logopen")
116+
if logging:
117+
self._log_write(f"0:000> {line}\r\n") # the transcript echoes the command
100118
if line.startswith(".echo "):
101119
marker = line[len(".echo "):]
102120
if self.answer_budget is not None:
@@ -105,6 +123,10 @@ def _handle(self, line: str):
105123
self.answer_budget -= 1
106124
if not self._swallow:
107125
self._out.put(marker)
126+
if logging:
127+
self._log_write(f"{marker}\r\n")
128+
elif line.startswith(".logopen /u "):
129+
self._open_log(line[len(".logopen /u "):].strip())
108130
elif line in ("q", "\x02"):
109131
# quit / detach: the real process exits, ending the reader loop
110132
self._alive = False
@@ -113,6 +135,8 @@ def _handle(self, line: str):
113135
self.running = True
114136
else:
115137
self._out.put(f"OUT:{line}")
138+
if logging:
139+
self._log_write(f"OUT:{line}\r\n")
116140

117141
def target_stops(self, *lines: str):
118142
"""The target halts on its own (bugcheck, breakpoint), draining stdin."""
@@ -158,6 +182,15 @@ def _fast_break_in_probe(monkeypatch):
158182
monkeypatch.setattr(debug_session, "RESUME_CONFIRM_TIMEOUT", 0.2)
159183

160184

185+
@pytest.fixture(autouse=True)
186+
def _single_byte_code_page(monkeypatch):
187+
"""Default the hermetic tests to the single-byte (pipe) path, so they behave
188+
the same whatever the host's real code page is - otherwise a session built on
189+
a multibyte host would auto-open the Unicode log mid-test. The log tests
190+
re-patch this to True where they need it."""
191+
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: False)
192+
193+
161194
@pytest.fixture(autouse=True)
162195
def _ctrl_break_event(monkeypatch):
163196
"""CTRL_BREAK_EVENT only exists on Windows, and the fake process never
@@ -599,3 +632,66 @@ def test_a_timed_out_prefix_still_reports_why_the_target_stopped(make_session):
599632
with pytest.raises(DebuggerError) as exc:
600633
session.send_command("bp nt!NtCreateFile; g", timeout=1)
601634
assert "Fatal System Error" in str(exc.value)
635+
636+
637+
def test_output_is_read_from_the_unicode_log_on_a_multibyte_code_page(make_session, monkeypatch):
638+
"""On a multibyte code page the session opens a UTF-16 log and returns each
639+
command's output from it (the fake mirrors that log). The pipe still drives
640+
the markers; only the returned content comes from the log."""
641+
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
642+
session, proc = make_session()
643+
644+
assert session._log_active is True
645+
assert proc._log_path is not None and os.path.exists(proc._log_path)
646+
# Content comes from the log, marker-synced, with the prompt/echo scaffolding
647+
# stripped - the same lines the pipe path would have returned.
648+
assert session.send_command("r rip") == ["OUT:r rip"]
649+
assert session.send_command("du @rsp") == ["OUT:du @rsp"]
650+
651+
logpath = session._log_path
652+
session.shutdown()
653+
assert not os.path.exists(logpath) # the temp log is cleaned up
654+
655+
656+
def test_a_missing_log_falls_back_to_the_pipe_without_raising(make_session, monkeypatch):
657+
"""If the log cannot be read, the command still returns (from the pipe)
658+
rather than failing - the reader must never depend on the log existing."""
659+
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
660+
session, proc = make_session()
661+
assert session._log_active is True
662+
663+
# Drop the log out from under the reader: the next command falls back.
664+
os.remove(session._log_path)
665+
proc._log_path = None
666+
assert session.send_command("lm") == ["OUT:lm"]
667+
668+
669+
def test_the_log_is_left_untouched_on_a_single_byte_code_page(make_session, monkeypatch):
670+
"""The default (single-byte) path never opens a log and returns pipe output
671+
verbatim, so a Western setup is byte-for-byte unchanged."""
672+
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: False)
673+
session, proc = make_session()
674+
675+
assert session._log_active is False
676+
assert proc._log_path is None
677+
assert session.send_command("r rip") == ["OUT:r rip"]
678+
679+
680+
def test_a_remote_client_keeps_the_pipe_even_on_a_multibyte_code_page(monkeypatch):
681+
"""A -remote client's engine runs on the server, so the log would open there,
682+
not here. Such a session must stay on the pipe even on a multibyte page."""
683+
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
684+
685+
class _RemoteLike(DebuggerSession):
686+
is_live_session = True
687+
_engine_is_local = False
688+
689+
proc = _FakeProc()
690+
monkeypatch.setattr(debug_session.subprocess, "Popen", lambda *a, **k: proc)
691+
session = _RemoteLike(debugger_path="fake", launch_args=["fake"], timeout=5, verbose=False)
692+
try:
693+
assert session._log_active is False
694+
assert proc._log_path is None
695+
assert session.send_command("r rip") == ["OUT:r rip"]
696+
finally:
697+
session.shutdown()

0 commit comments

Comments
 (0)