@@ -165,14 +165,55 @@ function legacySessionId(json: RemoteConnectResponse): string | undefined {
165165}
166166
167167/**
168- * Parse SSE stream from a ReadableStream.
169- * Yields { event, data } for each SSE message.
168+ * Parse an SSE stream from a ReadableStream.
169+ * Yields `{ event, data }` for each complete SSE message.
170+ *
171+ * ⚠️ **The in-progress event must live OUTSIDE the read loop.** A `read()`
172+ * returns an arbitrary slice of bytes, not a whole frame, so a frame's `data:`
173+ * line can arrive in one chunk and the blank line terminating it in the next.
174+ * Declaring `currentEvent` / `currentData` inside the loop — which this did
175+ * until #2134 — discards the accumulated payload at that boundary: the frame is
176+ * never yielded, the JSON-RPC response it carried never settles, and the caller
177+ * hangs forever with no error on any channel. `buffer` already carries a partial
178+ * *line* across reads; these carry the partial *frame*.
179+ *
180+ * It survived because Chromium and Firefox happen to deliver whole frames at the
181+ * payload sizes this transport sees. That is luck rather than a guarantee —
182+ * chunk boundaries are a function of payload size, TCP segmentation and any
183+ * intermediary — and the exposure grows with payload size, so a large
184+ * `resources/read` is the most likely first victim.
185+ *
186+ * Exported for `clients/web/src/test/core/mcp/remote/parseSSE.test.ts`, which is
187+ * the only way to reach the defect deterministically: driving a real transport
188+ * cannot steer where the chunk boundary lands, but a test that supplies the
189+ * reader can put it exactly on the terminator.
170190 */
171- async function * parseSSE (
191+ export async function * parseSSE (
172192 reader : ReadableStreamDefaultReader < Uint8Array > ,
173193) : AsyncGenerator < { event : string ; data : string } > {
174194 const decoder = new TextDecoder ( ) ;
175195 let buffer = "" ;
196+ // Carried ACROSS reads, not per chunk — see the header.
197+ let currentEvent = "message" ;
198+ let currentData : string [ ] = [ ] ;
199+
200+ const consume = ( line : string ) : { event : string ; data : string } | null => {
201+ if ( line . startsWith ( "event:" ) ) {
202+ currentEvent = line . slice ( 6 ) . trim ( ) ;
203+ } else if ( line . startsWith ( "data:" ) ) {
204+ currentData . push ( line . slice ( 5 ) . trimStart ( ) ) ;
205+ } else if ( line === "" ) {
206+ const complete =
207+ currentData . length > 0
208+ ? { event : currentEvent , data : currentData . join ( "\n" ) }
209+ : null ;
210+ currentEvent = "message" ;
211+ currentData = [ ] ;
212+ return complete ;
213+ }
214+ // Anything else (a `:` keep-alive comment, `id:`, `retry:`) is ignored.
215+ return null ;
216+ } ;
176217
177218 while ( true ) {
178219 const { done, value } = await reader . read ( ) ;
@@ -183,36 +224,21 @@ async function* parseSSE(
183224 /* v8 ignore next -- String.split always returns a non-empty array, so pop() is never undefined; the ?? "" fallback is unreachable. */
184225 buffer = lines . pop ( ) ?? "" ;
185226
186- let currentEvent = "message" ;
187- let currentData : string [ ] = [ ] ;
188-
189227 for ( const line of lines ) {
190- if ( line . startsWith ( "event:" ) ) {
191- currentEvent = line . slice ( 6 ) . trim ( ) ;
192- } else if ( line . startsWith ( "data:" ) ) {
193- currentData . push ( line . slice ( 5 ) . trimStart ( ) ) ;
194- } else if ( line === "" ) {
195- if ( currentData . length > 0 ) {
196- yield { event : currentEvent , data : currentData . join ( "\n" ) } ;
197- }
198- currentEvent = "message" ;
199- currentData = [ ] ;
200- }
228+ const complete = consume ( line ) ;
229+ if ( complete ) yield complete ;
201230 }
202231 }
203232
204- if ( buffer . trim ( ) ) {
205- const lines = buffer . split ( "\n" ) ;
206- let currentEvent = "message" ;
207- const currentData : string [ ] = [ ] ;
208- for ( const line of lines ) {
209- if ( line . startsWith ( "event:" ) ) currentEvent = line . slice ( 6 ) . trim ( ) ;
210- else if ( line . startsWith ( "data:" ) )
211- currentData . push ( line . slice ( 5 ) . trimStart ( ) ) ;
212- }
213- if ( currentData . length > 0 ) {
214- yield { event : currentEvent , data : currentData . join ( "\n" ) } ;
215- }
233+ // Stream ended: flush the trailing partial line, then anything still held. A
234+ // server that closes without a final blank line has still delivered a complete
235+ // frame, and dropping it would lose the last message of every such stream.
236+ if ( buffer ) {
237+ const complete = consume ( buffer ) ;
238+ if ( complete ) yield complete ;
239+ }
240+ if ( currentData . length > 0 ) {
241+ yield { event : currentEvent , data : currentData . join ( "\n" ) } ;
216242 }
217243}
218244
0 commit comments