diff --git a/src/background/ws-client.ts b/src/background/ws-client.ts index 5443108..988ec71 100644 --- a/src/background/ws-client.ts +++ b/src/background/ws-client.ts @@ -32,7 +32,22 @@ interface WireError { message: string; } -type WireMessage = WireLockState | WireAck | WireError; +interface WirePong { + type: 'pong'; +} + +type WireMessage = WireLockState | WireAck | WireError | WirePong; + +// JSON-level ping, per the protocol's "ping (optional fallback)" section — +// used here specifically (not relying on the browser's native WS ping/pong +// alone) because this client runs in a MV3 background service worker, which +// Chromium can idle-kill after ~30s with no activity reaching its own +// onMessage-style handlers; a plain protocol-level ping/pong frame isn't +// guaranteed to count as that activity, but a JSON message our own +// 'message' listener below processes is. Sending well under that 30s +// budget (not waiting until the deadline) keeps the socket (and the +// worker) alive continuously rather than trying to time a wakeup. +const PING_INTERVAL_MS = 20_000; const WIRE_TO_INTERNAL_SCOPE: Record = { buy_only: 'buy-only', @@ -95,10 +110,14 @@ export function connectWsClient(options: WsClientOptions): WsClientController { const socket = new WebSocket(`${BACKEND_WS_URL}?token=${encodeURIComponent(token)}`); currentSocket = socket; let authFailure = false; + let pingTimer: ReturnType | undefined; socket.addEventListener('open', () => { reconnectAttempt = 0; options.onStatusChange('connected'); + pingTimer = setInterval(() => { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'ping' })); + }, PING_INTERVAL_MS); }); socket.addEventListener('message', (event) => { @@ -129,9 +148,12 @@ export function connectWsClient(options: WsClientOptions): WsClientController { // 'ack' has nothing to react to yet — the client doesn't send // unlock_request/escalate_request until lock-decision logic exists // server-side (see backend/CLAUDE.md non-goals for this milestone). + // 'pong' likewise needs no handling — sending the ping is the whole + // point (see PING_INTERVAL_MS above), receiving a reply isn't acted on. }); socket.addEventListener('close', (event) => { + if (pingTimer) clearInterval(pingTimer); if (currentSocket === socket) currentSocket = undefined; if (manuallyDisconnected) {