Conversation
added 3 commits
May 3, 2026 22:35
Content-Length in JSON-RPC's HTTP-style framing is bytes (LSP makes this explicit). The previous read-message used (make-string LENGTH) + read-sequence on a character stream, undercounting whenever the body had multi-byte UTF-8 -- the leftover bytes corrupted the next header parse and the read loop hung. write-message had the symmetric bug: (length json) is character count, not byte count. This change consolidates around binary streams throughout: - request-response.lisp: read-message / write-message and the supporting read-headers now operate on binary streams. Headers are decoded as ASCII (LSP headers are pure ASCII); bodies as UTF-8 via trivial-utf-8. A small read-ascii-line helper does scan-until-LF on a binary stream so read-headers stays structurally identical to the previous character-stream version (loop until empty line, parse each line) instead of becoming a state machine. - transport/tcp.lisp: socket-stream is already binary, so the shared read-message / write-message are exactly what tcp needs. The previous bespoke read-headers and inline body-reading logic (~70 lines) deleted; tcp's send/receive methods now delegate. - transport/stdio.lisp: stdio-transport's :input/:output now default to binary stdio streams (SBCL via sb-sys:make-fd-stream; non-SBCL must pass :input/:output binary streams explicitly). - transport/local-domain-socket.lisp: unchanged behavior. Note added: socket-make-stream there still defaults to a character stream; for multi-byte safety pass :element-type '(unsigned-byte 8) at the call site (this transport wasn't exercised in the original bug report). Tests: tests/request-response.lisp gains read-message-test with three cases plus a small frame-stream helper (uses flexi-streams, already a transitive dep, for in-memory binary input streams): - ASCII body parses (regression guard). - Multi-byte UTF-8 body parses with byte-counted Content-Length. - Two back-to-back messages where the first has multi-byte content -- second's method comes through intact (proves no desync). - tests/transport/stdio.lisp updated to pass binary fd-streams in the existing pipe-based stdio-server test. All upstream test suites (request-response, tcp-server, stdio-server, websocket-server) pass. Real-world impact: this hung an LSP server built on jsonrpc/transport/stdio -- every Lisp source file with one em-dash in a comment desynced the wire after didOpen.
stdio, tcp, and local-domain-socket all had identical 1-line SEND-MESSAGE-USING-TRANSPORT methods that called WRITE-MESSAGE on the connection's stream, and near-identical RECEIVE-MESSAGE-USING-TRANSPORT methods (tcp's only addition was an EOF handler that returns NIL). Move both to default :method clauses on the generic. WEBSOCKET-TRANSPORT keeps its own methods (different framing). No behavioral change. All 4 upstream test suites pass.
Optional follow-up to the binary-framing fix. Discard this commit if adding flexi-streams as a direct dep is unwelcome -- the previous two commits stand on their own. flexi-streams provides a stream wrap that exposes both character and binary I/O over a single underlying binary stream, with one shared buffer. With it, READ-MESSAGE creates one wrap and uses it for both: READ-LINE for ASCII headers, READ-SEQUENCE of bytes for the UTF-8 body. The local READ-ASCII-LINE helper and the custom EOF condition go away; READ-LINE signals stdlib END-OF-FILE on stream end and the default RECEIVE-MESSAGE-USING-TRANSPORT method catches that. Trade-off: flexi-streams becomes a direct dep. Widely depended on across the CL ecosystem (drakma, hunchentoot, websocket-driver) but not currently a transitive dep of jsonrpc itself. All 4 upstream test suites pass.
Author
|
The TL;DR on this is that it fixes a bug for UTF characters (specifics can be found in the individual test additions), by relying a bit more directly on the flexi-stream library which is already a dependency here |
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.
PR: Read & write JSON-RPC frames as bytes; require binary streams
Branch:
ramfjord:fix/byte-counted-content-length-binary→cxxxr:masterThe bug
Content-Lengthin JSON-RPC's HTTP-style framing is bytes (LSP makesthis explicit). The previous
read-messageused(make-string LENGTH)+read-sequenceon a character stream — thatreads
LENGTHcharacters. For pure-ASCII bodies the two are identical;for any multi-byte UTF-8 in the body, the reader takes too few bytes
from the stream. The leftover bytes corrupt the next message's header
parse and the read loop hangs.
write-messagehad the symmetriccharacter-vs-byte mismatch in
Content-Length.I hit this building a swank-backed LSP server: every Lisp source file
with a single em-dash in a comment desynced the wire after
didOpen,hanging
gdandKuntil the LSP was restarted. Same root causewould bite any LSP-shaped consumer of
jsonrpc/transport/stdio.The TCP transport never had this bug because it already operated on a
binary socket-stream and had its own byte-aware
read-headersinline.This PR consolidates around that working pattern.
The change (3 commits)
Three commits, each independently green and reviewable.
1. Read & write JSON-RPC frames as bytes; require binary streams
The actual bug fix.
request-response.lisp:read-headers,read-message, andwrite-messagenow operate on binary streams. Headers decode asASCII (LSP headers are pure ASCII); body decodes as UTF-8 via
trivial-utf-8.transport/tcp.lisp: socket-stream is already binary, so the sharedread-message/write-messageare exactly what tcp needs. tcp'sbespoke inline header parser (~70 lines) and body-reading deleted.
transport/stdio.lisp::input/:outputnow default to binarystdio streams via
sb-sys:make-fd-stream. Non-SBCL implementationsmust pass binary streams explicitly (errors helpfully on missing
initform).
transport/local-domain-socket.lisp: noted thatsocket-make-streamthere still defaults to a character stream.ASCII bodies behave as before; multi-byte safety needs
:element-type '(unsigned-byte 8)at the call site (left asfollow-up since this transport wasn't exercised in my report).
2. Hoist 1-line transport methods to defaults on the generic
Pure cleanup, no behavior change. stdio, tcp, and local-domain-socket
all had identical 1-line
send-message-using-transportmethods andnear-identical
receive-message-using-transportmethods. Move bothto default
:methodclauses on the generic intransport/interface.lisp. Websocket keeps its own methods (differentframing). Three transports now inherit the default; -19 LoC.
3. Use flexi-streams to wrap the binary stream as bivalent
flexi-streamsprovides a stream wrap exposing both character andbinary I/O over one buffered binary stream. With it,
read-messagecreates one wrap and uses it for both:
read-linefor ASCII headers,read-sequenceof bytes for the UTF-8 body. The byte-loop helper andcustom
eofcondition go away; stdlibend-of-filereplaces them.Adds
flexi-streamsas a direct dep — widely used across the CLecosystem (drakma, hunchentoot, websocket-driver) but not currently a
transitive dep of
jsonrpc.Tests
tests/request-response.lispgainsread-message-testwith threecases (each frames bytes inline via
flexi-streams—make-in-memory-input-stream+ a localfletfor theContent-Length:-prefixed wire format):the second's method comes through intact (proves no desync).
tests/transport/stdio.lispupdated to pass binary fd-streams in theexisting pipe-based stdio-server test.
All 4 upstream test suites pass on every commit:
request-response,tcp-server,stdio-server,websocket-server.Stats
flexi-streams(commit 3).Happy to split things differently or rework anything — let me know
what you'd prefer.