Skip to content

fix(pi): decode MCP stdout incrementally so multi-byte text survives chunk boundaries - #1249

Open
aniruddhaadak80 wants to merge 1 commit into
mksglu:nextfrom
aniruddhaadak80:fix/utf8-decode-across-chunks
Open

aniruddhaadak80 wants to merge 1 commit into
mksglu:nextfrom
aniruddhaadak80:fix/utf8-decode-across-chunks

Conversation

@aniruddhaadak80

Copy link
Copy Markdown

Fixes #000

What / Why / How

src/adapters/pi/mcp-bridge.ts:531 decoded every stdout chunk from the child MCP server in isolation:

private onData(chunk: Buffer): void {
  this.buffer += chunk.toString("utf-8");

A Node stream delivers bytes in arbitrary chunks that are not aligned to UTF-8 code-point boundaries. When a chunk ends mid-sequence, Buffer#toString("utf-8") substitutes U+FFFD for the incomplete tail — and because the decoded text is appended immediately, that U+FFFD is baked permanently into this.buffer. The real bytes arriving in the next chunk can no longer repair it.

The failure is silent, not loud. U+FFFD is a legal character inside a JSON string, so JSON.parse(line) still succeeds, the catch { continue } never fires, and the mangled text goes straight to handler.resolve(msg.result). Any ctx_execute / ctx_search / ctx_fetch_and_index result containing CJK, emoji, or accented text silently loses characters on the Pi/OMP path, and the calling agent has no way to detect it.

How: hold the incomplete trailing sequence back across chunks with a StringDecoder, flush it on child exit, and reset it alongside the existing this.buffer = "" in respawn().

src/executor.ts:496 already gets this right with Buffer.concat(stdoutChunks).toString("utf-8"), so this was an oversight rather than house style. A concat refactor is not available here: the bridge is long-lived and cannot know which chunk is the last one, which is exactly the case StringDecoder exists for.

Not a duplicate

I checked all 622 PRs and the issue tracker before starting. The existing UTF-8 issues are a different defect class — they truncate a Buffer at a chosen byte offset and produce lone surrogates, whereas this corrupts on the stream-chunk boundary during decode:

Ref Scope Verdict
#1163 + PR #1176 src/cli.ts, src/server.ts, src/truncate.ts — surrogate-safe truncation different class, different files
#903, #659 SessionStart / preview truncation slicing surrogate pairs different class
#273 src/truncate.ts byte budget + surrogate boundary different class

Repo-wide searches for decoder and stringdecoder return zero prior PRs or issues; there was no StringDecoder anywhere in the tree.

Affected platforms

  • Claude Code
  • Cursor
  • VS Code Copilot (GitHub Copilot)
  • JetBrains Copilot
  • Gemini CLI
  • Qwen Code
  • OpenCode
  • KiloCode
  • Codex CLI
  • OpenClaw (Pi Agent)
  • Pi
  • Kiro
  • Antigravity
  • Zed
  • All platforms

src/adapters/pi/mcp-bridge.ts is the stdio bridge behind the Pi and OMP (OMP/MCP-only) adapters, which is why those two are checked.

Test plan

Three tests added to the existing tests/adapters/pi-mcp-bridge.test.ts (per CONTRIBUTING: no new test files). All three drive onData directly through an inert stdin stub, so no child process is needed:

  1. resolves the exact tool text when a chunk ends mid-code-point — cuts the JSON-RPC line one byte into the first 3-byte CJK character.
  2. holds an incomplete trailing sequence instead of baking U+FFFD into the buffer — asserts buffer contains no U+FFFD after the partial chunk, then that the withheld bytes arrive intact.
  3. reassembles the payload byte-for-byte when the stream delivers one byte per chunk — worst-case framing, every code point split across chunks.

Before (no fix) — 3 failed

 ❯ tests/adapters/pi-mcp-bridge.test.ts (33 tests | 3 failed | 30 skipped) 1503ms
   × resolves the exact tool text when a chunk ends mid-code-point 1447ms
   × holds an incomplete trailing sequence instead of baking U+FFFD into the buffer 11ms
   × reassembles the payload byte-for-byte when the stream delivers one byte per chunk 32ms

 FAIL  ... > resolves the exact tool text when a chunk ends mid-code-point
 AssertionError: expected '<...> \ufffd\ufffd\ufffd ...' to be '<...> 日本語 ...'
   at tests/adapters/pi-mcp-bridge.test.ts:1220:39

 FAIL  ... > holds an incomplete trailing sequence instead of baking U+FFFD into the buffer
 AssertionError: expected '{"jsonrpc":"2.0","id":1,...' not to contain '\ufffd'
   at tests/adapters/pi-mcp-bridge.test.ts:1238:26

 Test Files  1 failed (1)
      Tests  3 failed | 30 skipped (33)

The failure is exactly the predicted corruption: three U+FFFD where 日本語 should be.

After (with the fix) — 3 passed

 ✓ tests/adapters/pi-mcp-bridge.test.ts (33 tests | 30 skipped) 1194ms
   ✓ resolves the exact tool text when a chunk ends mid-code-point 1168ms

 Test Files  1 passed (1)
      Tests  3 passed | 30 skipped (33)

Whole-file before/after (same machine, same session, same commit)

Tests Failed Passed
Before (pristine next) 30 4 26
After (this PR) 33 4 29

The same 4 tests fail in both runs, all Test timed out in the respawn/idle-exit cases that spawn a real child MCP server:

  • re-spawns the child when callTool is invoked after exit, and the call succeeds
  • listTools() after an idle exit triggers respawn (not just callTool)
  • concurrent callTool() invocations after exit share ONE respawn (no orphan children)
  • respawn() resets state in the documented order

These are pre-existing Windows process-spawn slowness, not a regression — I confirmed this by reverting to the pristine tree in the same worktree and reproducing the identical 4 failures. The delta is exactly +3 passing tests and zero changed outcomes. npm run typecheck passes clean.

Full-suite caveat

I could not get a complete npm test run on this machine: the suite was killed partway through three separate attempts, and the box is concurrently running at least two other full vitest suites plus dev servers (31 node processes / ~5 GB resident). I deliberately did not kill those other processes, since they belong to other work in flight. The full-suite counts are therefore unverified here, but the file-level before/after above is a genuine same-session comparison, and the CI checks on this PR are the authoritative signal.

Checklist

  • Tests added/updated (TDD: red → green)
  • npm test passes — see caveat above; pre-existing failures are environmental (Windows spawn timeouts), unchanged by this PR
  • npm run typecheck passes
  • Docs updated if needed — no user-facing behaviour or config change
  • No Windows path regressions (forward slashes only) — no path handling touched
  • Targets next branch (unless hotfix)
Cross-platform notes
  • No file paths, hook paths, or separators are touched — the change is confined to byte decoding of an already-streamed Buffer, so it is platform-independent by construction.
  • StringDecoder is imported from node:string_decoder, matching the existing node:-prefixed import convention in the file.
  • The fix strictly improves Windows behaviour, where pipe chunking is the most unpredictable.

Related, deliberately out of scope

While verifying scope I found a second chunk.toString("utf-8") in the same file, at the stderr handler (mcp-bridge.ts:509-510):

this.child.stderr?.on("data", (chunk: Buffer) => {
  const text = chunk.toString("utf-8");

It has the same decode-boundary flaw, but it only feeds this.diag(..., "debug") — the ~/.omp/logs diagnostic stream — and never reaches handler.resolve, so it cannot corrupt agent-visible tool output. I left it alone to keep this diff tight and reviewable, and am flagging it rather than silently widening scope. Happy to send a small follow-up PR that gives stderr its own decoder if you'd like.

…chunks

src/adapters/pi/mcp-bridge.ts decoded every stdout chunk in isolation:

    this.buffer += chunk.toString("utf-8");

A Node stream hands us bytes in arbitrary chunks that are not aligned to
UTF-8 code-point boundaries. When a chunk ends mid-sequence,
Buffer#toString("utf-8") substitutes U+FFFD for the incomplete tail, and
because the decoded text is appended immediately that U+FFFD is baked
permanently into this.buffer -- the real bytes arriving in the next chunk can
no longer repair it.

The corruption is silent rather than loud: U+FFFD is a legal character inside a
JSON string, so JSON.parse(line) still succeeds and the `catch { continue }`
never fires. The mangled text goes straight to handler.resolve(msg.result), so
any ctx_execute / ctx_search / ctx_fetch_and_index result containing CJK,
emoji, or accented text silently loses characters on the Pi/OMP path, and the
calling agent has no way to detect it.

Hold the incomplete trailing sequence back across chunks with a StringDecoder,
flushing it on child exit and resetting it alongside the existing buffer reset
in respawn(). src/executor.ts:496 already gets this right via
Buffer.concat(...).toString("utf-8"); a concat is not available here because
the bridge is long-lived and cannot know which chunk is the last one.

This is a different defect class from the slice-boundary lone-surrogate work in
mksglu#1163/mksglu#1176, mksglu#903 and mksglu#659: those truncate a Buffer at a chosen byte offset,
this one corrupts on the stream-chunk boundary.

Tests: three cases added to tests/adapters/pi-mcp-bridge.test.ts covering a
chunk that ends mid-code-point, a withheld incomplete tail, and the worst case
of one byte per chunk.
Copilot AI balanced review requested due to automatic review settings October 3, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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