Repository navigation
fix(util): decode stdin as UTF-8, not the ANSI code page - #19
Conversation
force_utf8_stdio() reconfigured stdout and stderr but left stdin at cp936,
so the `api` op -- which reads JSON-lines requests from stdin -- decoded its
client's UTF-8 bytes through GBK. On a Chinese Windows install
`{"query": "会话记录"}` arrived as `"浼氳瘽璁板綍"` and the search returned
`count: 0`. A miss and a corrupted query are the same answer to the caller,
which is why this survived: the index was never wrong.
The bug also hides from a casual echo check. For this specific pair the two
decodings share bytes -- `gbk("浼氳瘽") == utf8("会话") == e4bc9ae8af9d` --
so the echoed query can render correctly while the match is already corrupted.
The regression test therefore asserts `count`, not the echo.
This mirrors the `_force_utf8` helper the Claude Code SessionStart hook
already carries (integrations/claude_session_start.py), which includes stdin
for exactly this reason.
Verified on Windows/cp936 against the plugin's real path
(`python -m voyager.cli api`):
before: clean -> count 0, PYTHONIOENCODING=gbk -> count 0, utf-8 -> count 1
after: clean -> count 1, PYTHONIOENCODING=gbk -> count 1, utf-8 -> count 1
Mutation-checked: removing `sys.stdin` from the loop turns both new tests
red and leaves the other five green.
Two defects, both in the stdio bridge the VS Code client drives.
1. stdin was decoded in the ANSI code page. 1.3.1 forced UTF-8 on
stdout/stderr only, so on a Chinese Windows install the UTF-8 request
lines a client writes were silently decoded through GBK:
{"query": "????"} searched for "??????" and answered count: 0.
"No such session" and "your query was corrupted in transit" are the
same answer to the caller, which is why it survived.
The setup now lives in api.serve() -- the one place that reads
stdin -- instead of only in cli.main(), so �oyager api,
python -m voyager.api and python -m voyager.api serve are all
covered. force_utf8_stdio() is idempotent, so the double call is free.
2. serve was documented but never parsed. docs/API.md, the CHANGELOG
and vscode-extension/extension.js (which spawns
["-m","voyager.api","serve"]) all use the verb, but neither parser
accepted it: the child exited 2 on "unrecognized arguments: serve"
before reading a single request, so the shipped sidebar could never
have worked. Both parsers now accept serve as a no-op verb; an
unknown verb is still a usage error.
Tests assert count, never the echoed query: for this pair a GBK echo of
the UTF-8 bytes renders as plausible CJK
(gbk("???") == utf8("??") == e4bc9ae8af9d), so an assertion on the
echo can pass while the match is already corrupted.
Mutation-verified: dropping sys.stdin from force_utf8_stdio fails exactly
the 4 stdin-reading paths and leaves the other 6 green; removing the
serve verb fails exactly test_documented_serve_verb_is_accepted.
Regression: 1372 passed, 8 skipped, 2 failed -- both failures are
PackageNotFoundError from voyager not being pip-installed here, and both
reproduce identically on pristine main.
HarryHeYu
left a comment
There was a problem hiding this comment.
独立 Review 通过。CI 已批准并跑完 10/10 success(fork 首次贡献的 action_required 是审批门,不是失败)。
代码 Review(5 个文件)
force_utf8_stdio()加入sys.stdin后,我逐个核对了调用点:cli.main()(所有子命令)、mcp_server.main()、以及新增的api.serve()。包内只有api.py:546读sys.stdin;integrations/*_session_start.py各自带一份等价的_force_utf8(),未受影响。实测连续调用三次仍为utf-8/replace,随后readline()正确解出中文;sys.stdin is None不抛异常。幂等性与「放在真正读 stdin 的地方」这两点成立。serve作 no-op 动词、choices=["serve"]、help=SUPPRESS:未知动词仍 exit 2,serve extra仍 exit 2,没有变成 catch-all;--db的前置/后置四种摆法都通过。- 5 个文件按原始字节核对:均为合法 UTF-8、U+FFFD 计数 0、无控制字符。正文里提到的 commit message 编码退化确实只在提交信息里,没有污染代码。
BRIDGE_SCHEMA_VERSION = 1未变,integration_info的 9 个 op 未变;voyager --version / list / search / repo / integration-info --json在 GBK 环境下行为与 main 一致(repo --json两棵树同为 exit 2 要求位置参数)。
独立验证(不依赖作者的自测)
字节级 before/after,断言 count 与命中 id,不看 query 回显:
| 入口 | main 02e0760 |
87cd92a |
|---|---|---|
voyager api |
count=0,回显 浼氳瘽璁板綍 |
count=1 |
python -m voyager.api |
count=0,但回显看起来是 会话记录 |
count=1 |
python -m voyager.api serve |
exit 2 | count=1 |
voyager api serve |
exit 2 | count=1 |
第二行正是你指出的陷阱:GBK stdin + GBK stdout 把乱码又编回原来的 UTF-8 字节,回显看着正确、匹配早已坏了。用 count 断言是对的。
VS Code 扩展真端到端:加载真实 vscode-extension/extension.js(仅 stub vscode 模块)、拦截其 spawn、调用注册的 voyager.overview 命令,走完整条 getBridge → 子进程 → stdout 解析 → 输出通道。断言是 CJK repo 过滤后的 recent sessions: 1 和中文标题,不是 echo。
| 树 | 子进程 | 扩展请求 | 结果 |
|---|---|---|---|
| main | exit 2 unrecognized arguments: serve |
超时,从未得到应答 | FAIL |
87cd92a |
存活 | DONE | recent sessions: 1、中文会话标题、无乱码 |
并做了变异验证:只把 sys.stdin 从 force_utf8_stdio 去掉,扩展子进程立刻 UnicodeDecodeError 死在 CJK 请求上——说明这个 E2E 确实钉住了修复,不是摆设。这一步是扩展级验证,不是 parser 测试冒充。
入口矩阵:8 种调用形式(含 4 种 --db 位置)全部 exit 0 + 合法 JSON;未知动词 3 例全 exit 2;坏 JSON 返回 bad json 且同一长连接下一行继续服务;单子进程连发 3 个请求、CJK search count=1(GBK stdin 下);python -m voyager.mcp_server 的 initialize 握手在两棵树上返回同样的合法 JSON-RPC,exit 0。
全量对照(同一环境,均带 dist 元数据):main 1373 passed / 6 skipped / 31 subtests / 0 failed;本 PR 1379 passed / 6 skipped / 31 subtests / 0 failed——正好 +6 条新回归,无新增失败。你报告的两处 test_scan_streaming.py 硬编码 E:/code/voyager 我确认在 main 上原本就有、本 PR 未改该文件(逐字节相同),属环境问题(本机有 E: 盘 + user-site 已装,所以这里通过);已由 issue #20 单独记录,我会作为维护者单独修,不塞进你的 PR。两个 PackageNotFoundError 版本用例同样是缺 dist 元数据的环境问题,补上后两棵树都 2 passed。
一个非阻塞的后续项
errors="replace" 让非法 UTF-8 从「main 上 traceback + exit 1」变成「合法 JSON 响应 + U+FFFD + count=0」,稳定性和可解析性是改善,但严格说还没达到我在 #21 里写的「非法 UTF-8 应给出明确错误而非静默空结果」。我会在 issue 里记为后续硬化项,不阻塞本次合并——本次修的是合法 UTF-8 被错误解码,范围边界清楚。
按约定 Squash Merge,保留 SunnyRad 的作者归属。感谢把 stdin 和 serve 这两个「看起来能跑」的问题挖到字节级,尤其是用 count 而不是回显来钉住它。
|
已 Squash Merge( 特别感谢你发现并修好了这两件事:Windows 中文环境下的 stdio 通信——stdin 仍按 GBK 解码,UTF-8 中文请求被静默改写、 也谢谢你坚持用 非阻塞的后续项(非法 UTF-8 目前是 U+FFFD + |
两个缺陷,都在 VS Code 客户端驱动的 stdio bridge 上
一、stdin 按 ANSI 代码页解码
1.3.1 只修了 stdout/stderr 的 UTF-8。中文 Windows 下 stdin 仍是 cp936,调用方写的 UTF-8 请求行不报错、被静默按 GBK 解码:
0 results和「确实没有」长得一模一样,所以活了下来。修复点下移到
api.serve()(真正读 stdin 的唯一位置),而不是只放cli.main()。这样voyager api、python -m voyager.api、python -m voyager.api serve三条入口全覆盖;force_utf8_stdio()幂等,重复调用无副作用。二、
serve这个动词文档里写了,实现里从来没有三处都说要用
serve:docs/API.md:28—voyager api serve [--db PATH]CHANGELOG.md—voyager api servevscode-extension/extension.js:44—spawn(python, ["-m", "voyager.api", "serve"])但两个 parser 都不认。我取回引入该功能的提交
0d5d03c3核对过,api.py的main()与cli.py:1268当时也只有一个裸api——从第一天起就是坏的:子进程在读到第一个请求之前就退出,所以已发布的 VS Code 侧边栏从未可能工作过。两个 parser 现在都把
serve接受为 no-op 动词;未知动词仍是 usage error(exit 2),没有放成 catch-all。为什么断言的是
count而不是回显这对字符的两种解码恰好同字节:
回显的
query看着是对的,匹配早就坏了。任何断言回显的测试都会在这个 bug 下通过。验证
顺带纠正我早前在本 PR 里的说法:
python -m voyager.api那条路我原先说「stdout 变 U+FFFD」,其实是我用错入口测的——它绕过cli.main(),所以不调force_utf8_stdio()。它坏的是 stdin,不是 stdout。四种调用形式 × GBK(修复后):
voyager apipython -m voyager.apipython -m voyager.api servepython -m voyager.api bogus变异验证(证明新测试不是摆设):
force_utf8_stdio去掉sys.stdin→ 恰好 4 条读 stdin 的路径变红(test_force_utf8_covers_stdin、test_api_op_reads_utf8_stdin_under_gbk、test_documented_serve_verb_is_accepted、test_bare_api_module_still_serves),其余 6 条仍绿serve动词 → 恰好test_documented_serve_verb_is_accepted变红,其余 9 条仍绿回归:我在当前 HEAD
87cd92a上重跑了一次全量,正文早前那行1372 passed, 8 skipped, 2 failed是较早一轮的数字,一并更正。本机(中文 Windows / cp936,包未以发行版安装,且没有
E:盘):1376 passed, 7 skipped, 2 failed(另 31 subtests passed)。这 2 个失败在我这边不是PackageNotFoundError(那是缺 dist 元数据时的另一种失败),而是tests/test_scan_streaming.py的两个并发用例:test_two_concurrent_streaming_scans_converge(:569)test_concurrent_creation_of_a_fresh_index_does_not_fail(:637)子进程脚本里硬编码了
sys.path.insert(0, "E:/code/voyager"),该路径在非作者机器上不存在,于是ModuleNotFoundError: No module named 'voyager'。这两行在main上原本就有,本 PR 的改动文件不含test_scan_streaming.py。它不是Store()的并发缺陷:把注入路径换成真实仓库路径(或给子进程传PYTHONPATH=<repo>)后,这 2 个用例 2 passed,其中并发建库用例自身连跑 10 轮、0 失败。另有两个版本用例(
test_version_reports_the_installed_distribution/test_dunder_version_agrees_with_the_distribution)取决于 dist 元数据:未生成voyager.egg-info的检出上报PackageNotFoundError,生成后即通过。所以「失败几条」是环境相关的——不装发行版、又没有E:盘时是 4 failed(2 版本 + 2 硬编码路径),PYTHONPATH=<repo>下这 4 条 4 passed。这些都在未改动的 pristine main 上逐一复现,与本 PR 的代码逻辑无关。CI:本 PR 的
testsworkflow(run42845178098)已由维护者批准并于 2026-11-11T06:04:45Z 启动,status=completed/conclusion=success,10/10 jobs 全绿(py3.10–3.13 × ubuntu-latest / windows-latest 共 8 组,加上 2 组core install (no extras);每组的Run test suite与Release metadata consistency均为 success,head_sha仍为87cd92a)。这是 PR 分支上第一份独立证据——上面的本地数字不再是孤证。注意:首次外部贡献者的 workflow 每次推送都需重新批准,因此 CI 转绿后不再推任何代码,以免作废这份 10/10 结果。附:关于 commit message 的编码退化(仅说明,不改动)
87cd92a的 commit message 本身也是一次编码损坏的样本:标题里的api被\a(BEL, 0x07) 吃掉a变成\x07pi,正文里的voyager被\v(VT, 0x0b) 吃掉v变成\x0boyager,中文字符全部退化成?。这与本 PR 要修的「字节在传输中被静默改写」是同一类问题——提交成了它自己 bug 的一个样本。这些
?只发生在提交信息里:5 个改动文件我按 base64 原始字节逐一核对过,U+FFFD计数为 0、em dash 正常、CJK 完整,代码与测试文件没有数据丢失,均为正常 UTF-8。修正 commit message 需要
amend+ force-push,会改写 HEAD SHA、作废本正文里的87cd92a引用与 CI run42845178098、并重写 fork PR 的历史。commit message 不参与构建与 CI,纯属呈现问题,因此本 PR 不做 force-push,保留现状如实说明;若维护者希望一并修正,可在批准 CI 时处理,我再同步刷新正文里的 SHA 引用。处置结论(2026-11-11 定稿):CI 现已 10/10 全绿,若此时
amend+ force-push 修 commit message,会连带作废这份刚批准的 CI 结果、要求维护者重新批准,代价明显大于收益。因此本 PR 确定不修 commit message,不再作为待裁定项;它不参与构建/CI,代码与测试文件均为正常 UTF-8(上面已按字节核对),仅作为「提交恰好成了自己所修 bug 的一个样本」如实留档。若维护者在合并后仍希望整理历史,可单独处理,不影响本 PR 的代码正确性。