[Medium] SDK SSE stream leaks timers on disconnect and silently truncates the entity feed at 500
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
ReadableStreampassed to theResponsedefinesstart()with twosetIntervaltimers (heartbeat every 15s, entity push every 5s) but nocancel()— the standard hook the stream runtime calls when the client disconnects or the reader is cancelled. The timers are only cleared reactively, inside their owncatchblocks, when a futurecontroller.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_updateevent's payload has exactly 500 entries and never includesentity-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 theReadableStreamthat 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/totalCountfield in theentity_updatepayload 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.
Source: simplifaisoul/osiris