Repository navigation
fix(pi): decode MCP stdout incrementally so multi-byte text survives chunk boundaries - #1249
Open
aniruddhaadak80 wants to merge 1 commit into
Open
aniruddhaadak80 wants to merge 1 commit into
aniruddhaadak80 wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #000
What / Why / How
src/adapters/pi/mcp-bridge.ts:531decoded every stdout chunk from the child MCP server in isolation: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 intothis.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, thecatch { continue }never fires, and the mangled text goes straight tohandler.resolve(msg.result). Anyctx_execute/ctx_search/ctx_fetch_and_indexresult 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 existingthis.buffer = ""inrespawn().src/executor.ts:496already gets this right withBuffer.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 caseStringDecoderexists 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
Bufferat a chosen byte offset and produce lone surrogates, whereas this corrupts on the stream-chunk boundary during decode:src/cli.ts,src/server.ts,src/truncate.ts— surrogate-safe truncationsrc/truncate.tsbyte budget + surrogate boundaryRepo-wide searches for
decoderandstringdecoderreturn zero prior PRs or issues; there was noStringDecoderanywhere in the tree.Affected platforms
src/adapters/pi/mcp-bridge.tsis 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 driveonDatadirectly through an inert stdin stub, so no child process is needed:buffercontains no U+FFFD after the partial chunk, then that the withheld bytes arrive intact.Before (no fix) — 3 failed
The failure is exactly the predicted corruption: three U+FFFD where
日本語should be.After (with the fix) — 3 passed
Whole-file before/after (same machine, same session, same commit)
next)The same 4 tests fail in both runs, all
Test timed outin 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 succeedslistTools() 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 orderThese 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 typecheckpasses clean.Full-suite caveat
I could not get a complete
npm testrun 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
npm testpasses — see caveat above; pre-existing failures are environmental (Windows spawn timeouts), unchanged by this PRnpm run typecheckpassesnextbranch (unless hotfix)Cross-platform notes
Buffer, so it is platform-independent by construction.StringDecoderis imported fromnode:string_decoder, matching the existingnode:-prefixed import convention in the file.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):It has the same decode-boundary flaw, but it only feeds
this.diag(..., "debug")— the~/.omp/logsdiagnostic stream — and never reacheshandler.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.