fix: flush replayed stream state and handle orphaned streams after hibernation#989
Merged
threepointone merged 1 commit intomainfrom Feb 26, 2026
Merged
Conversation
🦋 Changeset detectedLatest commit: aec54c0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
…bernation - Send replayComplete signal after replaying stored chunks for live streams so the client flushes accumulated parts to React state immediately. - Detect orphaned streams (restored from SQLite after hibernation with no live LLM reader) via _isLive flag on ResumableStream. On reconnect, send done:true, complete the stream, and persist the partial assistant message. - Client: flush activeStreamRef on replayComplete (keeps stream alive) and on done during replay (finalizes orphaned streams). - Add server tests for replayComplete, orphaned streams, edge cases (zero chunks, tool call parts, concurrent ACKs). - Add React hook tests for client-side flush behavior. Fixes #896
38941e4 to
aec54c0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a client reconnects to an active stream,
replayChunks()sends all stored chunks from SQLite but the client UI never updates until the next live chunk arrives from the LLM. This creates a jarring UX where the user sees a blank assistant message despite the server having already produced content.A second, more severe problem: when a Durable Object hibernates during an active stream, the
ReadableStreamreader from the LLM is lost permanently. On wake, the stream appears active in SQLite but no live chunks will ever arrive — a "dead stream" that leaves the client stuck with a loading indicator forever.Fixes #896
Root Cause
Bug 1: Replay chunks not flushed to React state
The client-side
useAgentChathook skipsflushActiveStreamToMessages()for replay chunks (by design — to avoid intermediate renders during the tight replay loop). However, after all replay chunks were sent, the server never signaled "replay is done but the stream is still live." The accumulated parts sat inactiveStreamRefunflushed until the next live chunk arrived from the LLM.Bug 2: Orphaned streams after hibernation
When a DO is evicted and later wakes, the constructor calls
ResumableStream.restore()which reads the active stream from SQLite. But theReadableStreamreader that was consuming the LLM response is gone — it existed only in the previous instance's memory. The stream looks active but will never produce another chunk.Solution
Server:
replayCompletesignal for live streamsAfter
replayChunks()sends all stored chunks for a stream that is still live (has an active LLM reader), it now sends a final message withreplayComplete: true. This tells the client: "I've sent everything I have stored — flush your accumulated state to the UI. More live chunks may follow."Server:
_isLiveflag + orphaned stream detectionResumableStreamnow tracks whether the active stream wasstart()-ed in the current instance (_isLive = true) vs restored from SQLite after hibernation (_isLive = false). WhenreplayChunks()detects an orphaned stream:done: trueto the client (stream is over)completedin SQLitestreamIdto the callerThe caller (
AIChatAgent) then reconstructs the partial assistant message from stored chunks usingapplyChunkToParts()and persists it viapersistMessages(), so it survives further page refreshes.Client: flush on
replayCompleteanddoneIn
react.tsx, theCF_AGENT_USE_CHAT_RESPONSEhandler now:replayComplete: flushesactiveStreamRefto React state but keeps it alive (live chunks continue appending)doneduring replay: flushes and nulls outactiveStreamRef(stream is finalized)Edge Cases Handled
applyChunkToPartsreconstructs both text and tool-invocation parts correctlyhasActiveStream()returns false on second ACK → no-op, no duplicate messagesstartchunk missing (row-size guard dropped it)Files Changed
src/types.tsreplayCompletefield toCF_AGENT_USE_CHAT_RESPONSEsrc/resumable-stream.ts_isLiveflag,isLivegetter; rewritereplayChunkswith three-way branchsrc/index.tsreplayChunksreturn value; new_persistOrphanedStreammethodsrc/react.tsxreplayComplete(keep stream alive) anddoneduring replay (finalize)src/tests/worker.tstestSimulateHibernationWakehelpersrc/tests/resumable-streaming.test.tssrc/react-tests/use-agent-chat.test.tsxReviewer Notes
_isLiveintentionally defaults tofalse—restore()is called in the constructor and leaves it false. Onlystart()sets it totrue. This means any stream that exists in SQLite when the DO wakes is treated as orphaned unlessstart()was called in the current instance.replayChunksreturn type changed fromvoidtostring | null— internal-only API, not in public exports. The returnedstreamIdis used by the caller to know it should persist the orphaned message.testSimulateHibernationWakereplaces_resumableStreambut not other instance fields (_streamCompletionPromise,_pendingResumeConnections). In a real hibernation wake the entire DO is reconstructed. This is sufficient for testing because those fields are only relevant for live streams, and the test uses fresh WebSocket connections.The
replayCompletemessage hasdone: false— this is intentional. The stream is still active; we're just signaling that stored chunks have been replayed. The client keepsactiveStreamRefalive for subsequent live chunks.All 237 server tests + 26 React tests pass.