Skip to content

2.0: Scope event streams and bound event payloads #36441

Description

@kitlangton

Problem

The v2 /api/event stream is process-global. Every TUI receives every server-visible event across locations, workspaces, and sessions, then independently decodes, logs, projects, and sometimes reacts to it.

With N open TUIs, one source event currently causes approximately:

  • N listener queue offers in the server event bus
  • N schema encodes, JSON serializations, and SSE writes
  • N client decodes and durable-event log operations
  • N TUI event dispatches and store projections
  • potentially N follow-up refreshes or notifications

This becomes especially expensive because durable events currently contain unbounded payloads. Examples observed in a real next-channel database include:

  • session.tool.success events up to 10.2 MB, primarily unrestricted structured.output
  • session.input.admitted events over 1 MB due to inline base64 file attachments

An incident with eleven open TUIs drove the managed server to roughly 280-665% CPU and multiple gigabytes of memory. Every oversized event caused all connected TUIs to fail and reconnect together, amplifying subscriber and connection pressure.

Current behavior

  • packages/server/src/handlers/event.ts subscribes each /api/event response to the process-global EventV2 service.
  • isOpenCodeEvent filters by event visibility/type only, not location, workspace, session, or client interest.
  • EventV2.notify walks all listeners for every event.
  • Each response independently runs Schema.encodeUnknownSync, JSON.stringify, and SSE encoding.
  • packages/tui/src/context/sdk.tsx decodes and dispatches every event and logs every durable event.
  • packages/tui/src/context/data.tsx projects foreign-session events into each TUI's store and some event types trigger refresh requests.
  • Stream ownership and server-side subscriber counts are not observable.

Desired invariants

  1. A TUI subscribes only to the locations/workspaces/sessions it needs.
  2. A session event is delivered once per interested client, not once per connected process.
  3. Opening unrelated TUIs does not materially increase CPU for an active session.
  4. Event payloads are bounded independently of tool output and attachment size.
  5. Large data is fetched by identifier only by interested clients.
  6. One mounted client owns exactly one active stream; reconnect replaces rather than overlaps it.
  7. Closing or failing a client stream promptly releases the server response, listener, and queue.
  8. Server diagnostics expose active stream/subscriber count and event serialization volume.

Proposed workstreams

Scope subscriptions

  • Add an explicit subscription scope to the event protocol: project/location, optional workspace, and optional session set.
  • Filter before events enter each subscriber queue, not after global fan-out.
  • Define behavior for truly global events separately.
  • Support changing interest without opening redundant streams, or define one scoped stream per location if that is the simpler ownership model.

Bound event projections

  • Replace full session.tool.success.structured/result payloads with bounded completion metadata and identifiers.
  • Remove inline base64 attachment data from session.input.admitted events.
  • Add bounded preview/summary fields only where the TUI needs immediate rendering.
  • Let interested clients fetch the complete message, tool result, or attachment through the session/message API.
  • Establish and test an encoded event-size budget.

Stream lifecycle

  • Make the client stream own transport cancellation and verify real socket closure.
  • Ensure reconnect waits for or aborts the previous stream before opening another.
  • Verify server HTTP response finalization removes the EventV2.liveBounded listener and queue.
  • Add jitter/backoff for synchronized reconnect waves where appropriate.

Observability

Expose bounded diagnostics for:

  • active event-stream responses
  • active event-bus subscribers
  • opened/closed streams
  • serialized events and bytes by event type
  • subscriber overflows
  • reconnect reason counts

Do not log event payloads or user content.

Regression tests

Correctness

  • One mounted TUI has exactly one active stream and zero after teardown.
  • Repeated malformed/transport reconnects never exceed one active stream per TUI.
  • Eleven TUI clients produce exactly eleven subscriptions and one handling per event ID.
  • Destroying five clients reduces active subscriptions from eleven to six.
  • Server-side listener count returns to baseline after response cancellation.
  • Location/session-scoped clients do not receive unrelated events.
  • Encoded event projections remain within the agreed budget.

Performance benchmark

Add a non-gating fan-out benchmark with client counts 1, 2, 4, 8, 11, 16 and small, medium, and large event projections. Record:

  • publish CPU time
  • serialized records and bytes
  • aggregate client handling time
  • peak active connections/subscribers
  • RSS and event-loop delay

The benchmark should verify the accounting laws even if absolute timing remains non-gating.

Related immediate fix

A separate v2 PR raises the generated Promise SSE event limit from 1 MiB to 16 MiB and reports SseEventTooLarge instead of MalformedResponse. That prevents valid current events from triggering synchronized reconnects, but it does not solve global fan-out or unbounded event payloads.

Implementation issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    2.0coreAnything pertaining to core functionality of the application (opencode server stuff)discussionUsed for feature requests, proposals, ideas, etc. Open discussionperfIndicates a performance issue or need for optimizationtui

    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