Keep large file views from disconnecting (#2482)

* fix(files): keep large downloads connected

Large file reads were emitted as one frame, crossing the physical socket high-water mark and terminating otherwise healthy connections. Stream bounded chunks from one file handle and pace each transfer by its own send completion without globally queueing unrelated traffic.

* fix(files): keep paced relay transfers bounded

Carry send completion through encryption to the physical WebSocket callback, cap growing files at their advertised size, and preserve text classification when a UTF-8 code point crosses the sample boundary.

* fix(files): reject changing transfer snapshots

Abort when a file shrinks below its advertised size, finalize UTF-8 validation for complete samples, and let the physical relay socket remain the sole authority for queued-byte accounting.

* fix(files): validate metadata before transfer

Keep the daemon handshake pending until the ready frame is physically sent, and classify file content with a bounded full-handle scan so streaming preserves the previous binary/text behavior.

* fix(files): detect changing transfer sources

* fix(files): enforce transfer snapshot integrity
This commit is contained in:
Mohamed Boudra
2026-07-27 13:52:57 +02:00
committed by GitHub
parent b97d6d13f3
commit e1b1ca569d
16 changed files with 1044 additions and 158 deletions

View File

@@ -252,6 +252,10 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport. Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
File downloads keep the existing `FileBegin`/`FileChunk`/`FileEnd` framing and stream 256 KiB chunks
from one stable file handle. Each transfer awaits completion of its own physical WebSocket send before
reading the next chunk; it is scoped to the requesting physical socket and does not queue unrelated
messages or transfers.
### Compatibility rules ### Compatibility rules

View File

@@ -1,5 +1,10 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { createClientChannel, createDaemonChannel, Transport } from "./encrypted-channel.js"; import {
createClientChannel,
createDaemonChannel,
EncryptedChannel,
Transport,
} from "./encrypted-channel.js";
import { import {
deriveSharedKey, deriveSharedKey,
encrypt, encrypt,
@@ -47,6 +52,64 @@ async function waitForAsyncDelivery(): Promise<void> {
} }
describe("EncryptedChannel", () => { describe("EncryptedChannel", () => {
it("rejects the daemon handshake when the ready frame fails to send", async () => {
const daemonKeyPair = generateKeyPair();
const clientKeyPair = generateKeyPair();
const transport: Transport = {
send: () => Promise.reject(new Error("ready send failed")),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
const channel = createDaemonChannel(transport, daemonKeyPair);
transport.onmessage?.({
data: JSON.stringify({
type: "e2ee_hello",
key: exportPublicKey(clientKeyPair.publicKey),
}),
isBinary: false,
});
await expect(channel).rejects.toThrow("ready send failed");
});
it("waits for transport send completion", async () => {
let completeSend: (() => void) | undefined;
const transport: Transport = {
send: () =>
new Promise<void>((resolve) => {
completeSend = resolve;
}),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
const first = generateKeyPair();
const second = generateKeyPair();
const channel = new EncryptedChannel(
transport,
deriveSharedKey(first.secretKey, second.publicKey),
{},
{ binaryCiphertext: true },
);
channel.setState("open");
let completed = false;
const sending = channel.send(new Uint8Array([1, 2, 3]).buffer).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
completeSend?.();
await sending;
expect(completed).toBe(true);
});
it("establishes encrypted channel between daemon and client", async () => { it("establishes encrypted channel between daemon and client", async () => {
const [daemonTransport, clientTransport] = createMockTransportPair(); const [daemonTransport, clientTransport] = createMockTransportPair();
@@ -191,6 +254,58 @@ describe("EncryptedChannel", () => {
} }
}); });
it("reports rejected handshake hello sends", async () => {
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
const transport: Transport = {
send: () => Promise.reject(new Error("hello send failed")),
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const onerror = vi.fn();
await createClientChannel(transport, daemonPubKeyB64, { onerror });
await Promise.resolve();
expect(onerror).toHaveBeenCalledTimes(1);
expect((onerror.mock.calls[0][0] as Error).message).toBe("hello send failed");
transport.onclose?.(1000, "closed");
});
it("reports rejected sends while flushing the handshake backlog", async () => {
const daemonKeyPair = generateKeyPair();
const daemonPubKeyB64 = exportPublicKey(daemonKeyPair.publicKey);
let sendAttempts = 0;
const transport: Transport = {
send: () => {
sendAttempts += 1;
return sendAttempts === 1 ? undefined : Promise.reject(new Error("backlog send failed"));
},
close: vi.fn(),
onmessage: null,
onclose: null,
onerror: null,
};
const onerror = vi.fn();
const channel = await createClientChannel(transport, daemonPubKeyB64, { onerror });
await channel.send(new ArrayBuffer(8));
transport.onmessage?.({
data: JSON.stringify({
type: "e2ee_ready",
capabilities: { binaryCiphertext: true },
}),
isBinary: false,
});
await waitForAsyncDelivery();
expect(onerror).toHaveBeenCalledTimes(1);
expect((onerror.mock.calls[0][0] as Error).message).toBe("backlog send failed");
expect(transport.close).toHaveBeenCalledWith(1011, "backlog send failed");
});
it("fails handshake on invalid hello", async () => { it("fails handshake on invalid hello", async () => {
const [daemonTransport] = createMockTransportPair(); const [daemonTransport] = createMockTransportPair();

View File

@@ -19,7 +19,7 @@ import {
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js"; import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
export interface Transport { export interface Transport {
send(data: string | ArrayBuffer): void; send(data: string | ArrayBuffer): void | Promise<void>;
close(code?: number, reason?: string): void; close(code?: number, reason?: string): void;
onmessage: ((message: TransportMessage) => void) | null; onmessage: ((message: TransportMessage) => void) | null;
onclose: ((code: number, reason: string) => void) | null; onclose: ((code: number, reason: string) => void) | null;
@@ -169,7 +169,10 @@ export async function createClientChannel(
}; };
const sendHello = () => { const sendHello = () => {
try { try {
transport.send(helloText); const result = transport.send(helloText);
if (result) {
void result.catch(emitSendError);
}
return true; return true;
} catch (error) { } catch (error) {
// This can happen during daemon restarts while the socket transitions // This can happen during daemon restarts while the socket transitions
@@ -263,11 +266,7 @@ export async function createDaemonChannel(
const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey); const sharedKey = deriveSharedKey(daemonKeyPair.secretKey, clientPublicKey);
const binaryCiphertext = supportsBinaryCiphertext(msg); const binaryCiphertext = supportsBinaryCiphertext(msg);
const channel = new EncryptedChannel(transport, sharedKey, events, { await transport.send(
daemonKeyPair,
binaryCiphertext,
});
transport.send(
JSON.stringify({ JSON.stringify({
type: "e2ee_ready", type: "e2ee_ready",
...(binaryCiphertext ...(binaryCiphertext
@@ -276,6 +275,10 @@ export async function createDaemonChannel(
} satisfies E2EEReadyMessage), } satisfies E2EEReadyMessage),
); );
const channel = new EncryptedChannel(transport, sharedKey, events, {
daemonKeyPair,
binaryCiphertext,
});
channel.setState("open"); channel.setState("open");
events.onopen?.(); events.onopen?.();
@@ -354,7 +357,14 @@ export class EncryptedChannel {
this.state = "open"; this.state = "open";
this.events.onopen?.(); this.events.onopen?.();
for (const cb of this.onOpenCallbacks) cb(); for (const cb of this.onOpenCallbacks) cb();
await this.flushPendingSends(); try {
await this.flushPendingSends();
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.events.onerror?.(err);
this.state = "closed";
this.transport.close(1011, err.message);
}
} }
} catch { } catch {
// ignore non-ready handshake traffic // ignore non-ready handshake traffic
@@ -455,12 +465,12 @@ export class EncryptedChannel {
const ciphertext = encrypt(this.sharedKey, data); const ciphertext = encrypt(this.sharedKey, data);
if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { if (this.options.binaryCiphertext && data instanceof ArrayBuffer) {
this.transport.send(ciphertext); await this.transport.send(ciphertext);
return; return;
} }
// COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends // COMPAT(binaryCiphertext): added in v0.2.3, remove base64 binary sends
// after 2027-01-27 once the supported peer floor includes negotiation. // after 2027-01-27 once the supported peer floor includes negotiation.
this.transport.send(arrayBufferToBase64(ciphertext)); await this.transport.send(arrayBufferToBase64(ciphertext));
} }
outboundWireByteLength(data: string | ArrayBuffer): number { outboundWireByteLength(data: string | ArrayBuffer): number {
@@ -489,7 +499,7 @@ export class EncryptedChannel {
// "ready" but do not re-key. Re-keying here would desync // "ready" but do not re-key. Re-keying here would desync
// the channel and cause decrypt failures. // the channel and cause decrypt failures.
if (keysEqual(nextSharedKey, this.sharedKey)) { if (keysEqual(nextSharedKey, this.sharedKey)) {
this.transport.send( await this.transport.send(
JSON.stringify({ JSON.stringify({
type: "e2ee_ready", type: "e2ee_ready",
...(this.options.binaryCiphertext ...(this.options.binaryCiphertext

View File

@@ -1,8 +1,13 @@
import { chmod, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getExplorerFileVersion, readExplorerFile, writeExplorerFile } from "./service.js"; import {
getExplorerFileVersion,
readExplorerFile,
streamExplorerFile,
writeExplorerFile,
} from "./service.js";
async function createHomeTempDir(prefix: string): Promise<string> { async function createHomeTempDir(prefix: string): Promise<string> {
return mkdtemp(path.join(os.homedir(), prefix)); return mkdtemp(path.join(os.homedir(), prefix));
@@ -192,6 +197,127 @@ describe("file explorer service", () => {
} }
}); });
it("fails a stream when the file grows after its revision is advertised", async () => {
const root = await createTempDir("paseo-file-stream-growth-");
try {
const filePath = path.join(root, "growing.log");
const initial = Buffer.alloc(300 * 1024, 0x61);
await writeFile(filePath, initial);
await expect(
streamExplorerFile({ root, relativePath: "growing.log" }, async (file) => {
await appendFile(filePath, Buffer.alloc(300 * 1024, 0x62));
for await (const _chunk of file.chunks) {
// Consume through the advertised prefix before validating the revision.
}
}),
).rejects.toThrow("File changed during transfer");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("fails a stream when the file shrinks below its advertised size", async () => {
const root = await createTempDir("paseo-file-stream-truncate-");
try {
const filePath = path.join(root, "shrinking.log");
await writeFile(filePath, Buffer.alloc(300 * 1024, 0x61));
await expect(
streamExplorerFile({ root, relativePath: "shrinking.log" }, async (file) => {
await truncate(filePath, 100 * 1024);
for await (const _chunk of file.chunks) {
// Consume until the stream detects the premature EOF.
}
}),
).rejects.toThrow("File changed during transfer");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("fails a stream when the file is overwritten in place", async () => {
const root = await createTempDir("paseo-file-stream-overwrite-");
try {
const filePath = path.join(root, "changing.log");
const initial = Buffer.alloc(600 * 1024, 0x61);
await writeFile(filePath, initial);
await expect(
streamExplorerFile({ root, relativePath: "changing.log" }, async (file) => {
let chunkIndex = 0;
for await (const _chunk of file.chunks) {
chunkIndex += 1;
if (chunkIndex === 1) {
const replacement = Buffer.alloc(initial.byteLength, 0x62);
await writeFile(filePath, replacement);
}
}
}),
).rejects.toThrow("File changed during transfer");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("classifies sampled text when UTF-8 crosses the sample boundary", async () => {
const root = await createTempDir("paseo-file-stream-utf8-");
try {
const content = Buffer.concat([Buffer.alloc(8191, 0x61), Buffer.from("€"), Buffer.from("z")]);
await writeFile(path.join(root, "sample.txt"), content);
let kind: string | undefined;
let encoding: string | undefined;
await streamExplorerFile({ root, relativePath: "sample.txt" }, async (file) => {
kind = file.kind;
encoding = file.encoding;
});
expect(kind).toBe("text");
expect(encoding).toBe("utf-8");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects incomplete UTF-8 when the whole file was sampled", async () => {
const root = await createTempDir("paseo-file-stream-invalid-utf8-");
try {
await writeFile(path.join(root, "invalid.txt"), Buffer.from([0x61, 0xe2, 0x82]));
let kind: string | undefined;
await streamExplorerFile({ root, relativePath: "invalid.txt" }, async (file) => {
kind = file.kind;
});
expect(kind).toBe("binary");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("detects binary bytes beyond the initial classification block", async () => {
const root = await createTempDir("paseo-file-stream-late-binary-");
try {
const content = Buffer.concat([Buffer.alloc(8192, 0x61), Buffer.from([0xff])]);
await writeFile(path.join(root, "late-binary.unknown"), content);
let kind: string | undefined;
await streamExplorerFile({ root, relativePath: "late-binary.unknown" }, async (file) => {
kind = file.kind;
});
expect(kind).toBe("binary");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("expands a ~ prefix in relative paths against the user home directory", async () => { it("expands a ~ prefix in relative paths against the user home directory", async () => {
const root = await createHomeTempDir(".paseo-file-explorer-home-"); const root = await createHomeTempDir(".paseo-file-explorer-home-");

View File

@@ -76,12 +76,24 @@ export interface FileExplorerFileBytes {
revision: string; revision: string;
} }
export interface FileExplorerFileStream {
path: string;
kind: ExplorerFileKind;
encoding: "utf-8" | "binary";
mimeType: string;
size: number;
modifiedAt: string;
revision: string;
chunks: AsyncIterable<Uint8Array>;
}
const TEXT_MIME_TYPES: Record<string, string> = { const TEXT_MIME_TYPES: Record<string, string> = {
".json": "application/json", ".json": "application/json",
}; };
const DEFAULT_TEXT_MIME_TYPE = "text/plain"; const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const FILE_TYPE_SAMPLE_BYTES = 8192; const FILE_TYPE_SAMPLE_BYTES = 8192;
export const FILE_EXPLORER_STREAM_CHUNK_BYTES = 256 * 1024;
export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024; export const MAX_EDITABLE_FILE_BYTES = 1024 * 1024;
const READ_FILE_OPEN_FLAGS = const READ_FILE_OPEN_FLAGS =
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW; process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
@@ -275,6 +287,109 @@ export async function readExplorerFileBytes({
} }
} }
export async function streamExplorerFile(
{ root, relativePath }: ReadFileParams,
consume: (file: FileExplorerFileStream) => Promise<void>,
): Promise<void> {
const filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath);
try {
const stats = await handle.stat({ bigint: true });
if (!stats.isFile()) {
throw new Error("Requested path is not a file");
}
const advertisedSize = Number(stats.size);
const advertisedRevision = fileRevision(stats);
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const isImage = ext in IMAGE_MIME_TYPES;
const isBinary = isImage || (await isFileHandleBinary(handle, advertisedSize));
let kind: ExplorerFileKind = "text";
let mimeType = textMimeTypeForExtension(ext);
if (isImage) {
kind = "image";
mimeType = IMAGE_MIME_TYPES[ext];
} else if (isBinary) {
kind = "binary";
mimeType = "application/octet-stream";
}
await consume({
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
kind,
encoding: isBinary ? "binary" : "utf-8",
mimeType,
size: advertisedSize,
modifiedAt: stats.mtime.toISOString(),
revision: advertisedRevision,
chunks: readFileHandleChunks(handle, advertisedSize, advertisedRevision),
});
} finally {
await handle.close();
}
}
async function isFileHandleBinary(handle: FileHandle, advertisedSize: number): Promise<boolean> {
if (advertisedSize === 0) return false;
const decoder = new TextDecoder("utf-8", { fatal: true });
let position = 0;
let suspiciousBytes = 0;
while (position < advertisedSize) {
const block = Buffer.allocUnsafe(
Math.min(FILE_EXPLORER_STREAM_CHUNK_BYTES, advertisedSize - position),
);
const { bytesRead } = await handle.read(block, 0, block.byteLength, position);
if (bytesRead === 0) {
throw new Error("File changed during transfer");
}
const bytes = block.subarray(0, bytesRead);
for (const byte of bytes) {
if (byte === 0) return true;
const isControl = byte < 32 && byte !== 9 && byte !== 10 && byte !== 13;
if (isControl || byte === 127) suspiciousBytes += 1;
}
try {
decoder.decode(bytes, { stream: true });
} catch {
return true;
}
position += bytesRead;
}
try {
decoder.decode();
} catch {
return true;
}
return suspiciousBytes / advertisedSize > 0.3;
}
async function* readFileHandleChunks(
handle: FileHandle,
advertisedSize: number,
advertisedRevision: string,
): AsyncIterable<Uint8Array> {
let position = 0;
while (position < advertisedSize) {
const chunk = Buffer.allocUnsafe(
Math.min(FILE_EXPLORER_STREAM_CHUNK_BYTES, advertisedSize - position),
);
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, position);
if (bytesRead === 0) {
throw new Error("File changed during transfer");
}
position += bytesRead;
yield chunk.subarray(0, bytesRead);
}
const finalStats = await handle.stat({ bigint: true });
if (fileRevision(finalStats) !== advertisedRevision) {
throw new Error("File changed during transfer");
}
}
export async function getExplorerFileVersion({ export async function getExplorerFileVersion({
root, root,
relativePath, relativePath,

View File

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type pino from "pino"; import type pino from "pino";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay";
import { startRelayTransport } from "./relay-transport"; import { startRelayTransport } from "./relay-transport";
function createMockLogger() { function createMockLogger() {
@@ -32,7 +34,10 @@ class FakeRelayWebSocket {
sent: Array<string | Uint8Array | ArrayBuffer> = []; sent: Array<string | Uint8Array | ArrayBuffer> = [];
terminateCalls = 0; terminateCalls = 0;
pingCalls = 0; pingCalls = 0;
deferSendCompletion = false;
onSend: ((data: string | Uint8Array | ArrayBuffer) => void) | null = null;
private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>(); private readonly listeners = new Map<string, Array<(...args: unknown[]) => void>>();
private readonly pendingSendCallbacks: Array<(error?: Error) => void> = [];
constructor(readonly url: string) {} constructor(readonly url: string) {}
@@ -61,11 +66,22 @@ class FakeRelayWebSocket {
this.emit("close", 1006, ""); this.emit("close", 1006, "");
} }
send(data: string | Uint8Array | ArrayBuffer) { send(data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) {
if (this.readyState !== FakeRelayWebSocket.OPEN) { if (this.readyState !== FakeRelayWebSocket.OPEN) {
throw new Error(`WebSocket not open (readyState=${this.readyState})`); throw new Error(`WebSocket not open (readyState=${this.readyState})`);
} }
this.sent.push(data); this.sent.push(data);
this.onSend?.(data);
if (!callback) return;
if (this.deferSendCompletion) {
this.pendingSendCallbacks.push(callback);
return;
}
callback();
}
completeNextSend() {
this.pendingSendCallbacks.shift()?.();
} }
ping() { ping() {
@@ -80,8 +96,8 @@ class FakeRelayWebSocket {
this.emit("open"); this.emit("open");
} }
message(data: unknown) { message(data: unknown, isBinary = data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
this.emit("message", data); this.emit("message", data, isBinary);
} }
pong() { pong() {
@@ -239,6 +255,80 @@ describe("relay-transport control lifecycle", () => {
]); ]);
}); });
test("encrypted sends wait for the physical data socket callback", async () => {
const logger = createMockLogger();
const daemonKeyPair = generateKeyPair();
let resolveAttached: ((socket: unknown) => void) | undefined;
const attached = new Promise<unknown>((resolve) => {
resolveAttached = resolve;
});
const controller = startRelayTransport({
logger: logger as unknown as pino.Logger,
attachSocket: async (socket) => resolveAttached?.(socket),
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
daemonKeyPair,
createWebSocket: relay.createWebSocket,
});
controllers.push(controller);
const control = relay.sockets[0];
control.open();
control.message(JSON.stringify({ type: "sync", connectionIds: [] }), false);
control.message(JSON.stringify({ type: "connected", connectionId: "clt_test" }), false);
const dataSocket = relay.sockets[1];
dataSocket.deferSendCompletion = true;
dataSocket.open();
let clientTransport: Transport;
clientTransport = {
send: (data) => dataSocket.message(data, data instanceof ArrayBuffer),
close: () => undefined,
onmessage: null,
onclose: null,
onerror: null,
};
dataSocket.onSend = (data) => {
clientTransport.onmessage?.({
data: data instanceof Uint8Array ? data.slice().buffer : data,
isBinary: data instanceof ArrayBuffer || data instanceof Uint8Array,
});
};
let resolveClientOpen: (() => void) | undefined;
const clientOpen = new Promise<void>((resolve) => {
resolveClientOpen = resolve;
});
await createClientChannel(clientTransport, exportPublicKey(daemonKeyPair.publicKey), {
onopen: () => resolveClientOpen?.(),
});
let attachedCompleted = false;
void attached.then(() => {
attachedCompleted = true;
return undefined;
});
await clientOpen;
await Promise.resolve();
expect(attachedCompleted).toBe(false);
dataSocket.completeNextSend();
const encryptedSocket = (await attached) as {
send: (data: Uint8Array) => void | Promise<void>;
};
let completed = false;
const sending = Promise.resolve(encryptedSocket.send(new Uint8Array([1, 2, 3]))).then(() => {
completed = true;
return undefined;
});
await Promise.resolve();
expect(completed).toBe(false);
dataSocket.completeNextSend();
await sending;
expect(completed).toBe(true);
});
test("uses relayUseTls for control and data socket URLs", () => { test("uses relayUseTls for control and data socket URLs", () => {
const logger = createMockLogger(); const logger = createMockLogger();
const controller = startRelayTransport({ const controller = startRelayTransport({

View File

@@ -28,7 +28,7 @@ export interface RelayTransportController {
interface RelaySocketLike { interface RelaySocketLike {
readyState: number; readyState: number;
bufferedAmount?: number; bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void; send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
terminate?: () => void; terminate?: () => void;
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
@@ -467,16 +467,23 @@ function createRelayTransportAdapter(
logger: pino.Logger, logger: pino.Logger,
): RelayTransport { ): RelayTransport {
const relayTransport: RelayTransport = { const relayTransport: RelayTransport = {
send: (data) => { send: (data) =>
try { new Promise<void>((resolve, reject) => {
socket.send(data); try {
} catch (err) { socket.send(data, (error) => {
// Socket likely transitioned to closed between checks; let onclose/onerror if (!error) {
// drive cleanup. Without this guard the synchronous throw would propagate resolve();
// up as an uncaughtException and take down the daemon. return;
logger.warn({ err }, "relay_socket_send_failed"); }
} logger.warn({ err: error }, "relay_socket_send_failed");
}, reject(error);
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn({ err }, "relay_socket_send_failed");
reject(err);
}
}),
close: (code?: number, reason?: string) => socket.close(code, reason), close: (code?: number, reason?: string) => socket.close(code, reason),
onmessage: null, onmessage: null,
onclose: null, onclose: null,

View File

@@ -405,6 +405,7 @@ export interface SessionOptions {
onMessage: (msg: SessionOutboundMessage) => void; onMessage: (msg: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessage?: (frame: Uint8Array) => void;
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
getTransportBufferedAmount?: () => number | null; getTransportBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
@@ -568,6 +569,9 @@ export class Session {
| ((source: object, msg: SessionOutboundMessage) => void) | ((source: object, msg: SessionOutboundMessage) => void)
| null; | null;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly onBinaryMessageToSource:
| ((source: object, frame: Uint8Array) => Promise<void>)
| null;
private readonly getTransportBufferedAmount: () => number | null; private readonly getTransportBufferedAmount: () => number | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly onWorkspaceRecovered: private readonly onWorkspaceRecovered:
@@ -646,6 +650,7 @@ export class Session {
onMessage, onMessage,
onMessageToSource, onMessageToSource,
onBinaryMessage, onBinaryMessage,
onBinaryMessageToSource,
getTransportBufferedAmount, getTransportBufferedAmount,
onLifecycleIntent, onLifecycleIntent,
onWorkspaceRecovered, onWorkspaceRecovered,
@@ -698,6 +703,7 @@ export class Session {
this.onMessage = onMessage; this.onMessage = onMessage;
this.onMessageToSource = onMessageToSource ?? null; this.onMessageToSource = onMessageToSource ?? null;
this.onBinaryMessage = onBinaryMessage ?? null; this.onBinaryMessage = onBinaryMessage ?? null;
this.onBinaryMessageToSource = onBinaryMessageToSource ?? null;
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
@@ -711,8 +717,8 @@ export class Session {
}); });
this.workspaceFilesSession = new WorkspaceFilesSession({ this.workspaceFilesSession = new WorkspaceFilesSession({
host: { host: {
emit: (msg) => this.emit(msg), emit: (msg, source) => this.emitForSource(msg, source),
emitBinary: (frame) => this.emitBinary(frame), emitBinary: (frame, source) => this.emitBinaryForFileTransfer(frame, source),
hasBinaryChannel: () => this.onBinaryMessage !== null, hasBinaryChannel: () => this.onBinaryMessage !== null,
}, },
downloadTokenStore, downloadTokenStore,
@@ -1786,7 +1792,7 @@ export class Session {
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceRecoveryMessage(msg) ?? this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ?? this.dispatchProviderMessage(msg) ??
this.dispatchTerminalMessage(msg) ?? this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ?? this.dispatchChatScheduleLoopMessage(msg) ??
@@ -2106,10 +2112,13 @@ export class Session {
} }
} }
private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchWorkspaceFileMessage(
msg: SessionInboundMessage,
source?: object,
): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "file_explorer_request": case "file_explorer_request":
return this.workspaceFilesSession.handleFileExplorerRequest(msg); return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
case "fs.file.subscribe.request": case "fs.file.subscribe.request":
return this.workspaceFilesSession.handleFileSubscribeRequest(msg); return this.workspaceFilesSession.handleFileSubscribeRequest(msg);
case "fs.file.unsubscribe.request": case "fs.file.unsubscribe.request":
@@ -6532,6 +6541,22 @@ export class Session {
} }
} }
private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise<void> {
if (source && this.onBinaryMessageToSource) {
await this.onBinaryMessageToSource(source, frame);
return;
}
this.emitBinary(frame);
}
private emitForSource(msg: SessionOutboundMessage, source?: object): void {
if (source && this.onMessageToSource) {
this.onMessageToSource(source, msg);
return;
}
this.emit(msg);
}
/** /**
* Clean up session resources * Clean up session resources
*/ */

View File

@@ -30,13 +30,21 @@ function makeDir(prefix: string): string {
return dir; return dir;
} }
function makeSubsystem(options: { hasBinaryChannel?: boolean } = {}) { function makeSubsystem(
options: {
hasBinaryChannel?: boolean;
emitBinary?: (frame: Uint8Array) => Promise<void> | void;
} = {},
) {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const binary: Uint8Array[] = []; const binary: Uint8Array[] = [];
let hasBinary = options.hasBinaryChannel ?? false; let hasBinary = options.hasBinaryChannel ?? false;
const host: WorkspaceFilesSessionHost = { const host: WorkspaceFilesSessionHost = {
emit: (msg) => emitted.push(msg), emit: (msg) => emitted.push(msg),
emitBinary: (frame) => binary.push(frame), emitBinary: async (frame) => {
binary.push(frame);
await options.emitBinary?.(frame);
},
hasBinaryChannel: () => hasBinary, hasBinaryChannel: () => hasBinary,
}; };
const paseoHome = makeDir("workspace-files-home-"); const paseoHome = makeDir("workspace-files-home-");
@@ -136,6 +144,74 @@ describe("WorkspaceFilesSession", () => {
]); ]);
}); });
test("streams a real file larger than the socket limit as paced ordered chunks", async () => {
const cwd = makeDir("workspace-files-large-binary-");
const fileBytes = Buffer.alloc(8 * 1024 * 1024 + 123);
for (let index = 0; index < fileBytes.length; index += 1) {
fileBytes[index] = index % 251;
}
writeFileSync(join(cwd, "large.bin"), fileBytes);
let releaseFirstChunk: (() => void) | undefined;
const firstChunkSent = new Promise<void>((resolve) => {
releaseFirstChunk = resolve;
});
let chunkSends = 0;
const { subsystem, emitted, binary } = makeSubsystem({
hasBinaryChannel: true,
emitBinary: async (frame) => {
if (decodeFileTransferFrame(frame)?.opcode !== FileTransferOpcode.FileChunk) return;
chunkSends += 1;
if (chunkSends === 1) await firstChunkSent;
},
});
const transfer = subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: "large.bin",
mode: "file",
requestId: "req-large-binary",
acceptBinary: true,
});
await expect.poll(() => chunkSends).toBe(1);
expect(binary.map((frame) => decodeFileTransferFrame(frame)?.opcode)).toEqual([
FileTransferOpcode.FileBegin,
FileTransferOpcode.FileChunk,
]);
await subsystem.handleFileExplorerRequest({
type: "file_explorer_request",
cwd,
path: ".",
mode: "list",
requestId: "req-unrelated-list",
});
expect(emitted).toEqual([
expect.objectContaining({
type: "file_explorer_response",
payload: expect.objectContaining({ requestId: "req-unrelated-list", error: null }),
}),
]);
releaseFirstChunk?.();
await transfer;
const frames = binary.map((frame) => decodeFileTransferFrame(frame));
const chunks = frames.flatMap((frame) =>
frame?.opcode === FileTransferOpcode.FileChunk ? [frame.payload] : [],
);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.byteLength <= 256 * 1024)).toBe(true);
expect(
Buffer.compare(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))), fileBytes),
).toBe(0);
expect(frames.at(0)?.opcode).toBe(FileTransferOpcode.FileBegin);
expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd);
expect(emitted).toHaveLength(1);
}, 30_000);
test("rejects an empty file-explorer cwd with an error envelope", async () => { test("rejects an empty file-explorer cwd with an error envelope", async () => {
const { subsystem, emitted } = makeSubsystem(); const { subsystem, emitted } = makeSubsystem();

View File

@@ -21,7 +21,7 @@ import {
getDownloadableFileInfo, getDownloadableFileInfo,
listDirectoryEntries, listDirectoryEntries,
readExplorerFile, readExplorerFile,
readExplorerFileBytes, streamExplorerFile,
writeExplorerFile, writeExplorerFile,
} from "../../file-explorer/service.js"; } from "../../file-explorer/service.js";
import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js"; import { workspaceFileObserver, type FileObserver } from "../../file-explorer/observer.js";
@@ -34,8 +34,8 @@ import { getProjectIcon } from "../../../utils/project-icon.js";
* — old clients without a binary channel fall back to inline JSON file content. * — old clients without a binary channel fall back to inline JSON file content.
*/ */
export interface WorkspaceFilesSessionHost { export interface WorkspaceFilesSessionHost {
emit(msg: SessionOutboundMessage): void; emit(msg: SessionOutboundMessage, source?: object): void;
emitBinary(frame: Uint8Array): void; emitBinary(frame: Uint8Array, source?: object): Promise<void>;
hasBinaryChannel(): boolean; hasBinaryChannel(): boolean;
} }
@@ -136,22 +136,25 @@ export class WorkspaceFilesSession {
this.fileSubscriptions.clear(); this.fileSubscriptions.clear();
} }
async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> { async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
const cwd = workspaceCwd.trim(); const cwd = workspaceCwd.trim();
if (!cwd) { if (!cwd) {
this.host.emit({ this.host.emit(
type: "file_explorer_response", {
payload: { type: "file_explorer_response",
cwd: workspaceCwd, payload: {
path: requestedPath, cwd: workspaceCwd,
mode, path: requestedPath,
directory: null, mode,
file: null, directory: null,
error: "cwd is required", file: null,
requestId, error: "cwd is required",
requestId,
},
}, },
}); source,
);
return; return;
} }
@@ -162,69 +165,77 @@ export class WorkspaceFilesSession {
relativePath: requestedPath, relativePath: requestedPath,
}); });
this.host.emit({ this.host.emit(
type: "file_explorer_response", {
payload: { type: "file_explorer_response",
cwd, payload: {
path: directory.path, cwd,
mode, path: directory.path,
directory, mode,
file: null, directory,
error: null, file: null,
requestId, error: null,
requestId,
},
}, },
}); source,
);
} else { } else {
if (request.acceptBinary && this.host.hasBinaryChannel()) { if (request.acceptBinary && this.host.hasBinaryChannel()) {
const file = await readExplorerFileBytes({ await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
root: cwd, await this.host.emitBinary(
relativePath: requestedPath, encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
}),
source,
);
for await (const chunk of file.chunks) {
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: chunk,
}),
source,
);
}
await this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
source,
);
}); });
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
revision: file.revision,
},
}),
);
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: file.bytes,
}),
);
this.host.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
);
} else { } else {
const file = await readExplorerFile({ const file = await readExplorerFile({
root: cwd, root: cwd,
relativePath: requestedPath, relativePath: requestedPath,
}); });
this.host.emit({ this.host.emit(
type: "file_explorer_response", {
payload: { type: "file_explorer_response",
cwd, payload: {
path: file.path, cwd,
mode, path: file.path,
directory: null, mode,
file, directory: null,
error: null, file,
requestId, error: null,
requestId,
},
}, },
}); source,
);
} }
} }
} catch (error) { } catch (error) {
@@ -232,18 +243,21 @@ export class WorkspaceFilesSession {
{ err: error, cwd, path: requestedPath }, { err: error, cwd, path: requestedPath },
`Failed to fulfill file explorer request for workspace ${cwd}`, `Failed to fulfill file explorer request for workspace ${cwd}`,
); );
this.host.emit({ this.host.emit(
type: "file_explorer_response", {
payload: { type: "file_explorer_response",
cwd, payload: {
path: requestedPath, cwd,
mode, path: requestedPath,
directory: null, mode,
file: null, directory: null,
error: getErrorMessage(error), file: null,
requestId, error: getErrorMessage(error),
requestId,
},
}, },
}); source,
);
} }
} }

View File

@@ -0,0 +1,168 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { WebSocket, type RawData } from "ws";
import {
decodeFileTransferFrame,
FileTransferOpcode,
} from "@getpaseo/protocol/binary-frames/index";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/index.js";
import { WSOutboundMessageSchema, type WSOutboundMessage } from "./messages.js";
const TEST_TIMEOUT_MS = 30_000;
const FILE_SIZE = 8 * 1024 * 1024 + 123;
let daemon: TestPaseoDaemon | undefined;
const temporaryDirectories: string[] = [];
const sockets: WebSocket[] = [];
afterEach(async () => {
for (const socket of sockets.splice(0)) socket.terminate();
await daemon?.close();
daemon = undefined;
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
test(
"a large file stays ordered, source-scoped, and does not block another socket",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "paseo-large-file-transfer-"));
temporaryDirectories.push(cwd);
const expected = Buffer.alloc(FILE_SIZE);
for (let index = 0; index < expected.length; index += 1) expected[index] = index % 251;
writeFileSync(join(cwd, "large.bin"), expected);
daemon = await createTestPaseoDaemon();
const source = await connectSocket(daemon.port, "shared-file-client");
const unrelated = await connectSocket(daemon.port, "shared-file-client");
sockets.push(source, unrelated);
let unrelatedBinaryFrames = 0;
unrelated.on("message", (_data, isBinary) => {
if (isBinary) unrelatedBinaryFrames += 1;
});
const transfer = receiveFileTransfer(source, "req-large-file");
source.send(
JSON.stringify({
type: "session",
message: {
type: "file_explorer_request",
cwd,
path: "large.bin",
mode: "file",
acceptBinary: true,
requestId: "req-large-file",
},
}),
);
await sendAndWait(
unrelated,
{
type: "session",
message: { type: "ping", requestId: "req-unrelated", clientSentAt: 1 },
},
(message) =>
message.type === "session" &&
message.message.type === "pong" &&
message.message.payload.requestId === "req-unrelated",
);
const frames = await transfer;
const chunks = frames.flatMap((frame) =>
frame.opcode === FileTransferOpcode.FileChunk ? [frame.payload] : [],
);
expect(frames[0]?.opcode).toBe(FileTransferOpcode.FileBegin);
expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.byteLength <= 256 * 1024)).toBe(true);
expect(Buffer.compare(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))), expected)).toBe(
0,
);
expect(source.readyState).toBe(WebSocket.OPEN);
expect(unrelated.readyState).toBe(WebSocket.OPEN);
expect(unrelatedBinaryFrames).toBe(0);
},
TEST_TIMEOUT_MS,
);
async function connectSocket(port: number, clientId: string): Promise<WebSocket> {
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
await sendAndWait(
socket,
{ type: "hello", clientId, clientType: "browser", protocolVersion: 1 },
(message) =>
message.type === "session" &&
message.message.type === "status" &&
message.message.payload.status === "server_info",
);
return socket;
}
function receiveFileTransfer(source: WebSocket, requestId: string) {
return new Promise<NonNullable<ReturnType<typeof decodeFileTransferFrame>>[]>(
(resolve, reject) => {
const frames: NonNullable<ReturnType<typeof decodeFileTransferFrame>>[] = [];
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for file transfer"));
}, TEST_TIMEOUT_MS);
const onMessage = (data: RawData, isBinary: boolean) => {
if (!isBinary) return;
const frame = decodeFileTransferFrame(new Uint8Array(data as Buffer));
if (!frame || frame.requestId !== requestId) return;
frames.push(frame);
if (frame.opcode === FileTransferOpcode.FileEnd) {
cleanup();
resolve(frames);
}
};
const onClose = () => {
cleanup();
reject(new Error("Socket closed during file transfer"));
};
const cleanup = () => {
clearTimeout(timeout);
source.off("message", onMessage);
source.off("close", onClose);
};
source.on("message", onMessage);
source.on("close", onClose);
},
);
}
function sendAndWait(
socket: WebSocket,
message: unknown,
matches: (message: WSOutboundMessage) => boolean,
): Promise<WSOutboundMessage> {
const response = new Promise<WSOutboundMessage>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
reject(new Error("Timed out waiting for WebSocket message"));
}, TEST_TIMEOUT_MS);
const onMessage = (data: RawData, isBinary: boolean) => {
if (isBinary) return;
const parsed = WSOutboundMessageSchema.safeParse(JSON.parse(data.toString()));
if (!parsed.success || !matches(parsed.data)) return;
cleanup();
resolve(parsed.data);
};
const cleanup = () => {
clearTimeout(timeout);
socket.off("message", onMessage);
};
socket.on("message", onMessage);
});
socket.send(JSON.stringify(message));
return response;
}

View File

@@ -96,6 +96,7 @@ import {
outboundFrameByteLength, outboundFrameByteLength,
physicalSocketHasCapacity, physicalSocketHasCapacity,
sendBoundedPhysicalFrame, sendBoundedPhysicalFrame,
sendBoundedPhysicalFrameAndWait,
} from "./websocket/physical-socket.js"; } from "./websocket/physical-socket.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401; const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
@@ -371,7 +372,10 @@ function getBrowserHostCapability(
export interface WebSocketLike { export interface WebSocketLike {
readyState: number; readyState: number;
bufferedAmount?: number; bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void; send: (
data: string | Uint8Array | ArrayBuffer,
callback?: (error?: Error) => void,
) => void | Promise<void>;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
terminate?: () => void; terminate?: () => void;
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
@@ -423,6 +427,7 @@ interface SocketSessionOptions {
onMessage: (message: SessionOutboundMessage) => void; onMessage: (message: SessionOutboundMessage) => void;
onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; onMessageToSource?: (source: object, message: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessage?: (frame: Uint8Array) => void;
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
getTransportBufferedAmount?: () => number | null; getTransportBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
hubExecutionAgents?: HubExecutionAgents; hubExecutionAgents?: HubExecutionAgents;
@@ -1054,6 +1059,23 @@ export class VoiceAssistantWebSocketServer {
}); });
} }
private async sendBinaryToClientAndWait(ws: WebSocketLike, frame: Uint8Array): Promise<void> {
try {
const sent = await sendBoundedPhysicalFrameAndWait({
socket: ws,
frame,
onHighWater: () => this.closeAtOutboundHighWater(ws),
});
if (!sent) {
throw new Error("Physical WebSocket is not open");
}
this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount);
} catch (err) {
this.logger.warn({ err }, "ws_send_failed");
throw err;
}
}
private sendFrameToClient( private sendFrameToClient(
ws: WebSocketLike, ws: WebSocketLike,
frame: string | Uint8Array, frame: string | Uint8Array,
@@ -1226,6 +1248,12 @@ export class VoiceAssistantWebSocketServer {
} }
this.sendBinaryToConnection(connection, frame); this.sendBinaryToConnection(connection, frame);
}, },
onBinaryMessageToSource: async (source, frame) => {
if (!connection || !connection.sockets.has(source as WebSocketLike)) {
throw new Error("File transfer source socket is no longer attached");
}
await this.sendBinaryToClientAndWait(source as WebSocketLike, frame);
},
getTransportBufferedAmount: () => { getTransportBufferedAmount: () => {
if (!connection) { if (!connection) {
return null; return null;
@@ -1271,6 +1299,7 @@ export class VoiceAssistantWebSocketServer {
onMessage: options.onMessage, onMessage: options.onMessage,
onMessageToSource: options.onMessageToSource, onMessageToSource: options.onMessageToSource,
onBinaryMessage: options.onBinaryMessage, onBinaryMessage: options.onBinaryMessage,
onBinaryMessageToSource: options.onBinaryMessageToSource,
getTransportBufferedAmount: options.getTransportBufferedAmount, getTransportBufferedAmount: options.getTransportBufferedAmount,
onLifecycleIntent: options.onLifecycleIntent, onLifecycleIntent: options.onLifecycleIntent,
logger: options.connectionLogger.child({ module: "session" }), logger: options.connectionLogger.child({ module: "session" }),

View File

@@ -40,10 +40,11 @@ class BlockingChannel implements EncryptedRelayChannel {
test("negotiated binary ciphertext accepts the exact hard bound and rejects one byte over", async () => { test("negotiated binary ciphertext accepts the exact hard bound and rejects one byte over", async () => {
const channel = new BlockingChannel(); const channel = new BlockingChannel();
let terminations = 0; let terminations = 0;
let transportBufferedAmount = 0;
const socket = createEncryptedRelaySocket({ const socket = createEncryptedRelaySocket({
channel, channel,
emitter: new EventEmitter(), emitter: new EventEmitter(),
getTransportBufferedAmount: () => 0, getTransportBufferedAmount: () => transportBufferedAmount,
terminateTransport: () => { terminateTransport: () => {
terminations += 1; terminations += 1;
}, },
@@ -51,9 +52,11 @@ test("negotiated binary ciphertext accepts the exact hard bound and rejects one
socket.send(new Uint8Array(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 40)); socket.send(new Uint8Array(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES - 40));
expect(channel.sent).toHaveLength(1); expect(channel.sent).toHaveLength(1);
transportBufferedAmount = MAX_PHYSICAL_SOCKET_BUFFERED_BYTES;
expect(socket.bufferedAmount).toBe(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES); expect(socket.bufferedAmount).toBe(MAX_PHYSICAL_SOCKET_BUFFERED_BYTES);
socket.send(new Uint8Array(1)); const rejected = socket.send(new Uint8Array(1));
await expect(rejected).rejects.toThrow("outbound high-water mark");
expect(channel.sent).toHaveLength(1); expect(channel.sent).toHaveLength(1);
expect(terminations).toBe(1); expect(terminations).toBe(1);
@@ -64,7 +67,7 @@ test("negotiated binary ciphertext accepts the exact hard bound and rejects one
await Promise.resolve(); await Promise.resolve();
}); });
test("underlying relay backpressure rejects binary before encryption and terminates physically", () => { test("underlying relay backpressure rejects binary before encryption and terminates physically", async () => {
const channel = new BlockingChannel(); const channel = new BlockingChannel();
let terminations = 0; let terminations = 0;
const socket = createEncryptedRelaySocket({ const socket = createEncryptedRelaySocket({
@@ -76,36 +79,14 @@ test("underlying relay backpressure rejects binary before encryption and termina
}, },
}); });
socket.send(new Uint8Array(1)); const rejected = socket.send(new Uint8Array(1));
await expect(rejected).rejects.toThrow("outbound high-water mark");
expect(channel.sent).toEqual([]); expect(channel.sent).toEqual([]);
expect(channel.closes).toEqual([]); expect(channel.closes).toEqual([]);
expect(terminations).toBe(1); expect(terminations).toBe(1);
}); });
test("pending encryption and underlying relay backpressure share one hard bound", () => {
const channel = new BlockingChannel();
let transportBufferedAmount = 3 * 1024 * 1024;
let terminations = 0;
const socket = createEncryptedRelaySocket({
channel,
emitter: new EventEmitter(),
getTransportBufferedAmount: () => transportBufferedAmount,
terminateTransport: () => {
terminations += 1;
},
});
socket.send(new Uint8Array(3 * 1024 * 1024));
expect(channel.sent).toHaveLength(1);
transportBufferedAmount = 6 * 1024 * 1024;
socket.send(new Uint8Array(1));
expect(channel.sent).toHaveLength(1);
expect(terminations).toBe(1);
});
test("explicit encrypted-socket termination forcibly terminates the relay transport", () => { test("explicit encrypted-socket termination forcibly terminates the relay transport", () => {
const channel = new BlockingChannel(); const channel = new BlockingChannel();
let terminations = 0; let terminations = 0;
@@ -124,3 +105,42 @@ test("explicit encrypted-socket termination forcibly terminates the relay transp
expect(channel.closes).toEqual([]); expect(channel.closes).toEqual([]);
expect(socket.readyState).toBe(3); expect(socket.readyState).toBe(3);
}); });
test("encrypted sends report physical completion through the returned promise", async () => {
const channel = new BlockingChannel();
const socket = createEncryptedRelaySocket({
channel,
emitter: new EventEmitter(),
getTransportBufferedAmount: () => 0,
terminateTransport: () => undefined,
});
let completed = false;
const sending = socket.send(new Uint8Array([1]));
if (!sending) throw new Error("Expected an awaitable encrypted send");
void sending.then(() => (completed = true));
await Promise.resolve();
expect(completed).toBe(false);
channel.drain();
await sending;
expect(completed).toBe(true);
expect(socket.bufferedAmount).toBe(0);
});
test("encrypted sockets do not double-count bytes already buffered by the transport", () => {
const channel = new BlockingChannel();
let transportBufferedAmount = 0;
const socket = createEncryptedRelaySocket({
channel,
emitter: new EventEmitter(),
getTransportBufferedAmount: () => transportBufferedAmount,
terminateTransport: () => undefined,
});
const payload = new Uint8Array(3 * 1024 * 1024);
void socket.send(payload);
transportBufferedAmount = payload.byteLength + 40;
expect(socket.bufferedAmount).toBe(payload.byteLength + 40);
});

View File

@@ -11,7 +11,7 @@ export interface EncryptedRelayChannel {
export interface EncryptedRelaySocket { export interface EncryptedRelaySocket {
readonly readyState: number; readonly readyState: number;
readonly bufferedAmount: number; readonly bufferedAmount: number;
send: (data: string | Uint8Array | ArrayBuffer) => void; send: (data: string | Uint8Array | ArrayBuffer) => void | Promise<void>;
close: (code?: number, reason?: string) => void; close: (code?: number, reason?: string) => void;
terminate: () => void; terminate: () => void;
on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void; on: (event: "message" | "close" | "error", listener: (...args: unknown[]) => void) => void;
@@ -26,7 +26,6 @@ export function createEncryptedRelaySocket(params: {
}): EncryptedRelaySocket { }): EncryptedRelaySocket {
const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params; const { channel, emitter, getTransportBufferedAmount, terminateTransport } = params;
let readyState = 1; let readyState = 1;
let pendingEncryptedBytes = 0;
channel.setState("open"); channel.setState("open");
@@ -51,26 +50,25 @@ export function createEncryptedRelaySocket(params: {
return readyState; return readyState;
}, },
get bufferedAmount() { get bufferedAmount() {
return pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); return getTransportBufferedAmount() ?? 0;
}, },
send: (data) => { send: (data) => {
if (readyState !== 1) return; if (readyState !== 1) {
return Promise.reject(new Error("Encrypted relay socket is not open"));
}
const outbound = normalizeRelaySendPayload(data); const outbound = normalizeRelaySendPayload(data);
const outboundBytes = channel.outboundWireByteLength(outbound); const outboundBytes = channel.outboundWireByteLength(outbound);
const queuedBytes = pendingEncryptedBytes + (getTransportBufferedAmount() ?? 0); const queuedBytes = getTransportBufferedAmount() ?? 0;
if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) { if (queuedBytes + outboundBytes > MAX_PHYSICAL_SOCKET_BUFFERED_BYTES) {
terminate(); terminate();
return; return Promise.reject(
new Error("Encrypted relay socket exceeded its outbound high-water mark"),
);
} }
pendingEncryptedBytes += outboundBytes; return channel.send(outbound).catch((error) => {
void channel emitter.emit("error", error);
.send(outbound) throw error;
.catch((error) => { });
emitter.emit("error", error);
})
.finally(() => {
pendingEncryptedBytes -= outboundBytes;
});
}, },
close, close,
terminate, terminate,

View File

@@ -4,6 +4,7 @@ import {
ApplicationSocketLease, ApplicationSocketLease,
MAX_PHYSICAL_SOCKET_BUFFERED_BYTES, MAX_PHYSICAL_SOCKET_BUFFERED_BYTES,
sendBoundedPhysicalFrame, sendBoundedPhysicalFrame,
sendBoundedPhysicalFrameAndWait,
} from "./physical-socket.js"; } from "./physical-socket.js";
test("sockets remain exempt until they send an application ping", () => { test("sockets remain exempt until they send an application ping", () => {
@@ -66,3 +67,56 @@ test("the shared physical send boundary rejects binary above the hard bound", ()
expect(sent).toEqual([]); expect(sent).toEqual([]);
expect(terminated).toBe(true); expect(terminated).toBe(true);
}); });
test("the awaitable physical send resolves only when that frame send completes", async () => {
const sent: Array<string | Uint8Array | ArrayBuffer> = [];
let completeSend: (() => void) | undefined;
const socket = {
readyState: 1,
bufferedAmount: 0,
send: (_data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => {
sent.push(_data);
if (callback) completeSend = () => callback();
},
};
let completed = false;
const sending = sendBoundedPhysicalFrameAndWait({
socket,
frame: new Uint8Array([1, 2, 3]),
onHighWater: () => undefined,
}).then(() => {
return (completed = true);
});
await Promise.resolve();
expect(completed).toBe(false);
expect(
sendBoundedPhysicalFrame({
socket,
frame: "unrelated",
onHighWater: () => undefined,
}),
).toBe(true);
expect(sent).toEqual([new Uint8Array([1, 2, 3]), "unrelated"]);
completeSend?.();
await sending;
expect(completed).toBe(true);
});
test("the awaitable physical send rejects callback errors", async () => {
const socket = {
readyState: 1,
bufferedAmount: 0,
send: (_data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) =>
callback?.(new Error("send failed")),
};
await expect(
sendBoundedPhysicalFrameAndWait({
socket,
frame: new Uint8Array([1]),
onHighWater: () => undefined,
}),
).rejects.toThrow("send failed");
});

View File

@@ -50,7 +50,39 @@ export function outboundFrameByteLength(data: string | Uint8Array | ArrayBuffer)
interface BoundedPhysicalSocket { interface BoundedPhysicalSocket {
readyState: number; readyState: number;
bufferedAmount?: number; bufferedAmount?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void; send: (
data: string | Uint8Array | ArrayBuffer,
callback?: (error?: Error) => void,
) => void | Promise<void>;
}
export async function sendBoundedPhysicalFrameAndWait(params: {
socket: BoundedPhysicalSocket;
frame: string | Uint8Array | ArrayBuffer;
frameBytes?: number;
onHighWater: () => void;
}): Promise<boolean> {
const { socket, frame, frameBytes = outboundFrameByteLength(frame), onHighWater } = params;
if (socket.readyState !== 1) return false;
if (!physicalSocketHasCapacity(socket, frameBytes)) {
onHighWater();
return false;
}
await new Promise<void>((resolve, reject) => {
let callbackUsed = false;
const result = socket.send(frame, (error) => {
callbackUsed = true;
if (error) reject(error);
else resolve();
});
if (result && typeof result.then === "function") {
result.then(resolve, reject);
} else if (socket.send.length < 2 && !callbackUsed) {
resolve();
}
});
return true;
} }
export function physicalSocketHasCapacity( export function physicalSocketHasCapacity(
@@ -73,6 +105,9 @@ export function sendBoundedPhysicalFrame(params: {
onHighWater(); onHighWater();
return false; return false;
} }
socket.send(frame); const result = socket.send(frame);
if (result && typeof result.then === "function") {
void result.catch(() => undefined);
}
return true; return true;
} }