From c33df31c3a1d865cec06505a421cd3f45b92afb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 15:34:20 +0000 Subject: [PATCH 1/5] feat: add system wake detection for Bun/Node.js daemon environments When running the Electric client in a Bun daemon (or any non-browser environment), in-flight HTTP long-poll requests hang until OS TCP timeout (~75s on macOS) after a machine sleeps and wakes. The existing visibility-based pause/resume mechanism requires `document` which doesn't exist in Bun/Node.js. This adds timer gap detection: a setInterval runs every 10s, and if the elapsed wall-clock time between ticks exceeds 25s (10s interval + 15s threshold), the system likely slept. On detection, the stale hanging fetch is aborted with a SYSTEM_WAKE reason and the fetch loop immediately restarts with a fresh connection, reducing reconnect time from 30-90s to near-instant. Key changes: - Add SYSTEM_WAKE constant alongside FORCE_DISCONNECT_AND_REFRESH - Add #subscribeToWakeDetection() using timer gap detection (skipped in browser environments where visibilitychange handles this) - Handle SYSTEM_WAKE abort in #requestShape() to restart the loop - Timer is unref'd so it doesn't prevent process exit - Cleanup on unsubscribeAll() https://claude.ai/code/session_01VhBX3nM9TfKSngu9u4C1zB --- packages/typescript-client/src/client.ts | 59 +++++++- packages/typescript-client/src/constants.ts | 1 + .../typescript-client/test/stream.test.ts | 142 +++++++++++++++++- 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index 512378b560..a09149bad7 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -51,6 +51,7 @@ import { REPLICA_PARAM, FORCE_DISCONNECT_AND_REFRESH, PAUSE_STREAM, + SYSTEM_WAKE, EXPERIMENTAL_LIVE_SSE_QUERY_PARAM, LIVE_SSE_QUERY_PARAM, ELECTRIC_PROTOCOL_QUERY_PARAMS, @@ -598,6 +599,7 @@ export class ShapeStream = Row> #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms) #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms) #unsubscribeFromVisibilityChanges?: () => void + #unsubscribeFromWakeDetection?: () => void #staleCacheBuster?: string // Cache buster set when stale CDN response detected, used on retry requests to bypass cache #staleCacheRetryCount = 0 #maxStaleCacheRetries = 3 @@ -666,6 +668,7 @@ export class ShapeStream = Row> this.#fetchClient = createFetchWithConsumedMessages(this.#sseFetchClient) this.#subscribeToVisibilityChanges() + this.#subscribeToWakeDetection() } get shapeHandle() { @@ -785,11 +788,13 @@ export class ShapeStream = Row> resumingFromPause, }) } catch (e) { - // Handle abort error triggered by refresh + // Handle abort error triggered by refresh or system wake if ( (e instanceof FetchError || e instanceof FetchBackoffAbortError) && requestAbortController.signal.aborted && - requestAbortController.signal.reason === FORCE_DISCONNECT_AND_REFRESH + (requestAbortController.signal.reason === + FORCE_DISCONNECT_AND_REFRESH || + requestAbortController.signal.reason === SYSTEM_WAKE) ) { // Start a new request return this.#requestShape() @@ -1432,6 +1437,7 @@ export class ShapeStream = Row> unsubscribeAll(): void { this.#subscribers.clear() this.#unsubscribeFromVisibilityChanges?.() + this.#unsubscribeFromWakeDetection?.() } /** Unix time at which we last synced. Undefined when `isLoading` is true. */ @@ -1566,6 +1572,55 @@ export class ShapeStream = Row> } } + /** + * Detects system wake from sleep using timer gap detection. + * When the system sleeps, setInterval timers are paused. On wake, + * the elapsed wall-clock time since the last tick will be much larger + * than the interval period, indicating the system was asleep. + * + * This is particularly important for non-browser environments (Bun, Node.js) + * where `document.visibilitychange` is not available, and in-flight HTTP + * long-poll requests may hang until the OS TCP timeout (~75s on macOS). + */ + #subscribeToWakeDetection() { + // Skip in browser environments where visibilitychange already handles this + if ( + typeof document === `object` && + typeof document.hidden === `boolean` && + typeof document.addEventListener === `function` + ) { + return + } + + const INTERVAL_MS = 10_000 // Check every 10 seconds + const THRESHOLD_MS = 15_000 // If 15+ extra seconds pass, system likely slept + + let lastTickTime = Date.now() + + const timer = setInterval(() => { + const now = Date.now() + const elapsed = now - lastTickTime + lastTickTime = now + + if (elapsed > INTERVAL_MS + THRESHOLD_MS) { + // System likely woke from sleep — abort the current (probably stale) + // request and restart the fetch loop with a fresh connection + if (this.#state === `active` && this.#requestAbortController) { + this.#requestAbortController.abort(SYSTEM_WAKE) + } + } + }, INTERVAL_MS) + + // Ensure the timer doesn't prevent the process from exiting + if (typeof timer === `object` && `unref` in timer) { + timer.unref() + } + + this.#unsubscribeFromWakeDetection = () => { + clearInterval(timer) + } + } + /** * Resets the state of the stream, optionally with a provided * shape handle diff --git a/packages/typescript-client/src/constants.ts b/packages/typescript-client/src/constants.ts index c143966304..08643002d5 100644 --- a/packages/typescript-client/src/constants.ts +++ b/packages/typescript-client/src/constants.ts @@ -20,6 +20,7 @@ export const EXPERIMENTAL_LIVE_SSE_QUERY_PARAM = `experimental_live_sse` export const LIVE_SSE_QUERY_PARAM = `live_sse` export const FORCE_DISCONNECT_AND_REFRESH = `force-disconnect-and-refresh` export const PAUSE_STREAM = `pause-stream` +export const SYSTEM_WAKE = `system-wake` export const LOG_MODE_QUERY_PARAM = `log` export const SUBSET_PARAM_WHERE = `subset__where` export const SUBSET_PARAM_LIMIT = `subset__limit` diff --git a/packages/typescript-client/test/stream.test.ts b/packages/typescript-client/test/stream.test.ts index db2f34677d..af9ee4b79b 100644 --- a/packages/typescript-client/test/stream.test.ts +++ b/packages/typescript-client/test/stream.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ShapeStream, isChangeMessage, Message, Row } from '../src' import { snakeCamelMapper } from '../src/column-mapper' import { resolveInMacrotask } from './support/test-helpers' @@ -408,3 +408,143 @@ describe(`ShapeStream`, () => { expect(changeMessage!.value).not.toHaveProperty(`created_at`) }) }) + +describe(`Wake detection`, () => { + const shapeUrl = `https://example.com/v1/shape` + let aborter: AbortController + let savedDocument: typeof globalThis.document | undefined + + beforeEach(() => { + aborter = new AbortController() + // Save and remove document to simulate non-browser (Bun/Node.js) environment + savedDocument = globalThis.document + delete (globalThis as Record).document + }) + + afterEach(() => { + aborter.abort() + // Restore document + if (savedDocument !== undefined) { + globalThis.document = savedDocument + } + vi.restoreAllMocks() + }) + + it(`should set up wake detection timer in non-browser environments`, async () => { + const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) + + const fetchWrapper = (): Promise => { + return resolveInMacrotask(Response.error()) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + // unsubscribeAll should clear the wake detection timer + stream.unsubscribeAll() + expect(clearIntervalSpy.mock.calls.length).toBeGreaterThanOrEqual(1) + + unsub() + }) + + it(`should NOT set up wake detection timer in browser environments`, async () => { + // Restore document to simulate browser environment + ;(globalThis as Record).document = { + hidden: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) + + const fetchWrapper = (): Promise => { + return resolveInMacrotask(Response.error()) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + // In browser env, unsubscribeAll should NOT have a wake detection timer to clear + // (only visibility change handler) + stream.unsubscribeAll() + // clearInterval might be called 0 times (no wake timer was set up) + // We verify by checking that no interval was created for wake detection + // The visibility handler uses addEventListener, not setInterval + expect(clearIntervalSpy.mock.calls.length).toBe(0) + + unsub() + }) + + it(`should detect time gap and abort stale fetch after system wake`, async () => { + vi.useFakeTimers() + + const fetchSignals: AbortSignal[] = [] + const fetchEvents = new EventTarget() + let fetchCallCount = 0 + + const fetchWrapper = ( + ...args: Parameters + ): Promise => { + const signal = args[1]?.signal + if (signal) fetchSignals.push(signal) + fetchCallCount++ + fetchEvents.dispatchEvent(new Event(`fetch`)) + // Simulate a hanging long-poll that rejects on abort + return new Promise((_resolve, reject) => { + signal?.addEventListener(`abort`, () => reject(new Error(`aborted`)), { + once: true, + }) + }) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + // Wait for first fetch + await vi.advanceTimersByTimeAsync(0) + expect(fetchCallCount).toBeGreaterThanOrEqual(1) + const initialFetchCount = fetchCallCount + + // Advance one normal interval (10s) — should NOT trigger wake detection + await vi.advanceTimersByTimeAsync(10_001) + expect(fetchSignals[fetchSignals.length - 1]?.aborted).toBe(false) + + // Simulate system sleep: jump Date.now() forward by 30s + // then advance timer to fire the next interval callback + const currentTime = Date.now() + vi.setSystemTime(currentTime + 30_000) // Date.now() jumps 30s + + // Advance just enough to trigger the next interval tick + await vi.advanceTimersByTimeAsync(10_001) + + // The wake detection should have aborted the stale fetch + // and the fetch loop should have restarted with a new fetch + // Give the async restart a moment to trigger + await vi.advanceTimersByTimeAsync(100) + + // Verify the first signal was aborted + expect(fetchSignals[0]?.aborted).toBe(true) + + // Verify new fetches were made (fetch loop restarted) + expect(fetchCallCount).toBeGreaterThan(initialFetchCount) + + unsub() + aborter.abort() + vi.useRealTimers() + }) +}) From f78d2e5c6b543d737140fca853eb82a696bdd70f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 8 Feb 2026 22:11:47 -0700 Subject: [PATCH 2/5] Apply review fixes: move wake tests to node env, force non-live reconnect on wake - Move wake detection tests to dedicated file with @vitest-environment node to fix unreliable globalThis.document deletion under jsdom - Add wake-detection.test.ts to unit test config (no Electric server needed) - Set #isRefreshing before wake abort to ensure non-live request on reconnect - Assert abort reason is specifically SYSTEM_WAKE - Broaden JSDoc to cover SSE and document browser exclusion - Extract #hasBrowserVisibilityAPI() and simplify abort reason check Co-Authored-By: Claude Opus 4.6 --- packages/typescript-client/src/client.ts | 52 ++++--- .../typescript-client/test/stream.test.ts | 142 +----------------- .../test/wake-detection.test.ts | 120 +++++++++++++++ .../typescript-client/vitest.unit.config.ts | 1 + 4 files changed, 149 insertions(+), 166 deletions(-) create mode 100644 packages/typescript-client/test/wake-detection.test.ts diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index a09149bad7..1bd7b42f2c 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -788,15 +788,16 @@ export class ShapeStream = Row> resumingFromPause, }) } catch (e) { - // Handle abort error triggered by refresh or system wake + const abortReason = requestAbortController.signal.reason + const isRestartAbort = + requestAbortController.signal.aborted && + (abortReason === FORCE_DISCONNECT_AND_REFRESH || + abortReason === SYSTEM_WAKE) + if ( (e instanceof FetchError || e instanceof FetchBackoffAbortError) && - requestAbortController.signal.aborted && - (requestAbortController.signal.reason === - FORCE_DISCONNECT_AND_REFRESH || - requestAbortController.signal.reason === SYSTEM_WAKE) + isRestartAbort ) { - // Start a new request return this.#requestShape() } @@ -1549,12 +1550,16 @@ export class ShapeStream = Row> }) } - #subscribeToVisibilityChanges() { - if ( + #hasBrowserVisibilityAPI(): boolean { + return ( typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function` - ) { + ) + } + + #subscribeToVisibilityChanges() { + if (this.#hasBrowserVisibilityAPI()) { const visibilityHandler = () => { if (document.hidden) { this.#pause() @@ -1578,22 +1583,17 @@ export class ShapeStream = Row> * the elapsed wall-clock time since the last tick will be much larger * than the interval period, indicating the system was asleep. * - * This is particularly important for non-browser environments (Bun, Node.js) - * where `document.visibilitychange` is not available, and in-flight HTTP - * long-poll requests may hang until the OS TCP timeout (~75s on macOS). + * Only active in non-browser environments (Bun, Node.js) where + * `document.visibilitychange` is not available. In browsers, + * `#subscribeToVisibilityChanges` handles this instead. Without wake + * detection, in-flight HTTP requests (long-poll or SSE) may hang until + * the OS TCP timeout. */ #subscribeToWakeDetection() { - // Skip in browser environments where visibilitychange already handles this - if ( - typeof document === `object` && - typeof document.hidden === `boolean` && - typeof document.addEventListener === `function` - ) { - return - } + if (this.#hasBrowserVisibilityAPI()) return - const INTERVAL_MS = 10_000 // Check every 10 seconds - const THRESHOLD_MS = 15_000 // If 15+ extra seconds pass, system likely slept + const INTERVAL_MS = 10_000 + const WAKE_THRESHOLD_MS = 15_000 let lastTickTime = Date.now() @@ -1602,11 +1602,13 @@ export class ShapeStream = Row> const elapsed = now - lastTickTime lastTickTime = now - if (elapsed > INTERVAL_MS + THRESHOLD_MS) { - // System likely woke from sleep — abort the current (probably stale) - // request and restart the fetch loop with a fresh connection + if (elapsed > INTERVAL_MS + WAKE_THRESHOLD_MS) { if (this.#state === `active` && this.#requestAbortController) { + this.#isRefreshing = true this.#requestAbortController.abort(SYSTEM_WAKE) + queueMicrotask(() => { + this.#isRefreshing = false + }) } } }, INTERVAL_MS) diff --git a/packages/typescript-client/test/stream.test.ts b/packages/typescript-client/test/stream.test.ts index af9ee4b79b..db2f34677d 100644 --- a/packages/typescript-client/test/stream.test.ts +++ b/packages/typescript-client/test/stream.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { ShapeStream, isChangeMessage, Message, Row } from '../src' import { snakeCamelMapper } from '../src/column-mapper' import { resolveInMacrotask } from './support/test-helpers' @@ -408,143 +408,3 @@ describe(`ShapeStream`, () => { expect(changeMessage!.value).not.toHaveProperty(`created_at`) }) }) - -describe(`Wake detection`, () => { - const shapeUrl = `https://example.com/v1/shape` - let aborter: AbortController - let savedDocument: typeof globalThis.document | undefined - - beforeEach(() => { - aborter = new AbortController() - // Save and remove document to simulate non-browser (Bun/Node.js) environment - savedDocument = globalThis.document - delete (globalThis as Record).document - }) - - afterEach(() => { - aborter.abort() - // Restore document - if (savedDocument !== undefined) { - globalThis.document = savedDocument - } - vi.restoreAllMocks() - }) - - it(`should set up wake detection timer in non-browser environments`, async () => { - const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) - - const fetchWrapper = (): Promise => { - return resolveInMacrotask(Response.error()) - } - - const stream = new ShapeStream({ - url: shapeUrl, - params: { table: `foo` }, - signal: aborter.signal, - fetchClient: fetchWrapper, - }) - const unsub = stream.subscribe(() => {}) - - // unsubscribeAll should clear the wake detection timer - stream.unsubscribeAll() - expect(clearIntervalSpy.mock.calls.length).toBeGreaterThanOrEqual(1) - - unsub() - }) - - it(`should NOT set up wake detection timer in browser environments`, async () => { - // Restore document to simulate browser environment - ;(globalThis as Record).document = { - hidden: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - } - - const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) - - const fetchWrapper = (): Promise => { - return resolveInMacrotask(Response.error()) - } - - const stream = new ShapeStream({ - url: shapeUrl, - params: { table: `foo` }, - signal: aborter.signal, - fetchClient: fetchWrapper, - }) - const unsub = stream.subscribe(() => {}) - - // In browser env, unsubscribeAll should NOT have a wake detection timer to clear - // (only visibility change handler) - stream.unsubscribeAll() - // clearInterval might be called 0 times (no wake timer was set up) - // We verify by checking that no interval was created for wake detection - // The visibility handler uses addEventListener, not setInterval - expect(clearIntervalSpy.mock.calls.length).toBe(0) - - unsub() - }) - - it(`should detect time gap and abort stale fetch after system wake`, async () => { - vi.useFakeTimers() - - const fetchSignals: AbortSignal[] = [] - const fetchEvents = new EventTarget() - let fetchCallCount = 0 - - const fetchWrapper = ( - ...args: Parameters - ): Promise => { - const signal = args[1]?.signal - if (signal) fetchSignals.push(signal) - fetchCallCount++ - fetchEvents.dispatchEvent(new Event(`fetch`)) - // Simulate a hanging long-poll that rejects on abort - return new Promise((_resolve, reject) => { - signal?.addEventListener(`abort`, () => reject(new Error(`aborted`)), { - once: true, - }) - }) - } - - const stream = new ShapeStream({ - url: shapeUrl, - params: { table: `foo` }, - signal: aborter.signal, - fetchClient: fetchWrapper, - }) - const unsub = stream.subscribe(() => {}) - - // Wait for first fetch - await vi.advanceTimersByTimeAsync(0) - expect(fetchCallCount).toBeGreaterThanOrEqual(1) - const initialFetchCount = fetchCallCount - - // Advance one normal interval (10s) — should NOT trigger wake detection - await vi.advanceTimersByTimeAsync(10_001) - expect(fetchSignals[fetchSignals.length - 1]?.aborted).toBe(false) - - // Simulate system sleep: jump Date.now() forward by 30s - // then advance timer to fire the next interval callback - const currentTime = Date.now() - vi.setSystemTime(currentTime + 30_000) // Date.now() jumps 30s - - // Advance just enough to trigger the next interval tick - await vi.advanceTimersByTimeAsync(10_001) - - // The wake detection should have aborted the stale fetch - // and the fetch loop should have restarted with a new fetch - // Give the async restart a moment to trigger - await vi.advanceTimersByTimeAsync(100) - - // Verify the first signal was aborted - expect(fetchSignals[0]?.aborted).toBe(true) - - // Verify new fetches were made (fetch loop restarted) - expect(fetchCallCount).toBeGreaterThan(initialFetchCount) - - unsub() - aborter.abort() - vi.useRealTimers() - }) -}) diff --git a/packages/typescript-client/test/wake-detection.test.ts b/packages/typescript-client/test/wake-detection.test.ts new file mode 100644 index 0000000000..04a3df48a1 --- /dev/null +++ b/packages/typescript-client/test/wake-detection.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ShapeStream } from '../src' +import { resolveInMacrotask } from './support/test-helpers' + +describe(`Wake detection`, () => { + const shapeUrl = `https://example.com/v1/shape` + let aborter: AbortController + + beforeEach(() => { + aborter = new AbortController() + }) + + afterEach(() => { + aborter.abort() + vi.restoreAllMocks() + }) + + it(`should set up wake detection timer in non-browser environments`, async () => { + const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) + + const fetchWrapper = (): Promise => { + return resolveInMacrotask(Response.error()) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + stream.unsubscribeAll() + expect(clearIntervalSpy.mock.calls.length).toBeGreaterThanOrEqual(1) + + unsub() + }) + + it(`should NOT set up wake detection timer in browser environments`, async () => { + ;(globalThis as Record).document = { + hidden: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + + const clearIntervalSpy = vi.spyOn(globalThis, `clearInterval`) + + const fetchWrapper = (): Promise => { + return resolveInMacrotask(Response.error()) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + stream.unsubscribeAll() + expect(clearIntervalSpy.mock.calls.length).toBe(0) + + unsub() + delete (globalThis as Record).document + }) + + it(`should detect time gap and abort stale fetch after system wake`, async () => { + vi.useFakeTimers() + + const fetchSignals: AbortSignal[] = [] + let fetchCallCount = 0 + + const fetchWrapper = ( + ...args: Parameters + ): Promise => { + const signal = args[1]?.signal + if (signal) fetchSignals.push(signal) + fetchCallCount++ + return new Promise((_resolve, reject) => { + signal?.addEventListener(`abort`, () => reject(new Error(`aborted`)), { + once: true, + }) + }) + } + + const stream = new ShapeStream({ + url: shapeUrl, + params: { table: `foo` }, + signal: aborter.signal, + fetchClient: fetchWrapper, + }) + const unsub = stream.subscribe(() => {}) + + // Wait for first fetch + await vi.advanceTimersByTimeAsync(0) + expect(fetchCallCount).toBeGreaterThanOrEqual(1) + const initialFetchCount = fetchCallCount + + // Advance one normal interval (10s) — should NOT trigger wake detection + await vi.advanceTimersByTimeAsync(10_001) + expect(fetchSignals[fetchSignals.length - 1]?.aborted).toBe(false) + + // Simulate system sleep by jumping Date.now() forward 30s + const currentTime = Date.now() + vi.setSystemTime(currentTime + 30_000) + + // Trigger the next interval tick and allow async restart + await vi.advanceTimersByTimeAsync(10_001) + await vi.advanceTimersByTimeAsync(100) + + expect(fetchSignals[0]?.aborted).toBe(true) + expect(fetchSignals[0]?.reason).toBe(`system-wake`) + expect(fetchCallCount).toBeGreaterThan(initialFetchCount) + + unsub() + aborter.abort() + vi.useRealTimers() + }) +}) diff --git a/packages/typescript-client/vitest.unit.config.ts b/packages/typescript-client/vitest.unit.config.ts index 36e6a90da9..a6ac751167 100644 --- a/packages/typescript-client/vitest.unit.config.ts +++ b/packages/typescript-client/vitest.unit.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ `test/parser.test.ts`, `test/snapshot-tracker.test.ts`, `test/expired-shapes-cache.test.ts`, + `test/wake-detection.test.ts`, ], testTimeout: 30000, environment: `jsdom`, From c91994c642c8fdb5a808527a6697e17df632b749 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 8 Feb 2026 22:13:06 -0700 Subject: [PATCH 3/5] Add changeset for wake detection fix Co-Authored-By: Claude Opus 4.6 --- .changeset/add-system-wake-detection.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-system-wake-detection.md diff --git a/.changeset/add-system-wake-detection.md b/.changeset/add-system-wake-detection.md new file mode 100644 index 0000000000..da68bca8e6 --- /dev/null +++ b/.changeset/add-system-wake-detection.md @@ -0,0 +1,5 @@ +--- +'@electric-sql/client': patch +--- + +Fix ShapeStream hanging after system sleep in non-browser environments (Bun, Node.js). Stale in-flight HTTP requests are now automatically aborted and reconnected on wake, preventing hangs until TCP timeout. From e4a1f0dd377ab4af9100d60491406d8d5ef9f451 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Sun, 8 Feb 2026 22:24:39 -0700 Subject: [PATCH 4/5] Fix wake detection timer leak on stream termination Clean up wake detection interval when #start() exits via error or normal completion, not just via unsubscribeAll(). Also move vi.useRealTimers() to afterEach for failure-safe test cleanup. Co-Authored-By: Claude Opus 4.6 --- packages/typescript-client/src/client.ts | 3 +++ packages/typescript-client/test/wake-detection.test.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index 1bd7b42f2c..41003400bf 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -738,6 +738,7 @@ export class ShapeStream = Row> } this.#connected = false this.#tickPromiseRejecter?.() + this.#unsubscribeFromWakeDetection?.() return } @@ -748,12 +749,14 @@ export class ShapeStream = Row> } this.#connected = false this.#tickPromiseRejecter?.() + this.#unsubscribeFromWakeDetection?.() throw err } // Normal completion, clean up this.#connected = false this.#tickPromiseRejecter?.() + this.#unsubscribeFromWakeDetection?.() } async #requestShape(): Promise { diff --git a/packages/typescript-client/test/wake-detection.test.ts b/packages/typescript-client/test/wake-detection.test.ts index 04a3df48a1..eb76a65a04 100644 --- a/packages/typescript-client/test/wake-detection.test.ts +++ b/packages/typescript-client/test/wake-detection.test.ts @@ -13,6 +13,7 @@ describe(`Wake detection`, () => { afterEach(() => { aborter.abort() + vi.useRealTimers() vi.restoreAllMocks() }) @@ -115,6 +116,5 @@ describe(`Wake detection`, () => { unsub() aborter.abort() - vi.useRealTimers() }) }) From e35c5f2368efa517fec48f727eb257d6beef1ad3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 9 Feb 2026 08:27:49 -0700 Subject: [PATCH 5/5] Reduce wake detection thresholds for faster recovery Lower interval from 10s to 2s and threshold from 15s to 4s, reducing minimum detectable sleep from 25s to 6s. The cost of a false positive (one extra request) is negligible compared to a missed sleep leaving a broken connection hanging. Co-Authored-By: Claude Opus 4.6 --- packages/typescript-client/src/client.ts | 4 ++-- packages/typescript-client/test/wake-detection.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/typescript-client/src/client.ts b/packages/typescript-client/src/client.ts index 41003400bf..20f407c0a7 100644 --- a/packages/typescript-client/src/client.ts +++ b/packages/typescript-client/src/client.ts @@ -1595,8 +1595,8 @@ export class ShapeStream = Row> #subscribeToWakeDetection() { if (this.#hasBrowserVisibilityAPI()) return - const INTERVAL_MS = 10_000 - const WAKE_THRESHOLD_MS = 15_000 + const INTERVAL_MS = 2_000 + const WAKE_THRESHOLD_MS = 4_000 let lastTickTime = Date.now() diff --git a/packages/typescript-client/test/wake-detection.test.ts b/packages/typescript-client/test/wake-detection.test.ts index eb76a65a04..a3193ffb89 100644 --- a/packages/typescript-client/test/wake-detection.test.ts +++ b/packages/typescript-client/test/wake-detection.test.ts @@ -98,16 +98,16 @@ describe(`Wake detection`, () => { expect(fetchCallCount).toBeGreaterThanOrEqual(1) const initialFetchCount = fetchCallCount - // Advance one normal interval (10s) — should NOT trigger wake detection - await vi.advanceTimersByTimeAsync(10_001) + // Advance one normal interval (2s) — should NOT trigger wake detection + await vi.advanceTimersByTimeAsync(2_001) expect(fetchSignals[fetchSignals.length - 1]?.aborted).toBe(false) - // Simulate system sleep by jumping Date.now() forward 30s + // Simulate system sleep by jumping Date.now() forward 10s const currentTime = Date.now() - vi.setSystemTime(currentTime + 30_000) + vi.setSystemTime(currentTime + 10_000) // Trigger the next interval tick and allow async restart - await vi.advanceTimersByTimeAsync(10_001) + await vi.advanceTimersByTimeAsync(2_001) await vi.advanceTimersByTimeAsync(100) expect(fetchSignals[0]?.aborted).toBe(true)