From 2bf69e6d8e8f34ced26ebde671acef9bfc467b2a Mon Sep 17 00:00:00 2001 From: Michael Vandesompel Date: Thu, 8 Oct 2026 21:47:26 +0200 Subject: [PATCH] Let readFile callers disable the request timeout readFile resolves only when FileEnd arrives, so the default 60 s request timeout bounds the whole transfer. A large file over a slow relay fails before it finishes even though every chunk is arriving. Thread an optional timeout through requestFileExplorer; 0 disables the timer, as the workspace.create request already does, and a dead connection still rejects on disconnect. The relay e2e case reads a 1 MiB file through a real daemon and a local relay with timeout 0 and compares SHA-256 digests with the source. --- packages/client/src/daemon-client.test.ts | 62 +++++++++++++++++ packages/client/src/daemon-client.ts | 9 +++ .../daemon-e2e/relay-transport.e2e.test.ts | 68 +++++++++++++++++++ 3 files changed, 139 insertions(+) diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index f62f46b733..85c9fee29f 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2801,6 +2801,68 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r await expect(responsePromise).rejects.toThrow("File is too large to display"); }); +async function connectFileClient(clientId: string) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId, + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + return { mock, client }; +} + +function sendBinaryFile( + mock: ReturnType, + requestId: string, + announcedSize: number, + chunks: string[], +) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "application/octet-stream", + size: announcedSize, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + }, + }), + ); + for (const chunk of chunks) { + mock.triggerMessage( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(chunk), + }), + ); + } +} + +test("readFile with timeout 0 outlives the default request timeout", async () => { + useHeartbeatClock(); + const { mock, client } = await connectFileClient("clsk_file_no_timeout"); + + const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0); + await vi.advanceTimersByTimeAsync(60_001); + + sendBinaryFile(mock, "req-slow", 5, ["hello"]); + mock.triggerMessage( + encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }), + ); + + const result = await responsePromise; + expect(result.size).toBe(5); + expect(new TextDecoder().decode(result.bytes)).toBe("hello"); +}); + test("uploadFile sends metadata request and file bytes as binary chunks", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 2bd19f2974..e717b709de 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4758,6 +4758,7 @@ export class DaemonClient { requestId?: string, acceptBinary = false, maxBytes?: number, + timeout?: number, ): Promise { return this.sendCorrelatedSessionRequest({ requestId, @@ -4770,6 +4771,7 @@ export class DaemonClient { ...(maxBytes ? { maxBytes } : {}), }, responseType: "file_explorer_response", + timeout, }); } @@ -4788,11 +4790,17 @@ export class DaemonClient { return payload.directory; } + /** + * `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as + * `workspace.create` does) for files that legitimately take longer than the default over a + * slow link; a dead connection still rejects on disconnect. + */ async readFile( cwd: string, path: string, requestId?: string, maxBytes?: number, + timeout?: number, ): Promise { const resolvedRequestId = this.createRequestId(requestId); this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes }); @@ -4804,6 +4812,7 @@ export class DaemonClient { resolvedRequestId, true, maxBytes, + timeout, ); if (payload.error) { throw new Error(payload.error); diff --git a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts index d204842e0c..b6dc8e6570 100644 --- a/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/relay-transport.e2e.test.ts @@ -8,9 +8,13 @@ import net from "node:net"; import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { Buffer } from "node:buffer"; +import { createHash, randomBytes } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; import { generateLocalPairingOffer } from "../pairing-offer.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { deriveSharedKey, @@ -75,6 +79,10 @@ function decodeOfferFromFragmentUrl(url: string): { return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 }; } +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + function encodeCiphertext(ciphertext: ArrayBuffer): string { return Buffer.from(new Uint8Array(ciphertext)).toString("base64"); } @@ -860,4 +868,64 @@ async function waitForCapturedLog( await stopRelay(); } }, 90000); + + test("reads a workspace file over the E2EE relay session byte-for-byte", async () => { + process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12"; + + const { logger, lines } = createCapturingLogger(); + await startRelay({ useLocalRelay: true }); + + const daemon = await createTestPaseoDaemon({ + listen: "127.0.0.1", + logger, + relayEnabled: true, + relayEndpoint: `127.0.0.1:${relayPort}`, + }); + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-")); + const fileName = "rapport données.bin"; + const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123); + await writeFile(path.join(workspaceDir, fileName), source); + let client: DaemonClient | null = null; + + try { + const offerUrl = await getPairingOfferUrl({ + paseoHome: daemon.paseoHome, + relayEnabled: daemon.config.relayEnabled, + relayEndpoint: daemon.config.relayEndpoint, + relayPublicEndpoint: daemon.config.relayPublicEndpoint, + appBaseUrl: daemon.config.appBaseUrl, + }); + const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl); + + client = new DaemonClient({ + url: buildRelayWebSocketUrl({ + endpoint: `127.0.0.1:${relayPort}`, + useTls: false, + serverId, + role: "client", + }), + clientType: "cli", + connectTimeoutMs: 30_000, + e2ee: { enabled: true, daemonPublicKeyB64 }, + reconnect: { enabled: false }, + }); + await client.connect(); + + const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0); + + expect(result.size).toBe(source.byteLength); + expect(result.bytes.byteLength).toBe(source.byteLength); + expect(sha256(result.bytes)).toBe(sha256(source)); + } catch (err) { + const tail = lines.slice(-50).join(""); + // eslint-disable-next-line no-console + console.error("daemon logs (tail):\n", tail); + throw err; + } finally { + await client?.close().catch(() => undefined); + await daemon.close(); + await stopRelay(); + await rm(workspaceDir, { recursive: true, force: true }); + } + }, 90000); });