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:
jwcrystal
2026-04-29 12:19:31 +03:00
committed by GitHub
parent 21253d7fc2
commit 9424cff02c
3 changed files with 138 additions and 1 deletions
+7 -1
View File
@@ -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);
});
});
+19
View File
@@ -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()
}