#355·osiris

[Medium] SDK SSE stream leaks timers on disconnect and silently truncates the entity feed at 500

Author: jsawyerdevCreated Sep 13, 2026Updated Sep 15, 2026
LabelsTriage

Severity: Medium (resource leak + silent data loss) Confidence: Confirmed by executing the actual SSE stream handler with a fake timer/controller harness. Audited commit: bd4057567de8ee18d49a8b2744c9746bcd67f3e0 (master, 2026-09-13 audit).

GET /api/sdk/stream has two related defects: it hard-caps the pushed entity list at 500 with no indication to the client that data was omitted, and it has no cancel() handler on the ReadableStream, so its setInterval timers keep running (and keep doing work) after a client disconnects, until an unrelated future push happens to throw.

Evidence and mechanism

  • src/app/api/sdk/stream/route.ts:66: Array.from(globalForSDK.sdkEntityStore.values()).slice(0, 500) — anything beyond the 500th entity (by Map insertion order, not priority) is silently dropped from every push.
  • Lines 29-80: the ReadableStream passed to the Response defines start() with two setInterval timers (heartbeat every 15s, entity push every 5s) but no cancel() — the standard hook the stream runtime calls when the client disconnects or the reader is cancelled. The timers are only cleared reactively, inside their own catch blocks, when a future controller.enqueue() happens to throw.

Reproduction and observed result

Executed the actual route with 501 synthetic entities in the shared store and fake timers substituted for setInterval/clearInterval:

  • Pushing an update: the emitted entity_update event's payload has exactly 500 entries and never includes entity-500 (the 501st), with no field indicating truncation occurred.
  • After calling reader.cancel() on the stream, both timers (heartbeat and entity-push) were still registered (timersAfterCancel: 2); it took three more forced timer cycles before both were actually cleared (timersAfterThreeTimerCycles: 1, i.e. the fix only propagates lazily as a side effect of a later failed enqueue).

Impact

An operational feed pushing more than 500 tracked entities permanently hides everything past the 500th from every connected map client, with no signal that the picture is incomplete. Separately, every disconnected/refreshed browser tab leaves its heartbeat and entity-push intervals running server-side for an indeterminate extra period, which scales with concurrent client churn.

Smallest correction and acceptance criteria

  • Add a cancel() handler to the ReadableStream that clears both intervals immediately on disconnect, rather than relying on a future enqueue to fail.
  • Either raise/remove the 500-entity cap or include an explicit truncated/totalCount field in the entity_update payload so clients know more data exists.
  • Acceptance: cancelling the stream's reader must clear both timers before the next scheduled tick; a store with more than 500 entities must report its true total count alongside the (possibly capped) payload.

Prior-issue check: no existing issue covers the SDK SSE stream's cleanup or truncation behavior.