SDK, Runtime: Recover JSON-RPC frames containing unpaired UTF-16 surrogates - #2283
Open
Chuxel wants to merge 1 commit into
Open
SDK, Runtime: Recover JSON-RPC frames containing unpaired UTF-16 surrogates#2283Chuxel wants to merge 1 commit into
Chuxel wants to merge 1 commit into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens Rust JSON-RPC decoding against unpaired UTF-16 surrogates without disrupting valid frames or masking other parse failures.
Changes:
- Repairs lone surrogate escapes to
U+FFFDafter strict decoding fails. - Preserves fatal handling for unrecoverable frames.
- Adds integration coverage for recovery, connection continuity, valid pairs, and escaped backslashes.
Show a summary per file
| File | Description |
|---|---|
rust/src/jsonrpc.rs |
Adds targeted surrogate repair and decode retry. |
rust/tests/jsonrpc_test.rs |
Verifies repair behavior and transport lifecycle. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Balanced
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (1)
rust/src/jsonrpc.rs:224
- The low-surrogate branch is not covered: every malformed test uses the high surrogate
\ud83d. Because the function promises to repair all unpaired surrogates and standalone low halves take a distinct path that never entersis_pair, add a case for\udc00–\udfffthat verifies replacement and continued connection usability.
if (0xD800..0xE000).contains(&unit) {
let output = repaired.get_or_insert_with(|| body.to_vec());
output[index..index + 6].copy_from_slice(br"\ufffd");
}
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
devm33
approved these changes
Aug 7, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 7, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 7, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 7, 2026
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.
Why
This PR fixes a problem that appeared in the github-app but appears to be a broader fix in the SDK.
Fixes github/app#678
Fixes github/app#1055
...and likely others.
The symptom is that the model picker in the App is permanently empty for some users, along with the quota counter and the agents/skills lists. All four report
request cancelled, the failure is 100% reproducible across restarts, and re-authorization does not clear it.Root cause appears to be a single unpaired UTF-16 surrogate in a CLI response (github/app#1055 has good data):
serde_jsoncorrectly rejects lone surrogates per RFC 8259, failing withunexpected end of hex escape.pending_requests, so every in-flight call resolves asRequestCancelled.models.list,account.getQuota,agents.discoverandskills.discover, so one bad frame takes out all four.is_transport_failure()is true, so the consumer spawns a fresh CLI, which refetches the same payload and dies at the same byte offset. Deterministic, and it survives restarts — matching the reporter's "same column 256886 across every restart".The CLI subprocess is the victim here, not the cause: its
write EPIPEflood is just the SDK having closed the pipe after the parse error.What changed
Repair unpaired surrogates to
U+FFFD(matching JavaScript'sString.prototype.toWellFormed) and retry the decode once.Two properties keep this contained:
send_request_with_inline_callbackawaits its oneshot with no transport-layer deadline. Those still return the originalserde_jsonerror, preserving the existing fast-fail-and-replace recovery.Net effect: exactly one input class changes behavior — a frame whose only defect is an unpaired surrogate.
The scanner tracks string context and consumes non-
\uescapes whole, so a literalC:\\ud83dis never mistaken for an escape prefix, and valid surrogate pairs pass through untouched.Demo
rust/tests/jsonrpc_test.rscovers this end of it:#1055-shaped frame is recovered and delivered withU+FFFDsubstituted, and the connection stays up for a subsequent requestReverting the fix while keeping the tests fails
lone_surrogate_frame_is_recovered_without_closing_connectionandvalid_pairs_and_escaped_backslashes_are_untouched, both unwrappingProtocol(RequestCancelled).The fix was also validated against the real desktop app and real CLI subprocesses before being brought upstream, by splicing a lone surrogate into live
models.listframes via a temporary debug-only injector (never committed).Same injected payload, two builds:
ERROR ... error reading from CLIfailed to list models/fetch account quotarequest cancelledWith the fix, a single CLI pid survived all three corrupted frames, across two different RPCs on the shared channel:
The original
serde_jsonmessage is preserved in theerror=field, so the diagnostic is kept rather than swallowed.Notes
repair_lone_surrogates. It is a hand-rolled byte scanner on the JSON-RPC read path, so the string-context and backslash-parity handling is where a bug would hide. It only ever runs on already-failed frames.send_request_with_inline_callbackhas no timeout, so any lost response hangs its caller indefinitely. That is the reason unrepairable frames must stay fatal, and it is worth revisiting on its own.JSON.parseaccepts lone surrogates natively so the TS SDK likely never had this bug; the strict-parser SDKs should be assessed separately rather than receiving unverified cross-language changes.scripts/sync-copilot-sdk.shonce this merges.