mirror of
https://github.com/svnscha/mcp-windbg.git
synced 2026-10-16 20:32:50 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user