diff --git a/relay-run/src/wiring/socket-pipe.ts b/relay-run/src/wiring/socket-pipe.ts index e920c90..3a86088 100644 --- a/relay-run/src/wiring/socket-pipe.ts +++ b/relay-run/src/wiring/socket-pipe.ts @@ -82,8 +82,33 @@ function toUint8(data: unknown): Uint8Array | null { return null } -/** Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). */ +/** + * Adapt a live `ws` WebSocket to `WebSocketLike` (binary frames only). + * + * The `ws.on('message'|'close')` listeners are attached NOW (at construction), not when the consumer + * later calls `onMessage`/`onClose`. The composition root builds this synchronously on the socket's + * 'connection' event, but the mux consumer only wires its handler AFTER an async mTLS-registry lookup + * (the bridge). Any frame that arrives in that gap — notably the agent's FIRST heartbeat ping, sent + * immediately on open — would otherwise be dropped by `ws` (no listener yet), starving the heartbeat + * and flapping the tunnel every 15 s. We buffer early frames (and an early close) and flush on wire-up. + */ export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike { + let onMessage: ((data: Uint8Array) => void) | null = null + let onClose: (() => void) | null = null + const backlog: Uint8Array[] = [] + let closedEarly = false + + ws.on('message', (data: unknown) => { + const bytes = toUint8(data) + if (bytes === null) return + if (onMessage === null) backlog.push(bytes) + else onMessage(bytes) + }) + ws.on('close', () => { + if (onClose === null) closedEarly = true + else onClose() + }) + return { send(data: Uint8Array) { ws.send(data, { binary: true }) @@ -92,13 +117,12 @@ export function wsToWebSocketLike(ws: WsWebSocket): WebSocketLike { ws.close(code) }, onMessage(handler) { - ws.on('message', (data: unknown) => { - const bytes = toUint8(data) - if (bytes !== null) handler(bytes) - }) + onMessage = handler + while (backlog.length > 0) handler(backlog.shift()!) }, onClose(handler) { - ws.on('close', () => handler()) + onClose = handler + if (closedEarly) handler() }, } }