diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index ecb322cf..9183c639 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -54,6 +54,13 @@ export const resolveBaseCwd = (email: string, role: string, cwd?: string) => { const wsToSessionMap = new WeakMap(); +// Per-connection heartbeat. Bun closes a WS idle for `idleTimeout` (60s), and its timer only resets +// on frames *received* from the client — but during a chat turn the client only receives. So we ping +// each connection every 25s; the client auto-pongs at the protocol level, which resets Bun's timer +// (and keeps reverse proxies happy). Genuinely dead sockets still time out (no pong). +const pingTimers = new WeakMap, ReturnType>(); +const PING_INTERVAL_MS = 25_000; + function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage): void { if (ws?.readyState === 1) { ws.send(JSON.stringify(msg)); @@ -61,7 +68,14 @@ function sendToClient(ws: ServerWebSocket | null, msg: ServerMessage): v } export async function open(ws: ServerWebSocket): Promise { - // logger.info('WebSocket connection opened', { email: ws.data.email }); + const timer = setInterval(() => { + try { + ws.ping(); // client auto-pongs → resets Bun's idleTimeout + } catch { + /* socket already gone */ + } + }, PING_INTERVAL_MS); + pingTimers.set(ws, timer); } export function message(ws: ServerWebSocket, raw: string | Buffer): void { @@ -86,7 +100,11 @@ export function message(ws: ServerWebSocket, raw: string | Buffer): void } export function close(ws: ServerWebSocket): void { - // logger.info('WebSocket connection closed', { email: ws.data.email }); + const timer = pingTimers.get(ws); + if (timer) { + clearInterval(timer); + pingTimers.delete(ws); + } const sessionId = wsToSessionMap.get(ws); if (sessionId) {