You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The stream ended without a terminal — or ended normally and fired onDone twice
Each fix is correct and each should land. But four guards written separately means the fifth leak is written the same way, by whoever next adds a path that can fail after the first byte is out.
The rule underneath
A chat turn ends in exactly one terminal, and the terminal is earned.
Today "ended" is inferred in several places from several things: the loop finished, the stream closed, the provider said stop, no error was raised. None of those is the same as the answer exists and was kept. The four fixes above are each a local repair of one inference.
What would make the fifth leak hard to write is a single representation the producer must return — a value that is either the answer, or a reason — with the SSE layer emitting exactly one terminal from it and the client accepting exactly one. Then "the loop ended" stops being a code path that can reach done at all, because done is not reachable without the value.
The client parser is a separate problem, and it should be a library
streamSse in web/src/api.ts hand-rolls SSE framing, and it is wrong in ways the spec is explicit about:
while((idx=buf.indexOf("\n\n"))>=0){// CRLF and bare CR are also frame separators
...
elseif(line.startsWith("data:"))data+=line.slice(5).trim();// multiple data: lines join with \n}// and trim() eats meaningful whitespace
Frames are split on \n\n only. The spec allows \r\n\r\n and \r\r.
Multiple data: lines in one event must be joined with \n. Here they are concatenated with nothing, so a multi-line payload is silently corrupted.
.trim() on each data line destroys leading and trailing whitespace that belongs to a delta.
Split UTF-8 across chunk boundaries is handled (decoder.decode(value, {stream: true})), which is the one part that is right.
The server side is already on a framework — axum::response::sse::{Event, KeepAlive, Sse} — so this is the one hand-written half. The browser's native EventSource is not a candidate because these streams are POSTs with a body, but the ecosystem has small focused parsers for exactly this shape (eventsource-parser is the common one; whoever picks should check its maintenance status rather than take this as a recommendation of a version). Feed it the chunks, keep our own event dispatch and reattachment policy on top.
web/package.json currently has no SSE dependency at all.
These two are complementary, not alternatives. A spec-correct parser fixes the framing bugs and none of the terminal-semantics bugs — onDone firing twice and an unterminated EOF reporting success are our logic, not the parser's. #851 is where that discipline belongs; the framing underneath it is what should stop being ours.
Then introduce the single producer terminal, and delete the guards that become unreachable.
Step 3 is the one that stops this list from growing, and it is only worth doing after 1 and 2 have settled what the terminal has to carry.
Background
Noticed while reviewing the batch on 2026-09-21. Related: #844 (the report behind #845), and the same shape appeared earlier this week on the export side — #821, #823, #824, #831 — where the store held something and the serializer dropped it without an error. Silent success is the recurring failure mode in both places.
The family
Four open PRs each close one hole, and they are the same hole:
donedonewas emitted anywayonDonetwiceEach fix is correct and each should land. But four guards written separately means the fifth leak is written the same way, by whoever next adds a path that can fail after the first byte is out.
The rule underneath
A chat turn ends in exactly one terminal, and the terminal is earned.
Today "ended" is inferred in several places from several things: the loop finished, the stream closed, the provider said
stop, no error was raised. None of those is the same as the answer exists and was kept. The four fixes above are each a local repair of one inference.What would make the fifth leak hard to write is a single representation the producer must return — a value that is either the answer, or a reason — with the SSE layer emitting exactly one terminal from it and the client accepting exactly one. Then "the loop ended" stops being a code path that can reach
doneat all, becausedoneis not reachable without the value.The client parser is a separate problem, and it should be a library
streamSseinweb/src/api.tshand-rolls SSE framing, and it is wrong in ways the spec is explicit about:\n\nonly. The spec allows\r\n\r\nand\r\r.data:lines in one event must be joined with\n. Here they are concatenated with nothing, so a multi-line payload is silently corrupted..trim()on each data line destroys leading and trailing whitespace that belongs to adelta.decoder.decode(value, {stream: true})), which is the one part that is right.The server side is already on a framework —
axum::response::sse::{Event, KeepAlive, Sse}— so this is the one hand-written half. The browser's nativeEventSourceis not a candidate because these streams are POSTs with a body, but the ecosystem has small focused parsers for exactly this shape (eventsource-parseris the common one; whoever picks should check its maintenance status rather than take this as a recommendation of a version). Feed it the chunks, keep our own event dispatch and reattachment policy on top.web/package.jsoncurrently has no SSE dependency at all.These two are complementary, not alternatives. A spec-correct parser fixes the framing bugs and none of the terminal-semantics bugs —
onDonefiring twice and an unterminated EOF reporting success are our logic, not the parser's. #851 is where that discipline belongs; the framing underneath it is what should stop being ours.Suggested order
streamSsewith a parser library, keeping the terminal handling Preserve exact chat outcomes across stream closure and reattachment #851 establishes.Step 3 is the one that stops this list from growing, and it is only worth doing after 1 and 2 have settled what the terminal has to carry.
Background
Noticed while reviewing the batch on 2026-09-21. Related: #844 (the report behind #845), and the same shape appeared earlier this week on the export side — #821, #823, #824, #831 — where the store held something and the serializer dropped it without an error. Silent success is the recurring failure mode in both places.