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 contextMenu from 'electron-context-menu';
|
||||||
import log from 'electron-log/main.js';
|
import log from 'electron-log/main.js';
|
||||||
import dgram from 'node:dgram';
|
import dgram from 'node:dgram';
|
||||||
@@ -2366,6 +2366,12 @@ app.whenReady().then(async () => {
|
|||||||
const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl();
|
const { initialUrl, localOrigin, bootOutcome } = await resolveInitialUrl();
|
||||||
await activateMainWindow(initialUrl, localOrigin, bootOutcome);
|
await activateMainWindow(initialUrl, localOrigin, bootOutcome);
|
||||||
startQuitRiskPoller();
|
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) => {
|
}).catch((error) => {
|
||||||
log.error('[electron] startup failed:', error);
|
log.error('[electron] startup failed:', error);
|
||||||
app.exit(1);
|
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"
|
| "pipeline_stopped"
|
||||||
| "ws_heartbeat_timeout"
|
| "ws_heartbeat_timeout"
|
||||||
| "sse_heartbeat_timeout"
|
| "sse_heartbeat_timeout"
|
||||||
|
| "ws_system_resume"
|
||||||
|
| "sse_system_resume"
|
||||||
| null
|
| null
|
||||||
|
|
||||||
export function createEventPipeline(input: EventPipelineInput) {
|
export function createEventPipeline(input: EventPipelineInput) {
|
||||||
@@ -616,16 +618,33 @@ export function createEventPipeline(input: EventPipelineInput) {
|
|||||||
attempt?.abort()
|
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") {
|
if (typeof document !== "undefined") {
|
||||||
document.addEventListener("visibilitychange", onVisibility)
|
document.addEventListener("visibilitychange", onVisibility)
|
||||||
window.addEventListener("pageshow", onPageShow)
|
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 = () => {
|
const cleanup = () => {
|
||||||
if (typeof document !== "undefined") {
|
if (typeof document !== "undefined") {
|
||||||
document.removeEventListener("visibilitychange", onVisibility)
|
document.removeEventListener("visibilitychange", onVisibility)
|
||||||
window.removeEventListener("pageshow", onPageShow)
|
window.removeEventListener("pageshow", onPageShow)
|
||||||
}
|
}
|
||||||
|
if (typeof globalThis.window !== "undefined") {
|
||||||
|
globalThis.window.removeEventListener("openchamber:system-resume", onSystemResume)
|
||||||
|
}
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flushAll()
|
flushAll()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user