Fix: decode the Reasoning Engine raw body with setEncoding so multi-byte UTF-8 survives chunk boundaries - #472
Open
AmaadMartin wants to merge 1 commit into
Open
Conversation
The raw-body fallback on POST /api/reasoning_engine accumulated the request into a string with `rawBody += chunk`. `req` is an IncomingMessage with no encoding set, so each 'data' event delivers a Buffer and the `+=` decodes every chunk in isolation. A multi-byte UTF-8 sequence straddling a chunk boundary is therefore split into two truncated fragments, each decoding to U+FFFD, and the bytes are gone before JSON.parse runs. Setting the stream encoding installs a StringDecoder, which holds back an incomplete multi-byte sequence until the following chunk completes it, so the body is decoded once over the whole byte stream. Only the raw-body branch is affected; when express.json() accepts the media type the handler takes the already-parsed branch.
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.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
N/A — no public issue is open for this defect.
Problem: The raw-body fallback on
POST /api/reasoning_enginecorrupts any non-ASCII text.dev/src/server/adk_api_server.tsaccumulated the request body as a string:reqis anhttp.IncomingMessagewith no encoding set, so every'data'eventdelivers a raw
BufferandrawBody += chunkimplicitly callsBuffer.prototype.toString()on each chunk in isolation. A multi-byte UTF-8sequence that straddles a chunk boundary is therefore decoded as two truncated
fragments — the trailing partial bytes of chunk N and the leading partial bytes
of chunk N+1 each decode to
U+FFFDREPLACEMENT CHARACTER. The bytes are gonebefore
JSON.parseever runs.Two consequences, in increasing order of visibility:
U+FFFDis a legal character inside aJSON string, so
JSON.parsesucceeds and the agent is invoked with mojibake innewMessage.Runner.runAsyncthen persists the corrupted text into thesession history, so the damage outlives the request.
JSON.parsethrows, the handler logs
Failed to parse raw body as JSONand falls through toexecuteQuery({}), which answers400 appName is required in input— amisleading error for a well-formed request.
Only the raw-body branch is affected. When
express.json()accepts the media typeit decodes the body correctly and the handler takes the
isParsedbranch. Theraw-body branch runs when
express.json()declines — notably for the doubledContent-Type: application/json,application/jsonheader that Agent Engine sends,which the
content-typeparser rejects as an invalid media type, sotype-isreports no match and body-parser skips parsing.
Solution: Set the stream encoding once, before reading.
} else { + // Decode through the stream's StringDecoder: a multi-byte UTF-8 + // sequence straddling two chunks would otherwise become replacement + // characters on both sides of the boundary. + req.setEncoding('utf-8'); let rawBody = ''; - req.on('data', (chunk) => { + req.on('data', (chunk: string) => { rawBody += chunk; });Readable.setEncoding(enc)installs aStringDecoder, which holds back anincomplete multi-byte sequence until the following chunk completes it. The body is
therefore decoded once over the complete byte stream, so the string handed to
JSON.parseequalsBuffer.concat(allChunks).toString('utf-8')regardless of howthe bytes were framed. The chunk parameter is now genuinely a
string, so theannotation is a type correction rather than a cast — no suppression is involved.
Why
setEncodingand not aBuffer.concataccumulator: both fix the defect.setEncodingis the remedy already used for the identical defect class incore/src/code_executors/unsafe_local_code_executor.ts(stdout += data.toString()),so both sites now read the same way. Exactly one of the two mechanisms is shipped —
adding a
Buffer.concataccumulator on top would be redundant.Deliberately out of scope (each would widen a one-defect diff):
// eslint-disable-next-line @typescript-eslint/no-explicit-anycomments onlet body: any/executeQuery(body: any)are left in place. Replacinganywith arequest interface makes
newMessagecorrectlyContent | undefined, which understrictis not assignable toexecuteAgentRun'snewMessage: Content; relaxingthat parameter pushes the same error onto
Runner.runAsyncincore/. Typing ithonestly needs a
coresignature change, which does not belong in a UTF-8 fix.(PR Fix: narrow the reasoning engine request body instead of typing it any (Part 3/3) #232 on this fork is doing exactly that work.)
null) makesexecuteQuerythrowTypeError: Cannot read properties of null (reading 'input')inside the un-awaitedreq.on('end')callback, so the request is never answered. Reproduced, but it is aseparate defect and is not touched here.
Behaviour change: none beyond the fix. No public API, type, export, or wire-format
change; no new dependency and no
package.json/lockfile change. Status codes, responseshapes, the
isParsedbranch, and the log lines are untouched — the existinglogger.infoof the raw body simply logs correct text instead of mojibake. Callerssending pure ASCII see byte-identical behaviour; the change strictly widens the set of
inputs handled correctly.
Collision check (run before implementing, over all 300 open PRs on the fork):
gh pr diff --name-onlyacross every open PR found nine touchingdev/src/server/adk_api_server.ts— #452, #432, #344, #332, #300, #278, #252, #232, #199.None of them lands this fix. Two are worth flagging:
new
readReasoningEngineBody()helper and carries the defect over verbatim(
req.on('data', (chunk: Buffer) => { rawBody += chunk; })). It textually overlaps thelines changed here, so whichever merges second will conflict — that conflict must be
resolved by keeping
setEncodinginside the extracted helper, not by taking theincoming side. The test added here fails loudly if it is dropped.
it is the tracked owner of the
anycleanup listed as out of scope above.This PR is branched from
mainrather than stacked on either, because it depends onneither, both are refactors of the same handler that would arbitrate the base, and a
stacked base means the
pull_request: branches: [main]workflow never triggers. Thediff is 6 lines of source and rebases onto either refactor trivially.
Testing Plan
Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.
One test added to
dev/test/server/adk_api_server_test.ts, in the existingdescribe('Reasoning Engine', …)block:'should not corrupt a multi-byte raw body split across chunk boundaries'.It POSTs a body whose text is
'世界你好'.repeat(16)(all 3-byte characters) overnode:httpwith noContent-Length, writing 7 bytes perwrite(). Node frames eachwrite()as its own chunked-transfer frame and the receiving parser emits one'data'event per frame, so the handler deterministically observes those byte boundaries; 7 is not
a multiple of 3, so boundaries land inside the multi-byte sequences.
fetchisdeliberately not used — it sends the body as a single frame, so the boundary under test
never occurs and such a test would pass with or without the fix. The framing is
per-
write(), not per-TCP-packet, so there are no sleeps and no timing assumptions.The assertion is on the persisted session event, not the response body: the shared
TestAgentechoes fixed text and never reflects the input, whereasRunner.runAsyncwrites the received
newMessageinto the session verbatim.Proof the test can fail. Two independent mutations, each run against the new test:
req.setEncoding('utf-8'), i.e. the pre-fix code). Written andrun in this state first, before the source was touched:
indexOfis0— the persisted text starts withU+FFFD.setEncoding('latin1')), to show the test pins the encoding and notmerely the presence of the call.
latin1produces noU+FFFD, so assertion 1 passesand the equality assertion catches it:
Coverage of the changed lines — measured with
@vitest/coverage-v8scoped todev/src/server/adk_api_server.ts, per-statement hit counts:req.setEncoding('utf-8');let rawBody = '';req.on('data', (chunk: string) => {rawBody += chunk;100% line coverage on every changed line (2 = the pre-existing single-frame raw-body test
plus the new one; 47 = 1 single frame + 46 chunked frames). The change adds no branches.
Other gates, all on the pushed commit:
npm run ts:checkhas a large pre-existing baseline elsewhere in the repo (mostlycore/test, the subject of separate PRs); neither file touched here contributes to it, andit is not currently a CI gate.
Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.
npm install && npm run buildat the repo root.AdkApiServerwith any agent registered astestApp.POST /api/reasoning_enginewith headerContent-Type: application/json,application/json(soexpress.json()declines and theraw-body branch runs) and no
Content-Length, writing the UTF-8 bytes of{ "input": { "appName": "testApp", "userId": "testUser", "sessionId": "utf8SessionId", "newMessage": {"parts": [{"text": "世界你好世界你好…"}], "role": "user"} } }request.write(bytes.subarray(offset, offset + 7))).sessionService.getSession({appName:'testApp', userId:'testUser', sessionId:'utf8SessionId'})and inspect
events.find(e => e.author === 'user')?.content?.parts?.[0].text.Before:
"��界你好世���你��世界你好…"—indexOf('\uFFFD')is0.After: the exact string that was sent —
indexOf('\uFFFD')is-1.No integration test was added: the behaviour is fully covered at the HTTP boundary by the
unit test above, which drives a real
AdkApiServerover a real socket with no mocks.Checklist
[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.