Skip to content

client: large image read disconnects every SSE subscriber #36343

Description

@kitlangton

Summary

Reading a supported PNG can emit a valid session.tool.success event larger than the generated client's 1 MiB SSE buffer limit. Because /api/event is global, one image read disconnects every connected TUI with MalformedResponse, including TUIs displaying unrelated sessions.

The observed events also contained the image base64 twice: once in structured output and once in model content.

Environment

  • opencode version: 0.0.0-next-15328
  • OS: Darwin 25.5.0, arm64
  • Terminal: WarpTerminal (TERM=xterm-256color, COLORTERM=truecolor)
  • Shell: /bin/zsh
  • Install/channel: global Bun installation, next
  • Active plugins: share-with-team.ts, v2 debug workspace plugin, @warp-dot-dev/opencode-warp

Reproduction

  1. Start multiple v2 TUIs connected to the same managed service.

  2. In one session, use the built-in read tool on a supported PNG of roughly 750 KiB:

    read({ path: "/tmp/example.png" })
  3. Observe every connected TUI, including TUIs displaying unrelated sessions.

The incident produced this durable sequence:

Seq Event Stored payload
611 session.tool.input.ended.1 200 bytes
612 session.tool.called.1 287 bytes
613 session.tool.success.1 2,007,317 bytes
614 session.step.ended.1 277 bytes

All nine clients parsed sequence 612, then disconnected within 20 ms while receiving sequence 613:

event stream disconnected component=sdk attempt=1 error=MalformedResponse role=cli

A second image read reproduced it approximately 25 seconds later:

Seq Event Stored payload
648 session.tool.called.1 276 bytes
649 session.tool.success.1 2,009,268 bytes
650 session.step.ended.1 277 bytes

Expected Behavior

  • Every event the server can legitimately emit should be consumable by the generated client.
  • Reading an image in one session should not disconnect unrelated clients.
  • Image bytes should not be duplicated in one durable event.
  • If an event is genuinely too large, the error should identify the size violation rather than report malformed data.

Actual Behavior

The server successfully persists and broadcasts an approximately 2 MiB valid SSE event. Every generated client rejects it after its local buffer exceeds 1 MiB and displays MalformedResponse.

The image is duplicated in the success event:

structured JSON: approximately 1,003,562 bytes
model content:   approximately 1,003,572 bytes
complete event:  2,007,317 bytes

The two observed image payloads were 1,003,412 and 1,004,404 base64 characters respectively.

Additional Context

Client limit

packages/client/src/promise/generated/client.ts checks the accumulated SSE buffer before parsing complete event boundaries:

buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")

The server has no matching per-event limit. HTTP can deliver the valid 2 MiB SSE block over multiple reads, but the client throws once its accumulated block crosses 1 MiB.

Duplicate image data

The built-in read tool returns structured image data containing base64. Its toModelOutput projection creates a file content item from the same base64. Tool settlement preserves both representations in session.tool.success.

The observed structured output had these fields:

content, encoding, mime, name, uri

The same image was also present in content[].uri as a data:image/png;base64,... attachment.

Output bounding gap

ToolOutputStore.bound measures structured output only when model content is empty. If model content exists, it measures only text items:

const contextual =
  input.output.content.length === 0
    ? JSON.stringify(input.output.structured, null, 2)
    : text.map((item) => item.text).join("")

For an image read, the measured text is effectively only Image read successfully; neither the base64 in structured output nor the media attachment is included. The approximately 2 MiB event therefore bypasses the configured 50 KiB generic output bound.

Even if text bounding were triggered, the current result preserves structured output and media.

Existing test gap

packages/core/test/session-runner-tool-events.test.ts has a test named local tool success serializes media base64 once and reconstructs from structured content, but its structured fixture contains only small metadata. It does not use the real read output shape, where structured output itself contains the base64.

Suggested fix

Immediate:

  1. Ensure image base64 is serialized at most once in a durable tool-success event.
  2. Define a protocol-level maximum SSE event size that both producer and generated client enforce.
  3. Parse complete SSE boundaries before applying the incomplete-buffer guard.
  4. Report a specific EventTooLarge error including received and maximum sizes.
  5. Add a regression test using the production-shaped read output and multiple subscribers.

Longer term:

Persist media through ToolOutputStore as an opaque managed artifact and broadcast a reference rather than base64 bytes. This avoids base64 expansion, SQLite/event-log bloat, and globally broadcasting large media to every TUI.

This is distinct from #31281, which concerned an upstream idle timeout while processing large tool results. This failure is a deterministic generated-client rejection of a valid oversized SSE event.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions