fix(paddock): reload stale tabs, honour 429s, and resume streams instead of replaying - #69
Merged
Merged
Conversation
…ead of replaying After #67 deployed (04:56Z) the account-wide list calls fell 14.5/s -> 1.1/s, but one browser tab still running the OLD bundle went into a hotter loop: 3,277 stream opens on one conversation and 4,319 receipt reads on one sandbox in five minutes, events?limit=1000 at ~7/s, and Fountain answering 429 rate_limited with Retry-After up to 35 s. The old bundle's loop was: poll -> new array -> new Tab object -> stream effect re-runs -> reopen with no Last-Event-ID -> replay the 40-turn history -> each replayed turn end re-reads the receipt and the strip. It ran at ~2/s before only because every refresh paid a 200-500 ms unfiltered list on the server; the 5 s memo made that instant, so the same loop spun 5-10x faster. And `backoff` was a local of the effect, reset to 1 s on every re-run, so the 429s slowed nothing. Four changes, each of which stands alone: 1. Stale-bundle self-heal. The server's build id is the hash of the built index.html (so a server-only change reloads no tab). It is stamped on every response as x-paddock-build, written into the served HTML as <meta name="paddock-build">, and exposed on /api/config. The bundle reads its own from the meta tag (not from the first API answer, which a deploy between the HTML and that call would poison) and reloads once on the first mismatch (src/lib/build.ts). The strip poll refuses a request whose header does not name the current build (409 stale_client): a current tab reloads on the mismatch anyway; an old tab, which knows nothing of builds, logs a failed poll and keeps the strip it has -- and stops, because its loop only turned on a *successful* poll. That is the lever over the tab looping now. 2. Honour 429. src/lib/hold.ts is one shared pause: any 429 sets it to Retry-After (default 15 s) plus jitter, and the poll, the receipt read, the scrollback fetch and the stream reconnect all wait on it. The stream open is now made by the client itself so a non-200 reaches the caller as an ApiError with status and Retry-After (the suite's readSse reported it as "the stream closed"); the read loop is the suite's, verbatim. 3. The tail is a state machine (src/lib/tail.ts), not an effect. Last-Event-ID is kept per conversation across reconnects; the backoff resets only when a stream actually opens (onOpen fires only after a 200); a 429 waits what the server asked. App.tsx's effect depends on [client, activeId, hold] and reads its three callbacks through a ref, so no state change can re-run it. Tests pin: a turn-ended stage event does not reopen the stream and reports once; the second open carries the id the first saw; backoff 1/2/4 s and reset on open only; a 429 waits 35 s; stop means stop. 4. Bounded replay. The catch-up read on open passes after=<newest id held>, so a reconnect fetches the gap, not limit=1000 pages of history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk
jhgaylor
added a commit
that referenced
this pull request
Sep 7, 2026
… unstamped (#70) #69 stamped the build id only on the SPA fallback path. `/` maps straight to `index.html` and was served as a bare file, so every tab loaded from the root -- which is every tab -- carried no <meta name="paddock-build">, sent no x-paddock-build header, and was turned away from the strip with 409 stale_client by the very check meant for OLD bundles. Verified in production after the roll: the header and /api/config named the build, the HTML did not. - serveStatic routes `/` and `/index.html` through the stamped shell like every client-side route; the test now covers all three paths and fails without the fix. - A tab whose HTML carried no stamp adopts the first build a response names and sends it from then on, so a current bundle can never be permanently refused by a server that stamps. The cost is the sub-second window between the HTML and its first API answer, in which a deploy would leave that tab not reloading until the deploy after -- far cheaper than the failure this guards against. Claude-Session: https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.
Why
After #67 deployed (04:56Z) the account-wide list calls fell from 14.5/s to 1.1/s, but one browser tab still running the old bundle went into a hotter loop. In five minutes on Fountain: 3,277 stream opens on one conversation, 4,319 receipt reads on one sandbox,
events?limit=1000at ~7/s, and Fountain answering 429rate_limitedwithRetry-Afterup to 35 s. Rates: stream 9/s, file 20/s, events 6.6/s (from 2/s, 4/s, 1.5/s before the deploy).Mechanism (verified against the old bundle's code)
The old
App.tsxkeyed the stream effect on theTabobject and keptlastEventIdandbackoffas locals of that effect. Every 4 s poll produced a new array, a newTab, a re-run: the stream reopened with noLast-Event-ID, Fountain replayed the 40-turn history, every replayed turn end calledrefreshConversations()andreadReceipt(), and the backoff went back to 1 s so the 429s slowed nothing. It ran at ~2/s before only because each refresh paid a 200-500 ms unfiltered list on the server; the 5 s memo from #67 made that instant, so the same loop spun 5-10x faster.The new bundle's callbacks (
refreshConversationson[client, paddockId],readReceipton[client, boxId]whereboxIdis a string,loadEventson[client]) are already stable; this PR removes the dependency on that being true.What changed
index.html, so a server-only change reloads no tab. It is stamped on every response asx-paddock-build, written into the served HTML as<meta name="paddock-build">, and exposed on/api/config. The bundle reads its own id from the meta tag (not from the first API answer, which a deploy between the HTML and that call would poison) and reloads once on the first mismatch (src/lib/build.ts). The strip poll refuses a request whose header does not name the current build (409stale_client); every other route still serves it. A current tab reloads on the mismatch anyway. An old tab, which knows nothing of builds, logs a failed poll and keeps the strip it has, and stops, because its loop only turned on a successful poll. That is the lever the server has over the tab looping right now.src/lib/hold.tsis one shared pause: any 429 sets it toRetry-After(default 15 s) plus jitter, and the poll, the receipt read, the scrollback fetch and the stream reconnect all wait on it. The stream open is now made by the client itself so a non-200 reaches the caller as anApiErrorcarrying status andRetry-After(the suite'sreadSsereported it as "the stream closed"); the read loop is the suite's, verbatim.src/lib/tail.ts), not an effect body.Last-Event-IDis kept per conversation across reconnects; the backoff resets only when a stream actually opens (onOpenfires only after a 200); a 429 waits what the server asked. TheApp.tsxeffect depends on[client, activeId, hold]and reads its callbacks through a ref.after=<newest id held>, so a reconnect fetches the gap, notlimit=1000pages of history.Tests
src/lib/tail.test.ts: a turn-ended stage event does not reopen the stream and is reported once; the second open carries the id the first saw and catches up from it; backoff 1/2/4 s and reset only on a real open; a 429 waits 35 s not the backoff; stop means stop.src/lib/hold.test.ts,src/lib/build.test.ts: the pause and the reload-once rules.server/app.test.ts: every answer names the build (refusals included) and/api/configexposes it; a stale or missing header is turned away from the strip and nowhere else; an unstamped server turns nobody away; the served HTML carries the meta tag.221 pass,
tsc --noEmitandbun run buildgreen locally.After deploy
Watch
sum by (route) (rate(phoenix_router_dispatch_stop_count{namespace="fountain"}[1m]))for the stream, file and events routes to fall, and paddock's logs for the 429s to stop. The looping tab stops on its next strip poll (it will show a stale strip until somebody reloads it); every tab thereafter reloads itself on the next deploy.🤖 Generated with Claude Code
https://claude.ai/code/session_018R1XyyWd9MDMYXqFL71owk