fix(transport): preserve backpressure across framing and routes

Ordinary daemon writes must remain visible to terminal catch-up and socket admission. Pace chunked writes and explicit completion requests, include waiting logical messages in pressure readings, and propagate origin completion through routed client transports.
This commit is contained in:
Mohamed Boudra
2026-10-30 15:51:50 +01:00
parent 98f667f93e
commit f28f0fee3d
9 changed files with 278 additions and 56 deletions
@@ -1836,3 +1836,58 @@ describe("list terminals across directories", () => {
]);
});
});
test("a paused terminal consumer catches up through a snapshot instead of retaining every output frame", async () => {
const { existsSync } = await import("node:fs");
const cwd = tmpCwd();
tempDirs.push(cwd);
const marker = path.join(cwd, "output-complete");
const created = await createTerminalInWorkspace(ctx.client, {
cwd,
options: {
command: process.execPath,
args: [
"-e",
`
const fs = require('node:fs');
process.stdin.setRawMode?.(true);
process.stdin.resume();
process.stdin.once('data', () => {
const chunk = Buffer.alloc(256 * 1024, 120);
let remaining = 128;
const write = () => {
if (remaining-- === 0) { fs.writeFileSync(${JSON.stringify(marker)}, 'done'); return; }
process.stdout.write(chunk, write);
};
write();
});
`,
],
},
});
const ws = await connectRawWebSocket(ctx.daemon.port);
try {
const slot = await subscribeRawTerminal(ws, created.terminal!.id, "slow-terminal");
await waitForRawBinaryFrame(
ws,
(frame) => frame.slot === slot && frame.opcode === TerminalStreamOpcode.Snapshot,
);
const capture = startTerminalOutputCapture(ws, slot);
try {
ws.pause();
sendRawTerminalInput(ws, slot, "go\n");
await waitForCondition(() => existsSync(marker), 20000);
ws.resume();
await waitForCondition(() => capture.snapshotCount > 0, 10000);
expect(ws.readyState).toBe(WebSocket.OPEN);
expect(capture.frames.reduce((total, frame) => total + frame.payloadBytes, 0)).toBeLessThan(
32 * 1024 * 1024,
);
} finally {
ws.resume();
capture.stop();
}
} finally {
await closeWebSocket(ws);
}
}, 40000);
+52 -21
View File
@@ -126,6 +126,7 @@ import {
outboundFrameByteLength,
physicalSocketHasCapacity,
sendBoundedPhysicalFrameAndWait,
sendBoundedPhysicalFrame,
} from "./websocket/physical-socket.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
@@ -1285,13 +1286,18 @@ export class VoiceAssistantWebSocketServer {
if (!transport) {
transport = new ChunkedTransport({
frameBytes: ws.maxFrameBytes ?? 32 * 1024 * 1024,
send: async (frame) => {
const sent = await sendBoundedPhysicalFrameAndWait({
send: (frame, waitForDrain) => {
const params = {
socket: ws,
frame,
onHighWater: () => this.closeAtOutboundHighWater(ws),
});
if (!sent) throw new Error("Source is closed");
};
if (waitForDrain)
return sendBoundedPhysicalFrameAndWait(params).then((sent) => {
if (!sent) throw new Error("Source is closed");
return undefined;
});
if (!sendBoundedPhysicalFrame(params)) throw new Error("Source is closed");
},
deliver: (frame) =>
this.handleRawMessage(ws, typeof frame === "string" ? frame : Buffer.from(frame)),
@@ -1301,6 +1307,27 @@ export class VoiceAssistantWebSocketServer {
return transport;
}
private socketBufferedAmount(ws: WebSocketLike): number | null {
const queued = this.chunkedSockets.get(ws)?.queuedByteLength ?? 0;
if (typeof ws.bufferedAmount === "number") return ws.bufferedAmount + queued;
return queued > 0 ? queued : null;
}
private sendSocketFrame(
ws: WebSocketLike,
frame: string | Uint8Array,
bytes: number,
waitForDrain = false,
): Promise<void> {
const transport = this.chunkedSocket(ws);
const admittedBytes = transport.isWriting
? bytes
: Math.min(bytes, ws.maxFrameBytes ?? 32 * 1024 * 1024);
if (!this.ensureOutboundCapacity(ws, admittedBytes))
return Promise.reject(new Error("Source is closed"));
return transport.send(frame, waitForDrain);
}
private async sendMessageToClientAndWait(
ws: WebSocketLike,
message: WSOutboundMessage,
@@ -1308,7 +1335,7 @@ export class VoiceAssistantWebSocketServer {
const prepared = this.prepareJsonDelivery([ws], message, true);
if (!prepared) throw new Error("Source message delivery unavailable");
try {
await this.chunkedSocket(ws).send(prepared.payload);
await this.sendSocketFrame(ws, prepared.payload, prepared.bytes, true);
this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
@@ -1335,7 +1362,7 @@ export class VoiceAssistantWebSocketServer {
return;
}
try {
await this.chunkedSocket(ws).send(frame);
await this.sendSocketFrame(ws, frame, frame.byteLength, true);
this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
@@ -1346,16 +1373,14 @@ export class VoiceAssistantWebSocketServer {
private sendFrameToClient(
ws: WebSocketLike,
frame: string | Uint8Array,
_frameBytes: number,
frameBytes: number,
recordSent: () => void,
): void {
try {
void this.chunkedSocket(ws)
.send(frame)
.then(recordSent, (err) => {
this.logger.warn({ err }, "ws_send_failed");
ws.close(1011, "Transport send failed");
});
void this.sendSocketFrame(ws, frame, frameBytes).then(recordSent, (err) => {
this.logger.warn({ err }, "ws_send_failed");
ws.close(1011, "Transport send failed");
});
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
}
@@ -1363,7 +1388,13 @@ export class VoiceAssistantWebSocketServer {
private ensureOutboundCapacity(ws: WebSocketLike, frameBytes: number): boolean {
if (ws.readyState !== 1) return false;
if (physicalSocketHasCapacity(ws, frameBytes)) return true;
if (
physicalSocketHasCapacity(
{ bufferedAmount: this.socketBufferedAmount(ws) ?? undefined },
frameBytes,
)
)
return true;
this.closeAtOutboundHighWater(ws);
return false;
@@ -1536,16 +1567,15 @@ export class VoiceAssistantWebSocketServer {
if (!connection) {
return null;
}
if (source) return (source as WebSocketLike).bufferedAmount ?? null;
if (source) return this.socketBufferedAmount(source as WebSocketLike);
// Relay-attached sockets are a WebSocketLike that doesn't expose
// bufferedAmount. Return null when no socket gives a signal so the
// terminal fallback can't mistake "no signal" for "client keeping up";
// a direct ws reports its real buffered bytes (0 when drained).
let maxBuffered: number | null = null;
for (const socket of connection.sockets) {
if (typeof socket.bufferedAmount === "number") {
maxBuffered = Math.max(maxBuffered ?? 0, socket.bufferedAmount);
}
const buffered = this.socketBufferedAmount(socket);
if (buffered !== null) maxBuffered = Math.max(maxBuffered ?? 0, buffered);
}
return maxBuffered;
},
@@ -1582,8 +1612,9 @@ export class VoiceAssistantWebSocketServer {
let total = 0;
for (const socket of this.sessions.keys()) {
if (socket instanceof VirtualHostSocket) continue;
if (typeof socket.bufferedAmount !== "number") return null;
total += socket.bufferedAmount;
const buffered = this.socketBufferedAmount(socket);
if (buffered === null) return null;
total += buffered;
}
return total;
},
@@ -2589,7 +2620,7 @@ export class VoiceAssistantWebSocketServer {
identity: JSON.stringify([connection.principalId, connection.clientId]),
permissions: () => connection.session.getPermissions(),
routed: () => connection.session.isRouted(),
bufferedAmount: () => ws.bufferedAmount ?? null,
bufferedAmount: () => this.socketBufferedAmount(ws),
delivery: connection.session.delivery,
admit: (socket, permissions) =>
this.attachSocket(socket, undefined, undefined, false, {
@@ -120,3 +120,53 @@ test("the awaitable physical send rejects callback errors", async () => {
}),
).rejects.toThrow("send failed");
});
test("ordinary frames expose a paused real socket's backlog and terminate at the physical backstop", async () => {
const { once } = await import("node:events");
const { WebSocket, WebSocketServer } = await import("ws");
const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport");
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(server, "listening");
const connected = once(server, "connection");
const address = server.address();
if (typeof address === "string" || !address) throw new Error("Missing server address");
const client = new WebSocket(`ws://127.0.0.1:${address.port}`);
await once(client, "open");
const [socket] = (await connected) as [InstanceType<typeof WebSocket>];
const tcp = (socket as unknown as { _socket: import("node:net").Socket })._socket;
let terminated = false;
const transport = new ChunkedTransport({
frameBytes: 32 * 1024 * 1024,
deliver: () => {},
send: (frame, wait) => {
const options = {
socket,
frame,
onHighWater: () => {
terminated = true;
socket.terminate();
},
};
if (wait) return sendBoundedPhysicalFrameAndWait(options).then(() => undefined);
sendBoundedPhysicalFrame(options);
},
});
try {
client.pause();
tcp.cork();
const frame = new Uint8Array(256 * 1024);
for (let count = 0; count < 200; count++) await transport.send(frame);
expect(socket.bufferedAmount).toBeGreaterThan(4 * 1024 * 1024);
expect(transport.queuedByteLength).toBe(0);
for (let count = 0; count < 100; count++) {
if (terminated) break;
await transport.send(frame);
}
expect(terminated).toBe(true);
} finally {
transport.close();
client.terminate();
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});