Skip to content

fix(util): decode stdin as UTF-8, not the ANSI code page - #19

Merged
HarryHeYu merged 2 commits into
HarryHeYu:mainfrom
SunnyRad:fix/utf8-stdin
Nov 11, 2026
Merged

HarryHeYu merged 2 commits into
HarryHeYu:mainfrom
SunnyRad:fix/utf8-stdin

Conversation

@SunnyRad

@SunnyRad SunnyRad commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

两个缺陷,都在 VS Code 客户端驱动的 stdio bridge 上

一、stdin 按 ANSI 代码页解码

1.3.1 只修了 stdout/stderr 的 UTF-8。中文 Windows 下 stdin 仍是 cp936,调用方写的 UTF-8 请求行不报错、被静默按 GBK 解码:

{"query": "会话记录"}  ->  "浼氳瘽璁板綍"  ->  count: 0

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 serve
  • vscode-extension/extension.js:44 — spawn(python, ["-m", "voyager.api", "serve"])

但两个 parser 都不认。我取回引入该功能的提交 0d5d03c3 核对过,api.py 的 main() 与 cli.py:1268 当时也只有一个裸 api——从第一天起就是坏的:

$ python -m voyager.api serve
voyager-api: error: unrecognized arguments: serve     # exit 2

子进程在读到第一个请求之前就退出,所以已发布的 VS Code 侧边栏从未可能工作过。两个 parser 现在都把 serve 接受为 no-op 动词;未知动词仍是 usage error(exit 2),没有放成 catch-all。

为什么断言的是 count 而不是回显

这对字符的两种解码恰好同字节:

gbk("浼氳瘽") == utf8("会话") == e4bc9ae8af9d

回显的 query 看着是对的,匹配早就坏了。任何断言回显的测试都会在这个 bug 下通过。

验证

顺带纠正我早前在本 PR 里的说法:python -m voyager.api 那条路我原先说「stdout 变 U+FFFD」,其实是我用错入口测的——它绕过 cli.main(),所以不调 force_utf8_stdio()。它坏的是 stdin,不是 stdout。

四种调用形式 × GBK(修复后):

调用 exit count
voyager api 0 1
python -m voyager.api 0 1
python -m voyager.api serve 0 1
python -m voyager.api bogus 2 —

变异验证(证明新测试不是摆设):

  • 从 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 的 tests workflow(run 42845178098)已由维护者批准并于 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 run 42845178098、并重写 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 的代码正确性。

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 HarryHeYu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

独立 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 而不是回显来钉住它。

@HarryHeYu
HarryHeYu merged commit 15723c1 into HarryHeYu:main Nov 11, 2026
10 checks passed
@HarryHeYu

Copy link
Copy Markdown
Owner

已 Squash Merge(15723c1),作者归属保留为 SunnyRad。

特别感谢你发现并修好了这两件事:Windows 中文环境下的 stdio 通信——stdin 仍按 GBK 解码,UTF-8 中文请求被静默改写、0 results 和「确实没有」长得一样;以及 VS Code 扩展的启动路径——文档和 extension.js 都在用 voyager api serve,但两个 parser 从一开始就不认这个动词,子进程在读第一个请求前就退出。我用真实扩展代码 + 真实子进程跑了端到端,确认 main 上是超时、这个 PR 上能完成一次完整请求并返回正确的中文结果。

也谢谢你坚持用 count 而不是 query 回显来断言——那对字符的 GBK/UTF-8 同字节陷阱,确实能让只看回显的测试全部假绿。以及主动把 test_scan_streaming.py 的硬编码路径单独开 issue、不扩大本 PR 范围,这个边界感很有帮助。

非阻塞的后续项(非法 UTF-8 目前是 U+FFFD + count: 0,还不是明确报错)我记在 #21;硬编码路径由我按 #20 单独修。不急着做,有时间再看。

HarryHeYu added a commit that referenced this pull request Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants