Skip to content

fix(paddock): reload stale tabs, honour 429s, and resume streams instead of replaying - #69

Merged
jhgaylor merged 1 commit into
mainfrom
fix/paddock-stale-tabs
Sep 7, 2026
Merged

fix(paddock): reload stale tabs, honour 429s, and resume streams instead of replaying#69
jhgaylor merged 1 commit into
mainfrom
fix/paddock-stale-tabs

Conversation

@jhgaylor

@jhgaylor jhgaylor commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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=1000 at ~7/s, and Fountain answering 429 rate_limited with Retry-After up 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.tsx keyed the stream effect on the Tab object and kept lastEventId and backoff as locals of that effect. Every 4 s poll produced a new array, a new Tab, a re-run: the stream reopened with no Last-Event-ID, Fountain replayed the 40-turn history, every replayed turn end called refreshConversations() and readReceipt(), 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 (refreshConversations on [client, paddockId], readReceipt on [client, boxId] where boxId is a string, loadEvents on [client]) are already stable; this PR removes the dependency on that being true.

What changed

  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 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 (409 stale_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.
  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 carrying 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 body. 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. The App.tsx effect depends on [client, activeId, hold] and reads its callbacks through a ref.
  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.

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/config exposes 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 --noEmit and bun run build green 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

…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
jhgaylor merged commit c67f784 into main Sep 7, 2026
2 checks passed
@jhgaylor
jhgaylor deleted the fix/paddock-stale-tabs branch September 7, 2026 05:16
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant