diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index b86abb05b..5358428ad 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -207,19 +207,23 @@ async function main() { process.on("uncaughtException", (err) => { logger.fatal({ err }, "Uncaught exception — daemon crashing"); - process.exit(1); + exitAfterPinoFlush(); }); process.on("unhandledRejection", (reason) => { logger.fatal({ err: reason }, "Unhandled promise rejection — daemon crashing"); - process.exit(1); + exitAfterPinoFlush(); }); } +// Give pino async streams a moment to flush the fatal log entry to daemon.log +// before the process exits. Without this, the last few entries that explain +// why the daemon crashed can be lost. +function exitAfterPinoFlush(): void { + setTimeout(() => process.exit(1), 200); +} + main().catch((err) => { process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`); - // Give pino streams a moment to flush the fatal log entry to daemon.log - // before the process exits. Without this, async file streams may lose the - // last few entries that explain why the daemon crashed. - setTimeout(() => process.exit(1), 200); + exitAfterPinoFlush(); }); diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 0698af77a..fc93a1d11 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -396,7 +396,7 @@ async function attachEncryptedSocket( metadata?: ExternalSocketMetadata, ): Promise { try { - const relayTransport = createRelayTransportAdapter(socket); + const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); const channel = await createDaemonChannel(relayTransport, daemonKeyPair, { onmessage: (data) => emitter.emit("message", data), @@ -418,9 +418,18 @@ async function attachEncryptedSocket( } } -function createRelayTransportAdapter(socket: WebSocket): RelayTransport { +function createRelayTransportAdapter(socket: WebSocket, logger: pino.Logger): RelayTransport { const relayTransport: RelayTransport = { - send: (data) => socket.send(data), + send: (data) => { + try { + socket.send(data); + } catch (err) { + // Socket likely transitioned to closed between checks; let onclose/onerror + // drive cleanup. Without this guard the synchronous throw would propagate + // up as an uncaughtException and take down the daemon. + logger.warn({ err }, "relay_socket_send_failed"); + } + }, close: (code?: number, reason?: string) => socket.close(code, reason), onmessage: null, onclose: null, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 894d35c91..25429ccac 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -738,10 +738,17 @@ export class VoiceAssistantWebSocketServer { } private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { - // WebSocket.OPEN = 1 - if (ws.readyState === 1) { + // WebSocket.OPEN = 1. The check is a fast path; the socket can still + // transition to closed between here and ws.send(), so guard the send too — + // a synchronous throw here would propagate as an uncaughtException. + if (ws.readyState !== 1) { + return; + } + try { ws.send(JSON.stringify(message)); this.recordOutboundMessage(message, ws); + } catch (err) { + this.logger.warn({ err }, "ws_send_failed"); } } @@ -749,8 +756,12 @@ export class VoiceAssistantWebSocketServer { if (ws.readyState !== 1) { return; } - ws.send(frame); - this.recordOutboundBinaryFrame(ws); + try { + ws.send(frame); + this.recordOutboundBinaryFrame(ws); + } catch (err) { + this.logger.warn({ err }, "ws_send_binary_failed"); + } } private sendToConnection(connection: SessionConnection, message: WSOutboundMessage): void {