Last 12 weeks · 0 commits
2 of 6 standards met
Each reconnect creates a new listener UUID and adds it to the producer’s array. When a consumer disconnects, it only unsubscribes from its Redis chunk channel; the producer is never told to remove that listener ID. As a result, every future chunk is published to every listener ever registered during that stream, including disconnected listeners. Example: if a user disconnects and reconnects five times, the producer may issue five commands per chunk even though only the latest listener is active. For 500 remaining chunks, that becomes 2,500 publishes instead of 500. The producer should remove inactive listener IDs while preserving support for multiple genuinely active consumers.
Problem When the source stream finishes, the producer immediately sets the sentinel to and unsubscribes from the request channel. After that, any call to returns , even though the producer process is still alive and holds the complete chunk buffer in memory. This creates a blind spot in any application that does post-stream work (database writes, analytics, cleanup) after the source stream ends but before the process exits. A client that disconnects and reconnects during that window gets from , even though the producer could serve the full replay. Timeline of the problem The gap between and is the problem window. The library treats the stream as dead at , but the producer is alive until . What the client actually needs The complete chunk buffer (including ) that the producer already holds in memory at . The data is there. the library just blocks access to it. Existing code that almost works The subscribe callback in (runtime.ts:145-165) already handles the case: When , a resume request arriving via pub/sub receives the full chunk buffer replayed from memory, followed by . The client would see the complete stream as if it had been connected the whole time. This logic is already written and correct. It just never runs after the source ends because of the two lines that fire simultaneously (runtime.ts:181-195): 1. . causes (runtime.ts:117-124) to short-circuit to at the sentinel check, before attempting the pub/sub path. 2. . removes the callback entirely, so even if a resume request somehow bypassed the sentinel, no one would answer. Proposed change Defer the sentinel update and unsubscribe so the producer can continue serving resume requests from its in-memory chunk buffer for as long as the process is alive and the Redis clients are connected. Option A: expose a function to the caller When the source stream ends, only set (which already controls the logic in the callback). Do NOT set sentinel to and do NOT unsubscribe. Instead, return (or expose via the context) a function that the caller invokes when the process is ready to shut down: The caller controls when the producer goes dark. The library already has a mechanism that could be wired to this. could be deferred until is called instead of resolving when the source ends. Important: (line 196) currently resolves when the source ends, which signals that the work is done. If cleanup is deferred, must also be deferred — otherwise serverless runtimes may kill the process before runs, defeating the purpose. Pros: caller has full control over the producer's lifetime. No new states. Cons: requires the caller to call explicitly. If they don't, the subscription leaks until Redis client close or TTL expiry (the sentinel TTL already acts as a safety net). Option B: introduce an intermediate sentinel state Instead of going directly from the initial value () to , introduce a third state (e.g. ) that means "source has ended, but the producer may still be warm." When the source stream ends: Set sentinel to (instead of ). Keep the request channel subscription alive. In : → stream never existed → return . → stream finished but producer may be warm → attempt pub/sub resume via the existing function. If the 1-second ack timeout fires (producer is dead), fall back to returning . Note: the timeout handler (runtime.ts:265-275) currently only special-cases to resolve ; a sentinel would fall through to the error. The timeout handler must be updated to treat the same as (resolve ). → producer is fully gone → return immediately (current behavior). The sentinel transitions to when: The caller explicitly calls , OR The sentinel's Redis TTL expires (safety net. existing 24h TTL works here). Pros: backward-compatible for callers that don't know about the new state. Falls back cleanly via the ack timeout when the producer is dead. No new API surface required. Cons: adds a small latency (the ack timeout duration) on the path when the producer is truly gone and the sentinel is . Option C: always attempt pub/sub before returning null on The simplest option: when sees sentinel === , instead of returning immediately, attempt the pub/sub path with the existing 1-second timeout. If the producer is still alive and subscribed, it responds with buffered chunks. If not, the timeout fires and we return as before. This requires: NOT unsubscribing from the request channel when the source ends (the subscription stays alive until the Redis client closes). Changing to try pub/sub even when sentinel is . Pros: smallest diff. No new states, no new API. Falls back via the existing ack timeout. Cons: every resume pays the 1-second timeout cost when the producer is truly dead. May not be acceptable if resume is called frequently (e.g. client retry loops). Expected behavior after the change Summary In any environment where the producer process outlives the HTTP response (serverless functions, long-running servers with deferred writes, background job processors), there is a window between "stream finished" and "process shutdown" where the producer holds authoritative data that no other system has yet. The current design discards access to that data the instant the source stream ends. The practical consequence is a race condition: the client reconnects before the producer's database write commits, gets from , fetches from the database, and sees stale data. The data the client needs is sitting in the producer's memory. the library just won't serve it. Keeping the producer's pub/sub subscription alive for the full lifetime of the process (not just the lifetime of the source stream) eliminates this race by serving data from the authoritative source (the producer's in-memory buffer) instead of the eventually-consistent replica (the database).
Problem The DONE control message is delivered to resumed streams over Redis pub/sub, which is fire-and-forget. If that single message is lost — subscriber connection dropped and reconnected at the wrong moment, or the producer died between writing the DONE sentinel and publishing — a resumed stream stays open forever, even though: every content chunk was already delivered, and the durable sentinel key already says . We hit this in production behind an AI SDK chat UI: the answer rendered fully, but the SSE response never closed, so the client stayed in state indefinitely (the AI SDK only leaves when the connection closes). A page refresh recovered — reads the sentinel and returns — which shows the durable state was correct; only the live consumer never re-checks it. Related but distinct from #42 / #43, which cover the initial ack phase. This is about a consumer that attached successfully and then never learns the stream ended. Fix Add a watchdog to : periodically re-read the durable sentinel and close the stream once the producer is finished or the sentinel expired. Closes only after two consecutive DONE/missing observations, so in-flight tail messages get a full interval to drain before we give up on them. Interval configurable via a new context option (default 10 000 ms) — one per interval per attached consumer. Watchdog is cleared on every existing cleanup path (DONE received, ack timeout, enqueue failure). A failed sentinel read (Redis briefly unavailable) is swallowed and retried next tick — it must not kill a healthy stream. Test New test in using the in-memory pub/sub: a wrapper publisher drops the DONE control message (durable still goes through), a resumed consumer reads the stream. Without the watchdog the test hangs forever; with it, the consumer receives all chunks and closes within two intervals.
Repository: vercel/resumable-stream. Description: Stream resumption for web streams Stars: 566, Forks: 42. Primary language: TypeScript. Languages: TypeScript (95.6%), Shell (4.4%). License: MIT. Homepage: https://sdk.vercel.ai Open PRs: 8, open issues: 12. Last activity: 4mo ago. Community health: 50%. Top contributors: cramforce, ethshea, mattpocock, MaxLeiter, ghislainf, lgrammel, MrLoh, matanper, 634750802.