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.
This commit is contained in:
Sven Scharmentke
2026-09-05 10:23:05 +02:00
committed by GitHub
parent d3d1fd06be
commit d8acc17885
5 changed files with 348 additions and 1 deletions
+6
View File
@@ -5,6 +5,12 @@ All notable changes to the MCP Server for WinDbg Crash Analysis project will be
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **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.
## [1.2.1] - 2026-08-27
### Changed
+4
View File
@@ -83,6 +83,10 @@ class CDBSession(DebuggerSession):
self.dump_path = dump_path
self.remote_connection = remote_connection
self.is_live_session = bool(remote_connection)
# A -remote client drives a debug engine on the server, so .logopen would
# open the Unicode log on the server (a path/lifecycle we do not own).
# The log-output transport is only for sessions whose engine is ours.
self._engine_is_local = remote_connection is None
cdb_path = find_executable(DEFAULT_CDB_PATHS, cdb_path)
if not cdb_path:
+174 -1
View File
@@ -28,11 +28,14 @@ Two robustness properties this base guarantees:
from __future__ import annotations
import locale
import os
import re
import signal
import subprocess
import tempfile
import threading
import time
from typing import List, Optional
# 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
return None
def _debugger_output_encoding() -> str:
"""The code page cdb/kd write their output in - the process ANSI code page,
which ``locale.getencoding`` reports (``getpreferredencoding`` on 3.10)."""
if hasattr(locale, "getencoding"):
return locale.getencoding()
return locale.getpreferredencoding(False)
# A prompt at the head of a logged line: ``0:000> ``, ``0: kd> ``, ``1:001:x86> ``,
# the local-kernel ``lkd> ``/``kd> `` forms, and the remote form that carries a
# ``[server (tcp ...)]`` banner first. In the Unicode log these prefix the
# command echo and the .echo marker line; the debugger's own output lines never
# start with one.
_LOGGED_PROMPT = re.compile(r"^(?:\[.*\]\s*)?(?:\d+:[^>]*|l?kd)>")
def _acp_is_multibyte() -> bool:
"""True when this machine's ANSI code page is multibyte (DBCS or UTF-8).
That is exactly when the debugger truncates its text output over a pipe, so
it is the gate for reading output from the Unicode log instead. Uses
``GetCPInfo(GetACP()).MaxCharSize`` - 1 for a single-byte page such as
Western 1252, greater for 932/936/949/950/65001. False where the call is
unavailable, so a single-byte or non-Windows host keeps the pipe path.
"""
try:
import ctypes
class _CPINFO(ctypes.Structure):
_fields_ = [
("MaxCharSize", ctypes.c_uint),
("DefaultChar", ctypes.c_char * 2),
("LeadByte", ctypes.c_char * 12),
]
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
info = _CPINFO()
if kernel32.GetCPInfo(kernel32.GetACP(), ctypes.byref(info)):
return info.MaxCharSize > 1
except Exception:
pass
return False
def _extract_log_output(segment: str) -> List[str]:
"""The debugger's own output lines from one command's Unicode-log segment.
The segment runs from just after the previous command's marker up to (not
including) the line that echoes this command's ``.echo <marker>``. Its first
line is the echo of the command itself; both it and any other prompt-prefixed
line are the transcript's scaffolding, not output, and are dropped. Leading
and trailing blank lines (a bare prompt writes one) are trimmed so a command
that prints nothing yields ``[]``, as the pipe path does.
"""
lines = [ln.rstrip("\r") for ln in segment.split("\n")]
kept = [ln for ln in lines if not _LOGGED_PROMPT.match(ln)]
while kept and kept[0] == "":
kept.pop(0)
while kept and kept[-1] == "":
kept.pop()
return kept
class DebuggerSession:
"""A debugger subprocess plus the marker protocol used to drive it.
@@ -145,6 +211,12 @@ class DebuggerSession:
#: break in) and are detached with CTRL+B instead of quit with ``q``.
is_live_session: bool = False
#: Whether this session's debug engine is our own subprocess (a dump or a
#: kernel target on the wire), rather than a remote server we are only a
#: client of. The Unicode-log transport needs the engine local, since it
#: opens and reads a log file on this machine. A -remote client sets False.
_engine_is_local: bool = True
def __init__(
self,
*,
@@ -185,16 +257,29 @@ class DebuggerSession:
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
# cdb/kd write output in the process ANSI code page (GetACP),
# regardless of PYTHONUTF8; decode with the same one, and never
# strictly, so a byte a multibyte code page split across reads
# cannot raise in the reader thread and wedge the session.
encoding=_debugger_output_encoding(),
errors="replace",
bufsize=1,
creationflags=creationflags,
)
except Exception as e: # pragma: no cover - Popen rarely fails once the exe is located
raise DebuggerError(f"Failed to start debugger process: {e}")
#: Unicode-log content channel (see _enable_unicode_log). Inactive until
#: the log is open, and only ever opened on a multibyte code page.
self._log_path: Optional[str] = None
self._log_offset = 0
self._log_active = False
self.reader_thread = threading.Thread(target=self._read_output, daemon=True)
self.reader_thread.start()
self._startup()
self._enable_unicode_log()
# -- Subclass hooks ---------------------------------------------------
@@ -438,7 +523,94 @@ class DebuggerSession:
f"Command timed out after {cmd_timeout} seconds: {command}{detail}{lost}"
)
return self._take_output()
pipe_output = self._take_output()
if self._log_active:
# The pipe truncates multibyte output; the log does not. Prefer the
# log segment for this command, falling back to the pipe if the log
# has not caught up (it always should, the marker just landed).
logged = self._read_log_segment(marker)
if logged is not None:
return logged
return pipe_output
# -- Unicode log content channel --------------------------------------
def _enable_unicode_log(self) -> None:
"""On a multibyte code page, mirror output to a UTF-16 log and read
command output from it instead of the truncating ANSI pipe.
cdb/kd write pipe (and ANSI-log) output short by each line's multibyte
expansion, losing the tail of any non-ASCII line; ``.logopen /u`` writes
a UTF-16 log that is complete and flushes per command. The pipe stays the
sync channel - the ``.echo`` markers are logged too, so each command's
output is the log slice ahead of its marker. Best effort: any failure
leaves the session on the pipe path exactly as before.
"""
if not _acp_is_multibyte() or not self._engine_is_local:
return
try:
fd, path = tempfile.mkstemp(prefix="mcp_windbg_", suffix=".ulog")
os.close(fd)
os.remove(path) # cdb creates it; a pre-existing file would be appended to
self._send_marked(f".logopen /u {path}", self.timeout)
if not os.path.exists(path):
return
self._log_path = path
self._log_offset = 0
self._log_active = True
# The log opens mid-command, so it starts with its banner and the
# logopen echo. One throwaway marked command drains all of that and
# leaves the offset at a clean boundary - robust to log-flush timing
# in a way that trusting the post-open file size is not.
self._send_marked(".echo", self.timeout)
except Exception:
self._cleanup_log()
def _read_log_segment(self, marker: str) -> Optional[List[str]]:
"""This command's output from the Unicode log, or None if not ready.
The marker appears twice in the log: first in the echoed ``.echo
<marker>`` command, then as that command's output. Everything before the
first is this command's transcript; consuming through the second leaves
the offset at a clean boundary for the next command.
"""
deadline = time.time() + max(2.0, self.timeout / 10)
while True:
try:
with open(self._log_path, "rb") as handle:
handle.seek(self._log_offset)
text = handle.read().decode("utf-16-le", errors="replace")
except OSError:
return None
first = text.find(marker)
second = text.find(marker, first + len(marker)) if first != -1 else -1
if first != -1 and second != -1:
line_start = text.rfind("\n", 0, first) + 1
end_nl = text.find("\n", second)
end = len(text) if end_nl == -1 else end_nl + 1
self._log_offset += len(text[:end].encode("utf-16-le"))
return _extract_log_output(text[:line_start])
if time.time() >= deadline:
return None
time.sleep(0.02)
def _cleanup_log(self) -> None:
self._log_active = False
if not self._log_path:
return
# A detached remote client is force-killed rather than quit, so the OS
# may still be releasing its handle on the log file when we get here; a
# dump/kernel session that quit cleanly releases it at once. Retry
# briefly so the temp file is not leaked in the remote case.
for _ in range(20):
try:
os.remove(self._log_path)
break
except FileNotFoundError:
break
except OSError:
time.sleep(0.05)
self._log_path = None
def _marker_landed(self) -> bool:
"""True if the pending marker arrived just as the deadline expired."""
@@ -710,6 +882,7 @@ class DebuggerSession:
print(f"Error during shutdown: {e}")
finally:
self.process = None
self._cleanup_log()
def _terminate_process(self) -> None:
"""Kill the debugger process. On Windows use a tree kill: cdb.exe/kd.exe
@@ -11,6 +11,7 @@ reading its stdin, so anything written to it is queued until the target stops.
from __future__ import annotations
import os
import queue
import threading
import time
@@ -88,6 +89,10 @@ class _FakeProc:
#: test hang one specific command without also hanging the break-in
#: probe that precedes it.
self.answer_budget: "int | None" = None
#: When a ``.logopen /u <path>`` is seen, the fake mirrors a UTF-16
#: transcript to this file the way cdb/kd do, so the log-content channel
#: can be exercised without a real multibyte debugger.
self._log_path = None
def _feed(self, text: str):
for line in text.splitlines():
@@ -96,7 +101,20 @@ class _FakeProc:
else:
self._handle(line)
def _open_log(self, path: str):
self._log_path = path
with open(path, "wb") as handle:
handle.write(b"\xff\xfe") # UTF-16LE BOM, as cdb writes
self._log_write("Opened log file\r\n")
def _log_write(self, text: str):
with open(self._log_path, "ab") as handle:
handle.write(text.encode("utf-16-le"))
def _handle(self, line: str):
logging = self._log_path is not None and not line.startswith(".logopen")
if logging:
self._log_write(f"0:000> {line}\r\n") # the transcript echoes the command
if line.startswith(".echo "):
marker = line[len(".echo "):]
if self.answer_budget is not None:
@@ -105,6 +123,10 @@ class _FakeProc:
self.answer_budget -= 1
if not self._swallow:
self._out.put(marker)
if logging:
self._log_write(f"{marker}\r\n")
elif line.startswith(".logopen /u "):
self._open_log(line[len(".logopen /u "):].strip())
elif line in ("q", "\x02"):
# quit / detach: the real process exits, ending the reader loop
self._alive = False
@@ -113,6 +135,8 @@ class _FakeProc:
self.running = True
else:
self._out.put(f"OUT:{line}")
if logging:
self._log_write(f"OUT:{line}\r\n")
def target_stops(self, *lines: str):
"""The target halts on its own (bugcheck, breakpoint), draining stdin."""
@@ -158,6 +182,15 @@ def _fast_break_in_probe(monkeypatch):
monkeypatch.setattr(debug_session, "RESUME_CONFIRM_TIMEOUT", 0.2)
@pytest.fixture(autouse=True)
def _single_byte_code_page(monkeypatch):
"""Default the hermetic tests to the single-byte (pipe) path, so they behave
the same whatever the host's real code page is - otherwise a session built on
a multibyte host would auto-open the Unicode log mid-test. The log tests
re-patch this to True where they need it."""
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: False)
@pytest.fixture(autouse=True)
def _ctrl_break_event(monkeypatch):
"""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):
with pytest.raises(DebuggerError) as exc:
session.send_command("bp nt!NtCreateFile; g", timeout=1)
assert "Fatal System Error" in str(exc.value)
def test_output_is_read_from_the_unicode_log_on_a_multibyte_code_page(make_session, monkeypatch):
"""On a multibyte code page the session opens a UTF-16 log and returns each
command's output from it (the fake mirrors that log). The pipe still drives
the markers; only the returned content comes from the log."""
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
session, proc = make_session()
assert session._log_active is True
assert proc._log_path is not None and os.path.exists(proc._log_path)
# Content comes from the log, marker-synced, with the prompt/echo scaffolding
# stripped - the same lines the pipe path would have returned.
assert session.send_command("r rip") == ["OUT:r rip"]
assert session.send_command("du @rsp") == ["OUT:du @rsp"]
logpath = session._log_path
session.shutdown()
assert not os.path.exists(logpath) # the temp log is cleaned up
def test_a_missing_log_falls_back_to_the_pipe_without_raising(make_session, monkeypatch):
"""If the log cannot be read, the command still returns (from the pipe)
rather than failing - the reader must never depend on the log existing."""
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
session, proc = make_session()
assert session._log_active is True
# Drop the log out from under the reader: the next command falls back.
os.remove(session._log_path)
proc._log_path = None
assert session.send_command("lm") == ["OUT:lm"]
def test_the_log_is_left_untouched_on_a_single_byte_code_page(make_session, monkeypatch):
"""The default (single-byte) path never opens a log and returns pipe output
verbatim, so a Western setup is byte-for-byte unchanged."""
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: False)
session, proc = make_session()
assert session._log_active is False
assert proc._log_path is None
assert session.send_command("r rip") == ["OUT:r rip"]
def test_a_remote_client_keeps_the_pipe_even_on_a_multibyte_code_page(monkeypatch):
"""A -remote client's engine runs on the server, so the log would open there,
not here. Such a session must stay on the pipe even on a multibyte page."""
monkeypatch.setattr(debug_session, "_acp_is_multibyte", lambda: True)
class _RemoteLike(DebuggerSession):
is_live_session = True
_engine_is_local = False
proc = _FakeProc()
monkeypatch.setattr(debug_session.subprocess, "Popen", lambda *a, **k: proc)
session = _RemoteLike(debugger_path="fake", launch_args=["fake"], timeout=5, verbose=False)
try:
assert session._log_active is False
assert proc._log_path is None
assert session.send_command("r rip") == ["OUT:r rip"]
finally:
session.shutdown()
+68
View File
@@ -0,0 +1,68 @@
"""Hermetic tests for the Unicode-log content extraction.
The full log transport (opening ``.logopen /u`` and reading complete UTF-16 for
every command on a multibyte code page) is exercised end to end against a real
cdb on the VM noted in the pull request; CI cannot reach that. What is unit-
tested here is the pure slice-to-output-lines logic, which is where the parsing
risk lives.
"""
from __future__ import annotations
from mcp_windbg import debug_session
from mcp_windbg.debug_session import _extract_log_output
def test_drops_the_command_echo_and_keeps_the_output():
segment = "0:000> du @rsp L20\n00000008`7fafe858 \"中文\"\n"
assert _extract_log_output(segment) == ['00000008`7fafe858 "中文"']
def test_a_command_that_prints_nothing_yields_no_lines():
# ew echoes the command and prints nothing; the pipe path returns [] too.
assert _extract_log_output("0:000> ew @rsp 4e2d 0\n") == []
def test_multiple_output_lines_are_kept_in_order():
segment = (
"0:000> dw @rsp L8\n"
"00000008`7fafe858 4e2d 6587 0000 0000 0000 0000 0000 0000\n"
"00000008`7fafe868 0000 0000\n"
)
assert _extract_log_output(segment) == [
"00000008`7fafe858 4e2d 6587 0000 0000 0000 0000 0000 0000",
"00000008`7fafe868 0000 0000",
]
def test_kernel_and_wow64_prompts_are_recognised_as_scaffolding():
segment = "1:001:x86> r eax\neax=00000001\n0: kd> \n"
assert _extract_log_output(segment) == ["eax=00000001"]
def test_local_kernel_prompt_is_recognised_as_scaffolding():
# Local kernel debugging (kd -kl) prompts with "lkd>", not "N: kd>".
segment = "lkd> lm m nt\nfffff803`ec600000 nt\nlkd> \n"
assert _extract_log_output(segment) == ["fffff803`ec600000 nt"]
def test_a_remote_servers_bracketed_prompt_is_recognised_as_scaffolding():
# A user-mode -remote client prompt carries a [server (tcp ...)] banner.
segment = (
"[BOX\\user (tcp [::1]:5005)] 0:000> du @rsp L20\n"
"0000004a`5ccff240 \"中文\"\n"
)
assert _extract_log_output(segment) == ['0000004a`5ccff240 "中文"']
def test_output_lines_are_never_mistaken_for_prompts():
# A real output line does not start with the N:...> prompt shape.
segment = "0:000> lm\nstart end module\n00400000 0041f000 app\n"
assert _extract_log_output(segment) == [
"start end module",
"00400000 0041f000 app",
]
def test_acp_gate_returns_a_bool():
assert isinstance(debug_session._acp_is_multibyte(), bool)