diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index d2b85e0e..a308b5ce 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, session, shell } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, Notification, powerMonitor, session, shell } from 'electron'; import contextMenu from 'electron-context-menu'; import log from 'electron-log/main.js'; import dgram from 'node:dgram'; @@ -2366,6 +2366,12 @@ app.whenReady().then(async () => { const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl(); await activateMainWindow(initialUrl, localOrigin, bootOutcome); startQuitRiskPoller(); + + // Notify renderer on OS wake-from-sleep so the SSE event pipeline can + // reconnect immediately instead of waiting for the heartbeat watchdog. + powerMonitor.on('resume', () => { + emitToAllWindows('openchamber:system-resume', { timestamp: Date.now() }); + }); }).catch((error) => { log.error('[electron] startup failed:', error); app.exit(1); diff --git a/packages/ui/src/sync/__tests__/event-pipeline-resume.test.js b/packages/ui/src/sync/__tests__/event-pipeline-resume.test.js new file mode 100644 index 00000000..3bb6ec29 --- /dev/null +++ b/packages/ui/src/sync/__tests__/event-pipeline-resume.test.js @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { createEventPipeline } from '../event-pipeline'; + +const savedDocument = globalThis.document; +const savedWindow = globalThis.window; + +afterEach(() => { + globalThis.document = savedDocument; + globalThis.window = savedWindow; +}); + +describe('createEventPipeline — system resume reconnect', () => { + it('reconnects immediately on openchamber:system-resume event', async () => { + const winListeners = {}; + globalThis.document = { + visibilityState: 'visible', + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.window = { + location: { + href: 'http://127.0.0.1:3000/', + origin: 'http://127.0.0.1:3000', + }, + addEventListener(event, handler) { winListeners[event] = handler; }, + removeEventListener(event) { delete winListeners[event]; }, + }; + + const disconnectReasons = []; + let reconnectCount = 0; + const eventCalls = []; + + let sdkCallIndex = 0; + let releaseFirstStream; + const firstHold = new Promise((resolve) => { releaseFirstStream = resolve; }); + + const sdk = { + global: { + // Accept options with signal so the mock generator can abort. + event: async (options) => { + const callIndex = sdkCallIndex++; + eventCalls.push(callIndex); + const signal = options?.signal; + if (callIndex === 0) { + return { + stream: (async function* () { + yield { + payload: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'idle' } } }, + }; + // Wait for either the hold promise or abort signal. + await Promise.race([ + firstHold, + new Promise((_, reject) => { + if (signal?.aborted) { reject(signal.reason || new DOMException('Aborted', 'AbortError')); return; } + signal?.addEventListener('abort', () => { + reject(signal.reason || new DOMException('Aborted', 'AbortError')); + }); + }), + ]); + })(), + }; + } + return { + stream: (async function* () { + yield { + payload: { type: 'session.status', properties: { sessionID: 's1', status: { type: 'idle' } } }, + }; + await new Promise(() => {}); + })(), + }; + }, + }, + }; + + const recovered = new Promise((resolve) => { + const { cleanup } = createEventPipeline({ + sdk, + transport: 'sse', + heartbeatTimeoutMs: 60_000, + reconnectDelayMs: 60_000, + onEvent: () => {}, + onDisconnect: (reason) => { + disconnectReasons.push(reason); + }, + onReconnect: () => { + reconnectCount += 1; + // onReconnect fires on the initial connect too (count=1), + // so wait for the second reconnect (count=2) triggered by resume. + if (reconnectCount === 2) { + cleanup(); + resolve(); + } + }, + }); + + // Wait for first SSE attempt to start and deliver the event, then + // simulate OS resume by invoking the registered handler directly. + setTimeout(() => { + const handler = winListeners['openchamber:system-resume']; + if (handler) handler(); + }, 80); + }); + + await recovered; + releaseFirstStream(); + + // Should have made two SDK calls: initial connect + reconnect after resume. + expect(eventCalls.length).toBe(2); + // Disconnect reason should include system_resume. + expect(disconnectReasons.some((r) => r.includes('system_resume'))).toBe(true); + }); +}); diff --git a/packages/ui/src/sync/event-pipeline.ts b/packages/ui/src/sync/event-pipeline.ts index 43e5e488..342e189c 100644 --- a/packages/ui/src/sync/event-pipeline.ts +++ b/packages/ui/src/sync/event-pipeline.ts @@ -152,6 +152,8 @@ type AttemptAbortReason = | "pipeline_stopped" | "ws_heartbeat_timeout" | "sse_heartbeat_timeout" + | "ws_system_resume" + | "sse_system_resume" | null export function createEventPipeline(input: EventPipelineInput) { @@ -616,16 +618,33 @@ export function createEventPipeline(input: EventPipelineInput) { attempt?.abort() } + // OS wake-from-sleep (Electron powerMonitor.resume). The SSE connection + // is almost certainly dead after sleep — abort immediately so the + // reconnect loop fires on the next tick with retryDelayMs = 0. + const onSystemResume = () => { + attemptAbortReason = `${activeTransport}_system_resume` + attempt?.abort() + } + if (typeof document !== "undefined") { document.addEventListener("visibilitychange", onVisibility) window.addEventListener("pageshow", onPageShow) } + // Use globalThis (not window) for the system-resume listener so that + // test environments can replace globalThis.window with a stub. + if (typeof globalThis.window !== "undefined") { + globalThis.window.addEventListener("openchamber:system-resume", onSystemResume) + } + const cleanup = () => { if (typeof document !== "undefined") { document.removeEventListener("visibilitychange", onVisibility) window.removeEventListener("pageshow", onPageShow) } + if (typeof globalThis.window !== "undefined") { + globalThis.window.removeEventListener("openchamber:system-resume", onSystemResume) + } abort.abort() flushAll() }