Skip to content

[Detail Bug] SSE streaming: final event without trailing newline is rejected as invalid #153

Description

@detail-app

Detail Bug Report

https://app.detail.dev/org_befd6425-a158-4e24-9d4d-1e5c08769515/bugs/bug_f7ff0127-e1c9-41ff-b796-4b676c017699

Introduced in #3 by @WilliamAGH on Jan 24, 2026

Summary

  • Context: sse.ts parses Server-Sent Events from backend streams. When a stream ends with an incomplete data line (no trailing newline), the parser commits the raw line to the event buffer without stripping the data: prefix.
  • Bug: Incomplete final data lines pass data: {...} to processEvent instead of {...}, causing tryParseJson to return null and validateWithSchema to fail. The user sees "Received an invalid SSE event from the server" instead of the actual content.
  • Actual vs. expected: The parser should strip the data: prefix from incomplete final lines just as it does for complete lines (lines 415-424), so the content can be parsed and delivered.
  • Impact: The user sees "Received an invalid SSE event from the server" instead of the actual content.

Code with Bug

// `frontend/src/lib/services/sse.ts` — stream-end handling
if (streamEnded) {
    streamCompletedNormally = true;
    const remainingDecodedText = decoder.decode();
    if (remainingDecodedText) {
        unprocessedText += remainingDecodedText;
    }
    // Commit any remaining buffered line before flushing event data
    if (unprocessedText.length > 0) {
        sseEventBuffer = sseEventBuffer
            ? `${sseEventBuffer}\n${unprocessedText}`
            : unprocessedText;  // <-- BUG 🔴 keeps "data:" prefix for final incomplete line
        hasBufferedSseEvent = true;
        unprocessedText = "";
    }
    flushSseEvent();
    break;
}
// `frontend/src/lib/services/sse.ts` — normal line processing
if (receivedLine.startsWith("data:")) {
    const sseEventText = receivedLine.startsWith("data: ")
        ? receivedLine.slice(6)
        : receivedLine.slice(5);  // Prefix stripped correctly
    if (sseEventText === "[DONE]") {
        continue;
    }
    if (hasBufferedSseEvent) {
        sseEventBuffer += "\n";
    }
    sseEventBuffer += sseEventText;  // Added without prefix
    hasBufferedSseEvent = true;
}

Explanation

When the stream ends, the code appends the remaining buffered text (unprocessedText) directly into sseEventBuffer and flushes. If the last line is an incomplete SSE data: line (missing a trailing newline), this path does not strip the data: prefix. As a result, processEvent receives a payload like data: {"text":"hello"} instead of valid JSON, so tryParseJson returns null, schema validation fails, and the client throws Received an invalid SSE event from the server.

Codebase Inconsistency

Normal per-line parsing strips data:/data: before buffering event text, but the stream-end “commit remaining buffered line” path does not. Both paths are performing the same logical operation (buffering event data) but produce different formats.

Failing Test

// frontend/src/lib/services/sse.incomplete.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { streamSseGet } from "./sse";  // PUBLIC API - exported at line 287

describe("SSE incomplete final line bug", () => {
  it("parses incomplete final data lines correctly", async () => {
    const encoder = new TextEncoder();
    // Input: event: text\ndata: {"text":"hello"} (no trailing newline)
    const incompleteSseBytes = encoder.encode('event: text\ndata: {"text":"hello"}');

    vi.stubGlobal("fetch", vi.fn().mockResolvedValue(
      new Response(
        new ReadableStream<Uint8Array>({
          start(controller) {
            controller.enqueue(incompleteSseBytes);
            controller.close();  // Stream ends normally (done: true) with incomplete event
          },
        }),
        { status: 200, statusText: "OK" }
      )
    ));

    const onText = vi.fn();
    const onError = vi.fn();

    await streamSseGet("/api/test/stream", { onText, onError }, "test");

    // BUG: onText is never called because data: prefix wasn't stripped
    expect(onText).toHaveBeenCalledWith("hello");
  });
});

Test output:

stderr | sse.incomplete.test.ts > SSE incomplete final line bug > parses incomplete final data lines correctly
[Zod] validateWithSchema [test:text] validation failed
Issues:
  - (root): Invalid input: expected object, received null (expected: object)
Payload keys: 
[Zod] validateWithSchema [test:text] - full details: {
  prettifiedError: '✖ Invalid input: expected object, received null',
  issues: [
    {
      expected: 'object',
      code: 'invalid_type',
      path: [],
      message: 'Invalid input: expected object, received null'
    }
  ],
  rawInput: null
}

 FAIL  sse.incomplete.test.ts > SSE incomplete final line bug > parses incomplete final data lines correctly
Error: Received an invalid SSE event from the server
 ❯ throwInvalidSseEvent src/lib/services/sse.ts:116:9
 ❯ processEvent src/lib/services/sse.ts:221:7
 ❯ flushSseEvent src/lib/services/sse.ts:357:5
 ❯ consumeSseStream src/lib/services/sse.ts:386:9
 ❯ streamSseGet src/lib/services/sse.ts:305:3

Recommended Fix

// `frontend/src/lib/services/sse.ts` (proposed fix)
if (unprocessedText.length > 0) {
    let sseEventText = unprocessedText;
    
    // Strip "data:" prefix if present (match normal line processing at lines 415-424)
    if (unprocessedText.startsWith("data: ")) {
        sseEventText = unprocessedText.slice(6);
    } else if (unprocessedText.startsWith("data:")) {
        sseEventText = unprocessedText.slice(5);
    }
    
    // Skip [DONE] token in incomplete final line
    if (sseEventText === "[DONE]") {
        unprocessedText = "";
    } else {
        sseEventBuffer = sseEventBuffer
            ? `${sseEventBuffer}\n${sseEventText}`
            : sseEventText;
        hasBufferedSseEvent = true;
        unprocessedText = "";
    }
}
flushSseEvent();

History

This bug was introduced in commit c5b1d8e. The commit added SSE event type parsing to route status, error, and text events to their respective handlers. The stream-end handling was updated to commit any remaining buffered line before flushing, but it failed to strip the data: prefix from incomplete lines — unlike the normal line processing which correctly strips the prefix. The bug slipped in because the stream-end case was a new addition that mirrored the multi-byte character buffering logic without accounting for SSE line format processing.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions