Repository navigation
2.0: Scope event streams and bound event payloads #36441
Description
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:
Nlistener queue offers in the server event busNschema encodes, JSON serializations, and SSE writesNclient decodes and durable-event log operationsNTUI event dispatches and store projections- potentially
Nfollow-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.successevents up to 10.2 MB, primarily unrestrictedstructured.outputsession.input.admittedevents 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.tssubscribes each/api/eventresponse to the process-globalEventV2service.isOpenCodeEventfilters by event visibility/type only, not location, workspace, session, or client interest.EventV2.notifywalks all listeners for every event.- Each response independently runs
Schema.encodeUnknownSync,JSON.stringify, and SSE encoding. packages/tui/src/context/sdk.tsxdecodes and dispatches every event and logs every durable event.packages/tui/src/context/data.tsxprojects 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
- A TUI subscribes only to the locations/workspaces/sessions it needs.
- A session event is delivered once per interested client, not once per connected process.
- Opening unrelated TUIs does not materially increase CPU for an active session.
- Event payloads are bounded independently of tool output and attachment size.
- Large data is fetched by identifier only by interested clients.
- One mounted client owns exactly one active stream; reconnect replaces rather than overlaps it.
- Closing or failing a client stream promptly releases the server response, listener, and queue.
- 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/resultpayloads with bounded completion metadata and identifiers. - Remove inline base64 attachment data from
session.input.admittedevents. - 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.liveBoundedlistener 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.
Activity
kitlangton commentedon Jul 19, 2026
Reproduced this on
0.0.0-next-15834with one healthy, version-matched managed service, nine TUI clients, and eight running sessions. This is a concrete current incident rather than a synthetic benchmark:topsamplestopnettopsnapshotsThe clients were connected from unrelated locations, but the older clients had each received about 209 MiB from the service since its 12:56 launch. A newer client had already received 106 MiB.
session.tool.successandsession.tool.progressdominated the rolling payload volume; one recent success event was 684 KiB. The client receive counters advanced by the same amount even for TUIs whose displayed session was unrelated.The current
origin/v2source matches the measurement.ClientProvideropens unscopedapi.event.subscribe()and dispatches every event (packages/tui/src/context/client.tsx:67-93).DataProvider.handleEventhas no location/session interest gate (packages/tui/src/context/data.tsx:296-884), andmessage.updatecreatesdraft[sessionID]for event-owned sessions even when that session was never hydrated in this TUI. This means every client can accumulate and reactively mutate foreign-session message state.EventFeednow encodes each event once, so the issue body's per-subscriber schema-encoding point is no longer current, but queueing, SSE writes, client parsing/dispatch, and projection are still multiplied by the number of TUIs.The saved-session count is therefore not the direct cause of the continuous load. The immediate multiplier is concurrently active sessions times globally subscribed TUIs; the large database separately worsens cold reads/startup. A useful regression should assert that an event for session A neither changes session B's transport byte counter nor creates a session A message-store entry in a client interested only in B, while still supporting explicitly followed cross-location child sessions.
kitlangton commentedon Jul 19, 2026
The production follow-up has separated four additional failure boundaries from the global transport fan-out in this issue:
ProjectCopy.refreshtimer remained incomplete for another 48.1 seconds with no changes, but current evidence cannot distinguish refresh work from scheduler starvation by another synchronous post-boot task.service restartissued while the incumbent was unresponsive silently reused the same PID after it recovered becauseService.stoptreats a failed health probe as no existing service.I am filing these as focused issues and will link them here rather than mixing cold-start and lifecycle acceptance criteria into the scoped-event implementation.
kitlangton commentedon Jul 20, 2026
A live
0.0.0-next-15839snapshot shows this is an active-runtime amplification problem, not only a cold-start or historical-database problem. Across the managed server and connected OpenCode clients, the sample accounted for about 13 GiB RSS and 457% CPU while system CPU was fully occupied. The managed health endpoint still returned healthy, but took 1.78 seconds. No process was restarted or terminated for the measurement.The newest 10,000 durable events covered about 22 minutes and 23 MiB. Tool success/progress dominated. The largest recent success was 2.43 MiB and came from
webfetch; itsstructured.outputalone was 2.22 MiB. In the 200 largest recent tool events,webfetchaccounted for 4.55 MiB, with 3.57 MiB instructuredand another 0.97 MiB incontent.readandgrepshowed the same duplication pattern.ToolOutputStore.boundbounds model-facingcontentbut preserves the fullstructuredvalue, so large structured output bypasses the practical event budget.With many TUIs open, the process-global event feed turns this into N-way JSON decode and store projection. This reinforces the landing order: scope delivery by client interest first, then enforce a complete encoded-event budget and store one full blob/reference rather than retaining the same body in both
structuredandcontent. The blob path must remain replay/export aware.kitlangton commentedon Jul 20, 2026
Design options
There are three reasonable levels of response to oversized tool results:
read,grep, andglob, where the model can request another page.ToolOutputStoreapproach so the complete result is written once and the durable event contains only a preview, byte count, truncation metadata, and managed path. This preserves output, but raw paths are weak durable identities: export/copy must discover them, cleanup can invalidate replay, and paths are not portable across placement.Recommendation
Use a content-addressed artifact, with hard truncation as the failure fallback. The durable result should look conceptually like:
The important invariant is: complete output is written once; every routine path carries only a bounded preview and stable reference.
structuredmust be included in the same budget ascontent, not retained as an unbounded second copy. A blob write failure must never fall back to publishing the original oversized value; return an explicit lossy preview or fail settlement instead.Policies can differ by output kind:
read,grep, andglob: bound at the producer and return continuation/page information. Do not archive an entire reproducible source by default.Also enforce two independent budgets:
Progress should be ephemeral, throttled/coalesced, and delta-based. Cap each delta independently, track total bytes separately, and persist only one bounded terminal result. Repeated cumulative progress snapshots should not become durable history.
Replay should use the preview/reference directly and must not eagerly hydrate the full artifact into the next model request or every TUI. Artifact fetches should be interest-gated to the visible Session/tool call, with monotonic hydration so late reads cannot overwrite newer progress or settlement. Export/database copy must include referenced artifacts; a missing artifact should project an explicit unavailable marker rather than fail replay.
Suggested landing order
structuredbypass.This keeps the immediate safety fix small while avoiding a truncation-only format that would need another durable-event migration later.