Parse SSE data lines once, byte-safely, and per spec - #16
Conversation
The six hand-rolled `data:` extractions -- parse-delta and parse-stream-event for each of OpenAI, Anthropic and Gemini -- were all `(String.starts-with? line "data: ")` plus a byte-slice at offset 6. Three problems followed. core's `String.starts-with?` guards on byte length and then slices characters, so a line of at least 6 bytes but fewer than 6 characters overruns `Array.prefix` and aborts the process. These lines arrive straight off the network. `http` #31 and `http-client` #16 fixed the same class. The SSE grammar is `field ":" [space] value` with at most one leading space stripped, so all six sites silently dropped every event from a provider or proxy emitting `data:{...}`. `llm-stream-extract-line` split on `\n` only, but SSE terminators are CRLF, CR or LF -- a CR-only stream never yielded a line at all. Replaced with `llm-byte-starts-with?` and `llm-sse-data` next to the other `llm-*` internals; the six sites now match on the shared extractor with their bodies untouched. A comment line needs no special case, since a line beginning `:` cannot begin `data:`. A CR that is the last byte in the buffer is held back rather than dispatched, since it may be the first half of a CRLF split across two reads. 195 -> 195 with the sites rewritten and tests unchanged; 209 with 14 new tests. Each defect was teeth-checked by reverting just its part of the fix.
There was a problem hiding this comment.
Build & Tests
Checked out claude/sse-line-parsing and ran it here (armhf Linux; CI is macOS-only, so these are complementary signals).
| result | |
|---|---|
carp -x test/llm.carp |
209 passed, 0 failed, exit 0 |
CI test (macos-latest) |
pass |
209/0 matches the PR body exactly.
The "before" value reproduces. The most important claim here is that the old parse could take the process down, so I checked it against unmodified main rather than taking the pasted output on faith:
$ carp -x .probeM.carp # first call is (OpenAI.parse-delta "ääää")
M1: OpenAI.parse-delta on 8 bytes / 4 chars -- about to call
Untitled: /home/hellerve/.carp/out/main.c:23395: Char *Array_unsafe_MINUS_nth__Char(Array *, int): Assertion `n < a.len' failed.
rc=134
It dies on the first probe, before printing a result. On this branch the same 17-case probe runs to completion, exit 0.
Findings
I went at this from the angles the test suite doesn't cover. Everything below checked out; no bugs found.
The length guard in llm-byte-starts-with? (llm.carp:455-457) is load-bearing, and it holds. Bool.carp:10 registers and as a strict (Fn [Bool Bool] Bool), so if that were the binding in play, (String.byte-slice s 0 n) would be evaluated even when n > (String.length s) — and String_byte_MINUS_slice is a bare memcpy with no bounds check, i.e. a heap overread on every short line. It isn't: Macros.carp:132 defines and as a macro expanding to if, so the guard short-circuits. Same for the or in llm-stream-extract-line.
The index arithmetic in llm-stream-extract-line (llm.carp:1514-1530) is consistent and in bounds. String_index_MINUS_of_MINUS_any_MINUS_from (carp_string.h:365) compares raw bytes and returns a byte offset, matching byte-slice and char-at; and since CR/LF are ASCII they can never collide with a UTF-8 continuation byte. The new (String.char-at buf (inc pos)) is only reachable when pos == cr, and the guard on the line above has already excluded (= (inc pos) len) for exactly that case — when pos == lf the and short-circuits before char-at runs. So inc pos < len whenever it is dereferenced.
The trailing-CR hold-back cannot wedge the stream. This was the part I most expected to bite: llm-stream-extract-line returning Nothing forever on a buffer ending in CR. It can't, because poll (:1585) and poll-event (:1659) answer Nothing by calling ResponseStream.poll, whose own Nothing sets done and exits the loop. At EOF the held CR is simply dropped, which is what the PR says.
The sweep is complete. No String.starts-with?, ends-with?, prefix, suffix or char-at remains anywhere in llm.carp outside the one bounds-checked char-at above. I also checked the fourth provider, since it isn't in the PR's list of six: Ollama.parse-delta goes straight to JSON.parse on NDJSON and never had the prefix check, so nothing was missed there.
The fix holds on the public path, not just at parse-delta. poll/poll-event run each line through String.trim before dispatching, so a byte-safe parse-delta behind a char-unsafe trim would still abort. String.trim is Pattern-based and byte-safe — it returns 8 bytes for "ääää" and 6 for a bare run of continuation bytes.
Hostile inputs, all safe on this branch (Nothing/ChunkSkip, no abort): 8-byte/4-char lines through all three parse-deltas and parse-stream-event; 6- and 32-byte runs of bare continuation bytes (0 characters, so the widest byte/char gap); "data: ääää"; "data" + one continuation byte. data:{...} and data:[DONE] both parse, data: x yields " x", and ": data: ping" yields Nothing.
Housekeeping claims check out. llm has no CHANGELOG.md, so adding none is right. docs/ genuinely is unaffected: the committed HTML contains no top-level llm-* helper at all, so two more of them change nothing. And the new helpers match local convention — llm.carp marks nothing hidden or private, and all 20-odd existing llm-* helpers are plain top-level defns.
One nit, take it or leave it
llm-sse-data (llm.carp:459-464) requires the colon. Per WHATWG HTML §9.2.6, a line that is not empty and contains no colon is processed as a field whose value is the empty string — so a bare data line is a legal empty-data event, and this returns Nothing for it. Not a regression (the old starts-with? "data: " dropped it too), and no provider emits it, so this is only worth a line if you want the grammar followed to the letter.
Verdict: merge
Builds, 209/0 locally and green on CI, the abort it claims to fix reproduces on main in one call, and the three defects are each real. The new code is more careful than what it replaces — byte-indexed throughout, short-circuited guards, bounds provable at every dereference — and the PR body is honest about which of its tests are teeth-checked and which are regression guards, including walking back the CRLF claim. Still a draft, so marking it ready is yours.
llm.carphand-rolled SSEdata:extraction in six places —parse-deltaand
parse-stream-eventfor each of OpenAI, Anthropic and Gemini. Each was(String.starts-with? line "data: ")followed by(String.byte-slice line 6 (String.length line)). This replaces all six withone shared, byte-safe extractor and fixes the line splitter to accept every
SSE terminator.
The three defects
(a) The parse could abort the process on bytes a provider chose. core's
String.starts-with?guards on byte length and then slices characters, soa line that is at least 6 bytes but fewer than 6 characters overruns
Array.prefixand trips its bounds assertion —SIGABRT, the whole process,no way to catch it. These lines come straight off the network.
Reproduced before fixing, on
main:http#31 andhttp-client#16 fixed exactly this class.(b) The space after the colon is optional. The SSE grammar is
field ":" [space] value, and at most one leading space is stripped. All sixsites demanded
"data: ", so a provider or proxy that emitsdata:{...}hadevery event silently dropped — no error, just an empty stream.
(c) Only
\nwas treated as a line terminator. SSE terminators are CRLF,CR or LF.
llm-stream-extract-linelooked for\newlineonly, so aCR-only stream never yielded a single line: the buffer grew until the socket
closed and the entire response was discarded.
What changed
Two helpers next to the other
llm-*internals:llm-byte-starts-with?— bytewise prefix test, same shape ashttp-client's.llm-sse-data— returns(Maybe String): thedatafield value with atmost one leading space stripped, or
Nothingfor any other line.Comment lines need no special case: a line beginning
:cannot begindata:,so
llm-sse-dataalready returnsNothingfor it. There is a test pinningthat.
The six sites become
(match (llm-sse-data line) (Maybe.Just json-str) ...).The bodies are untouched — the
matcharm replaces theif+letpair at thesame nesting depth, so the diff is two lines per site plus the closing paren.
OpenAI's
[DONE]check moves inside, so it now also benefits from (a) and (b).llm-stream-extract-linenow takes the earliest of CR and LF and consumes oneor two bytes accordingly.
One deliberate limitation
A CR that is the last byte in the buffer is held back rather than dispatched,
because it may be the first half of a CRLF split across two reads. If the
stream then ends there, that final line is dropped — the same way an
unterminated final line is dropped today. Handling it would need an EOF signal
inside
llm-stream-extract-line, which it has no access to; it is out of scopehere, and the last line of an SSE stream is the terminator event.
Verification
Local, on this branch (
llm's CI matrix is macOS-only, so local is thestronger signal):
mainbaseline14 tests were added. Each defect was checked for teeth by reverting just the
part of the fix that covers it:
String.starts-with?back intollm-sse-data→ the suiteaborts:
SIGABRT, exit 134, sameArray_unsafe_nth__Charboundsassertion as the probe above. The abort discards buffered stdout, so the log
cannot pin which assert died; the standalone probe run against unmodified
mainis the precise evidence, and it aborts on bothOpenAI.parse-deltaand
Anthropic.parse-deltafor the literal input these four tests use."data: "→ the four "without a space afterthe colon" tests fail.
\newline-only extractor → "strips the CR of a CRLFterminator" and "reads lines from a CR-only stream" fail.
Four of the 14 are regression guards rather than teeth-checked tests, and
worth naming so nobody assumes otherwise: "strips at most one space",
"ignores comment lines", "waits for the byte after a trailing CR" and "reads
lines from a CRLF stream" all still pass under the three reverts above. The
last one deserves a note — a CRLF stream is not actually broken today
through the public API, because
pollandpoll-eventrun each line throughString.trim, which happens to strip the stray\r. The trailing\risreal, but it is only observable one level down, at
llm-stream-extract-line,which is where that test asserts it. The defect that bites end to end is the
CR-only stream, which yields nothing at all.
carp-fmt --checkandanglerare clean on both changed files.llmhas noCHANGELOG.md, so none was added.docs/is unchanged: no doc string, nopublic signature and no documented module gained or lost a binding.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.