Skip to content

Commit d8acc17

Browse files
authored
fix: read command output from a UTF-16 log on multibyte code pages
Fixes #102. ## Problem On Windows whose **system code page is multibyte** (Chinese 936, Japanese 932, Korean 949, or the "Use Unicode UTF-8" setting 65001), the debugger truncates its text output over a pipe: each line is written short by its multibyte expansion, dropping the tail (and newline) of any line that contains non-ASCII text. This lost the end of `du` strings and other Unicode output, and a line cut in the middle of a character left the session unresponsive to every later command. Reproduced on a Windows 11 VM. It affects **every command that prints Unicode**, not just `du`, and no pipe output mode avoids it: interactive, `-c` batch, and `.logopen` to an ANSI file all truncate, because the miscount is in the debugger's writer. ## Fix (transport level, not per-command) `.logopen /u` writes a **UTF-16 log** that is complete and flushes per command. On a multibyte code page the session 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** for markers, timeouts, go-commands and break-in; only the returned command output comes from the log. All the existing sync logic is untouched. - **Only for sessions whose engine is local** - a crash dump and a kernel target on the wire, where the debugger is our own subprocess and the log opens on this machine. A user-mode `-remote` client drives the engine on the server, so it keeps the pipe (see Limitations). - **Single-byte code pages** (Western 1252 and the like), where the pipe is lossless, are detected with `GetCPInfo` and keep the pipe path, byte-for-byte unchanged. - **Prompt scaffolding stripped** from the log transcript: the bare `0:000>`, kernel `0: kd>` / local `lkd>`, WOW64 `1:001:x86>`, and remote `[server (tcp ...)] 0:000>` forms. - **Reader hardened**: the pipe is decoded with the debugger's own ANSI code page and `errors="replace"`, so a split multibyte sequence can no longer raise in the reader thread on the paths that still read the pipe. - **Cleaned up**: the temporary log is deleted on session shutdown (with a short retry, since a force-killed debugger may still be releasing the handle). ## Research: can the pipe itself be UTF-16? Checked before going the log route. A Windows pipe is a raw byte stream; the writer picks the encoding, and cdb/kd convert their internal Unicode to the ANSI code page (the truncating `WriteFile` path) whenever stdout is a redirected handle. There is no cdb.exe switch to emit UTF-16/UTF-8 to the pipe. The only native Unicode sinks the engine offers are the wide output callback `IDebugOutputCallbacksWide` (which would mean hosting `dbgeng.dll` ourselves, a full re-architecture) and the Unicode log (`.logopen /u`, or the command-line `-logou`/`-logau`). A pseudo-console (ConPTY) would make cdb call `WriteConsoleW`, but its output arrives wrapped in VT escape sequences and hard-wrapped at the console width, which is worse to parse than the log. The log is the supported, clean mechanism. ## Verification Reproduced at the 1.2.1 release commit and verified with this branch on a Windows 11 VM, stock SDK cdb/kd 10.0.26100.1742: | System code page | before | this branch | | :-- | :-- | :-- | | 1252 (Western) | works | passthrough, byte-for-byte unchanged | | 936 (Chinese) | empty / truncated / wedged | full Unicode for every command | | 65001 (UTF-8) | empty / truncated / wedged | full Unicode for every command | `du`, `dw`, `db`, `.echo`, `version` and the rest return complete text; the temp log leaves nothing behind. Full suite: 106 passed on the 1252 host and 111 on the VM under both 936 and 65001; the hermetic tests drive the log path on any platform (a fake debugger mirrors a UTF-16 log) and are pinned to the single-byte path by default so they behave the same regardless of the host's real code page. - **Crash dump:** full CJK strings recovered for `du`/`du <addr> L<n>` under 936 and 65001. - **Kernel (kd.exe):** verified against a live kernel via local kernel debugging on the UTF-8 VM - the log transport engages, and `version`, `lm m nt` and a live kernel-memory read return complete output with the `lkd>` prompt stripped and the temp log cleaned up. - **Remote process debugging (`-remote`):** end-to-end against a real `.server` host - attach, process (`|`), threads (`~`), modules (`lm`) and registers all work. The log stays off (engine is on the server), so this is unchanged from before. ## Limitations - A `-remote` **client on a multibyte code page** still truncates Unicode output: the engine runs on the server, so its Unicode log is not on our machine (and for a cross-machine server would not be reachable at all). Remote debugging otherwise works; this is unchanged from before the fix. - A **live target's asynchronous output** (a go/break-in bugcheck banner) still comes from the pipe, so a bugcheck message containing non-ASCII text could still truncate. Ordinary command output, which is what #102 is about, is fully covered.
1 parent d3d1fd0 commit d8acc17

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)