fix: reconnect SSE immediately on OS wake-from-sleep (#1066)
* fix: reconnect SSE immediately on OS wake-from-sleep When the desktop app resumes from OS sleep, TCP connections are dead but timers were paused during sleep so the heartbeat watchdog doesn't fire until ~30s after wake. Add Electron powerMonitor.resume → renderer notification → event-pipeline immediate abort, cutting reconnection delay from ~30s to ~0ms. Changes: - electron/main.mjs: import powerMonitor, emit openchamber:system-resume to all renderer windows on OS resume - ui/sync/event-pipeline.ts: listen for openchamber:system-resume, set attemptAbortReason and abort the active SSE/WS attempt to trigger immediate reconnection with retryDelayMs=0 and lastEventId preservation * fix: reconnect SSE immediately on OS wake-from-sleep When the desktop app resumes from OS sleep, TCP connections are dead but timers were paused during sleep so the heartbeat watchdog doesn't fire until ~30s after wake. Add Electron powerMonitor.resume → renderer notification → event-pipeline immediate abort, cutting reconnection delay from ~30s to ~0ms. Changes: - electron/main.mjs: import powerMonitor, emit openchamber:system-resume to all renderer windows on OS resume - ui/sync/event-pipeline.ts: listen for openchamber:system-resume via globalThis.window, set attemptAbortReason and abort the active SSE/WS attempt to trigger immediate reconnection with retryDelayMs=0 and lastEventId preservation - Test: event-pipeline-resume.test.js verifies abort → reconnect flow
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user