Skip to content

Commit e8fc42a

Browse files
committed
fix: buffer Docker log chunks into complete lines before SSE emit
Docker splits long stdout lines (e.g. stream-json tool_result events with full file contents) across multiple log frames. Each fragment fails JSON.parse in the frontend, displaying raw JSON blobs. Backend: stream_output now buffers chunks and only emits complete newline-delimited lines, so each SSE event is a parseable JSON line. Frontend: parseStreamEvent suppresses text that looks like a JSON fragment (starts with {, [, or ") as defense-in-depth.
1 parent 357bb74 commit e8fc42a

2 files changed

Lines changed: 27 additions & 9 deletions

File tree

apps/api/src/helprs/modules/container/service.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -299,16 +299,28 @@ async def stream_output(
299299
) -> AsyncIterator[str]:
300300
"""Async generator yielding container log lines as SSE events.
301301
302-
Each event includes an incrementing ``id:`` field so that clients can
303-
resume from the last received event via the ``offset`` query parameter
304-
(number of events to skip).
302+
Docker may split long stdout lines (e.g. stream-json tool_result
303+
events with full file contents) across multiple log frames. We
304+
buffer chunks and only emit complete newline-delimited lines so
305+
the frontend always receives valid, parseable JSON per SSE event.
306+
307+
Each event includes an incrementing ``id:`` field so that clients
308+
can resume from the last received event via the ``offset`` query
309+
parameter (number of events to skip).
305310
"""
306311
event_id = 0
307-
async for line in docker.container_logs(container_id, follow=True):
308-
event_id += 1
309-
if event_id <= offset:
310-
continue
311-
yield f"id: {event_id}\ndata: {line}\n\n"
312+
buffer = ""
313+
async for chunk in docker.container_logs(container_id, follow=True):
314+
buffer += chunk
315+
while "\n" in buffer:
316+
line, buffer = buffer.split("\n", 1)
317+
line = line.strip()
318+
if not line:
319+
continue
320+
event_id += 1
321+
if event_id <= offset:
322+
continue
323+
yield f"id: {event_id}\ndata: {line}\n\n"
312324

313325

314326
async def send_message(

apps/web/src/features/session/containerTypes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ export function parseStreamEvent(raw: string): { text: string; kind: TerminalLin
5757
try {
5858
event = JSON.parse(raw)
5959
} catch {
60-
// Not JSON -- show as raw text
60+
// Not JSON — if it looks like a truncated JSON fragment (from Docker
61+
// log frame splitting), suppress it. Only show genuinely non-JSON
62+
// lines (git clone output, entrypoint messages, etc.).
63+
const trimmed = raw.trimStart()
64+
if (trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('"')) {
65+
return null
66+
}
6167
return { text: raw, kind: 'text' }
6268
}
6369

0 commit comments

Comments
 (0)