From 314f1930903e3e547788668caf6235a2874109d9 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 22 Oct 2026 16:03:16 -0500 Subject: [PATCH 1/3] fix(realtime): redial backoff resets on the node's first frame; wake on online/visible A socket accepted then dropped before any frame reset the curve every time, so the tab redialled every ~0.5s. The reset now waits for the node's first frame. The page socket also cuts a local backoff short on `online` and a visible `visibilitychange` (LiveClient.wake()); a node-assigned drain slot is kept so deploy herds stay spread. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++- .../realtime/src/client-reconnect.test.ts | 13 +++- packages/realtime/src/client-wake.test.ts | 67 +++++++++++++++++++ packages/realtime/src/client.ts | 26 ++++++- packages/realtime/src/page-socket.ts | 8 +++ 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 packages/realtime/src/client-wake.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6954c097a..b3d945001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,16 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major ## [Unreleased] -Nothing yet. +### Fixed + +- **The page socket's redial curve resets on the node's first frame, not on the open.** A proxy or + an overloaded node that accepts the upgrade and drops it before saying a word reset `#attempt` + every time, so the tab redialled every ~0.5 s for as long as that lasted instead of backing off to + `BROWSER_RECONNECT_MAX_MS`. Any frame from the node now proves the socket carries. +- **A tab that comes back online or visible dials at once.** The page socket listens for `online` + and a visible `visibilitychange` and cuts a LOCAL backoff short (`LiveClient.wake()`), so a laptop + waking from sleep is live again before its armed wait runs out. A node-assigned drain slot is kept, + so a deploy's herd stays spread. ## 27.14.1 - 2026-10-22 diff --git a/packages/realtime/src/client-reconnect.test.ts b/packages/realtime/src/client-reconnect.test.ts index db23838c9..f9356848f 100644 --- a/packages/realtime/src/client-reconnect.test.ts +++ b/packages/realtime/src/client-reconnect.test.ts @@ -19,6 +19,14 @@ import type { JsonObject, Row } from './json'; import { PROTOCOL_VERSION } from './sync-protocol'; import { BROWSER_RECONNECT_MAX_MS } from './thundering-herd'; +const nodeHello = { + type: 'hello', + v: PROTOCOL_VERSION, + buildId: 'b', + sessionId: 's1', + actorId: null, +} as const; + describe('LiveClient reconnect', () => { test('a dropped socket arms a timer that actually dials again', () => { const { client, timers, sockets } = harness(); @@ -80,9 +88,10 @@ describe('LiveClient reconnect', () => { expect(timers.delays).toEqual([500, 1000, 2000]); timers.fire(); - sockets[3]?.open(); // this one lands + sockets[3]?.open(); // this one lands, and the node answers on it + sockets[3]?.deliver(nodeHello); sockets[3]?.disconnect(); - expect(timers.delays.at(-1)).toBe(500); // attempt counter reset on open + expect(timers.delays.at(-1)).toBe(500); // attempt counter reset on the node's first frame }); test('the whole subscription set is re-established on the automatic reconnect', () => { diff --git a/packages/realtime/src/client-wake.test.ts b/packages/realtime/src/client-wake.test.ts new file mode 100644 index 000000000..9bd025022 --- /dev/null +++ b/packages/realtime/src/client-wake.test.ts @@ -0,0 +1,67 @@ +// The two ways a redial loses time: a socket that opens and is dropped before the node says a +// word (a proxy, an overloaded node) resetting the backoff, and a tab that came back online or +// visible sitting out a wait armed while it was gone. `wake()` is the second's cure. + +import { describe, expect, test } from 'bun:test'; +import { harness } from './client-harness-fixture'; +import { PROTOCOL_VERSION } from './sync-protocol'; + +describe('LiveClient backoff reset and wake', () => { + test('a socket that opens and drops before any frame keeps backing off', () => { + const { client, timers, sockets } = harness(); + client.connect(); + sockets[0]?.open(); + sockets[0]?.disconnect(); + for (let dial = 1; dial <= 3; dial++) { + timers.fire(); + sockets[dial]?.open(); // accepted, then dropped with no word from the node + sockets[dial]?.disconnect(); + } + expect(timers.delays).toEqual([500, 1000, 2000, 4000]); + }); + + test('wake() dials at once instead of sitting out a local backoff, and restarts the curve', () => { + const { client, timers, sockets } = harness(); + client.connect(); + sockets[0]?.open(); + sockets[0]?.disconnect(); + timers.fire(); + sockets[1]?.disconnect(); + expect(timers.pending).toBe(1000); + + client.wake(); + expect(sockets).toHaveLength(3); + expect(timers.pending).toBeNull(); + sockets[2]?.disconnect(); + expect(timers.delays.at(-1)).toBe(500); + }); + + test('wake() keeps a node-assigned drain slot, so a deploy herd stays spread', () => { + const { client, timers, sockets } = harness(); + client.connect(); + sockets[0]?.open(); + sockets[0]?.deliver({ + type: 'reconnect', + v: PROTOCOL_VERSION, + afterMs: 7_777, + reason: 'drain', + }); + + client.wake(); + expect(sockets).toHaveLength(1); + expect(timers.pending).toBe(7_777); + }); + + test('wake() is a no-op while connected, and after close()', () => { + const { client, timers, sockets } = harness(); + client.connect(); + sockets[0]?.open(); + client.wake(); + expect(sockets).toHaveLength(1); + + client.close(); + client.wake(); + expect(sockets).toHaveLength(1); + expect(timers.pending).toBeNull(); + }); +}); diff --git a/packages/realtime/src/client.ts b/packages/realtime/src/client.ts index 321618778..5a4f8a73d 100644 --- a/packages/realtime/src/client.ts +++ b/packages/realtime/src/client.ts @@ -62,6 +62,8 @@ export class LiveClient { #attempt = 0; /** The armed reconnect's canceller, and the flag for "one timer in flight, the first wins". */ #reconnectTimer: (() => void) | null = null; + /** The armed wait is a node's drain slot: `wake()` leaves it alone, or the herd re-clusters. */ + #armedByNode = false; /** Set by `close()`: an explicit teardown must not be undone by the close it just triggered. */ #closed = false; #connected = false; @@ -157,7 +159,6 @@ export class LiveClient { // and the one handler that had none. A replaced socket opening late would otherwise mark the // live connection up and replay every subscription onto whatever socket is current. if (this.#socket !== socket) return; - this.#attempt = 0; this.#setStatus({ connected: true, reconnectAt: null }); // `hello` announces the connection and nothing else. Each cursor rides its own `subscribe` // frame below, which is the only place resume is decided — sending it here too shipped every @@ -174,6 +175,10 @@ export class LiveClient { // that is still draining bytes would otherwise fold its patches into the live registrations // a second time, over newer state. if (this.#socket !== socket) return; + // The curve resets on the node's first word, not on the open: a proxy or an overloaded node + // that accepts the upgrade and drops it at once would otherwise reset it every time, and the + // tab redialled every half second for as long as that lasted. + this.#attempt = 0; this.#heartbeat.saw(this.#clock.now().getTime()); applyFrame(decode(data), this.#frameTarget); }); @@ -227,6 +232,24 @@ export class LiveClient { this.#offline(); } + /** + * The network or the tab is back (`online`, a tab made visible): dial now instead of sitting out + * the armed backoff. Only a local backoff is cut short — a node's drain slot is how a deploy's + * herd is spread, so it is kept — and only while a reconnect is actually armed. + */ + wake(): void { + if (this.#closed || this.#connected || this.#reconnectTimer === null || this.#armedByNode) + return; + this.#cancelReconnect(); + this.#attempt = 0; + try { + this.connect(); + } catch (error) { + this.#scheduleReconnect(null); + this.#onError(error); + } + } + /** * Subscribe to a live query. The window holds ids; the rows are the page store's, so a record * updated by an HTTP response or another window re-renders here too, with no second copy. @@ -411,6 +434,7 @@ export class LiveClient { // `#attempt` counts reconnects already scheduled, from 0; the wait being armed is the next one. serverDelayMs ?? policyDelay(this.#options.backoff ?? browserBackoff, this.#attempt + 1, rng); this.#attempt += 1; + this.#armedByNode = serverDelayMs !== null; this.#setStatus({ reconnectAt: this.#clock.now().getTime() + delay }); const schedule = this.#options.scheduler ?? timeoutScheduler; this.#reconnectTimer = schedule(() => { diff --git a/packages/realtime/src/page-socket.ts b/packages/realtime/src/page-socket.ts index d828924a7..5221ebd45 100644 --- a/packages/realtime/src/page-socket.ts +++ b/packages/realtime/src/page-socket.ts @@ -66,16 +66,24 @@ export function pageSocket(hook: string): LiveClient { host.rehost(); client.connect(); }; + // The network or the tab came back: dial now, not at the end of a backoff armed while it was gone. + const wake = (): void => { + if (typeof document === 'undefined' || document.visibilityState !== 'hidden') client.wake(); + }; const listens = typeof addEventListener === 'function'; if (listens) { addEventListener('pagehide', bye); addEventListener('pageshow', restored); + addEventListener('online', wake); + if (typeof document !== 'undefined') document.addEventListener('visibilitychange', wake); } teardowns.add(() => { offRescope(); if (listens) { removeEventListener('pagehide', bye); removeEventListener('pageshow', restored); + removeEventListener('online', wake); + if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', wake); } client.close(); host.bye(); From ba94684ea88cde968eadadbc73e5b06f77f8ae1d Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 22 Oct 2026 16:16:04 -0500 Subject: [PATCH 2/3] fix(realtime): cover the page-socket wake; raise /feed by the measured bytes Co-Authored-By: Claude Opus 5.5 --- examples/dummy/apps/web/app/feed/page.tsx | 6 ++++- packages/realtime/src/page-socket.test.ts | 33 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/examples/dummy/apps/web/app/feed/page.tsx b/examples/dummy/apps/web/app/feed/page.tsx index 9fefa24d6..f771eb6e9 100644 --- a/examples/dummy/apps/web/app/feed/page.tsx +++ b/examples/dummy/apps/web/app/feed/page.tsx @@ -124,8 +124,12 @@ export const config = defineRoute({ * page's in-flight writes are answered (`catch-up-hold.ts`, `record-landings.ts`), so a read * that lands before a write's echo never paints one like too many; and an offline tap reaches * the outbox on disk before it paints, so a reload at that instant keeps the write. + * raised 162kb → 163kb (#855). measured: 166,122 B (2026-10-22; CI `x build --target static`), + * against 165,888. why: the page boot's socket redials at once on `online` and a visible + * `visibilitychange` (`LiveClient.wake()`), so a tab back from sleep is live before its armed + * backoff runs out, and its curve resets on the node's first frame rather than on the open. */ - budget: { js: '162kb' }, + budget: { js: '163kb' }, /** The badge's count is a read, so it is resolved here — the only place this page fetches. */ load: () => memberQueries.feedActivity({ orgId: useActor().orgId }), meta: ({ t }) => ({ title: t('app.feed.metaTitle'), robots: { index: false } }), diff --git a/packages/realtime/src/page-socket.test.ts b/packages/realtime/src/page-socket.test.ts index ae7738654..1d5dd64fa 100644 --- a/packages/realtime/src/page-socket.test.ts +++ b/packages/realtime/src/page-socket.test.ts @@ -135,6 +135,39 @@ describe('pageSocket', () => { expect(client.connected).toBe(true); }); + // A laptop waking from sleep, or a tab brought back, must not sit out a backoff armed while it + // was gone: `online` and a visible `visibilitychange` dial at once; a hidden one does not. + test('online, and the tab made visible, redial at once; a hidden tab waits', async () => { + const { hosts, openHost } = countedHosts(); + resetPageSocket({ openHost }); + const doc = Object.assign(new EventTarget(), { visibilityState: 'hidden' }); + const had = Reflect.get(globalThis, 'document'); + Reflect.set(globalThis, 'document', doc); + try { + browserPage(TARGET); + const client = pageSocket('useConnection'); + await microtasks(); + const sockets = (): FakeSocket[] => hosts[0]?.sockets ?? []; + sockets()[0]?.open(); + sockets()[0]?.close(1006); + expect(client.connected).toBe(false); + + doc.dispatchEvent(new Event('visibilitychange')); + expect(sockets()).toHaveLength(1); // still hidden: the armed backoff stands + + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + expect(sockets()).toHaveLength(2); + + sockets()[1]?.close(1006); + dispatchEvent(new Event('online')); + expect(sockets()).toHaveLength(3); + } finally { + resetPageSocket(); + Reflect.set(globalThis, 'document', had); + } + }); + test('a new principal leaves the old host and dials exactly once on a host of its own', async () => { const { hosts, openHost } = countedHosts(); resetPageSocket({ openHost }); From 3af66c2bd334df70e00825643c934636d92d1c3f Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 22 Oct 2026 16:21:39 -0500 Subject: [PATCH 3/3] chore(dummy): x manifest after the /feed budget raise Co-Authored-By: Claude Opus 5.5 --- examples/dummy/x.manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/dummy/x.manifest.json b/examples/dummy/x.manifest.json index 29b6a5f07..0bcb2adb2 100644 --- a/examples/dummy/x.manifest.json +++ b/examples/dummy/x.manifest.json @@ -1,6 +1,6 @@ { "manifestVersion": 1, - "buildId": "92080ba34442de8b", + "buildId": "30db2d2ee4f00d67", "app": { "name": "@postly/root", "version": "0.0.1" @@ -431,7 +431,7 @@ "revalidateTags": [], "surface": "app", "budget": { - "js": "162kb" + "js": "163kb" } }, {