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.
This commit is contained in:
Michael Vandesompel
2026-10-14 14:15:11 +02:00
committed by paseo-bot[bot]
parent cafa04d3fb
commit 2bf69e6d8e
3 changed files with 139 additions and 0 deletions
+62
View File
@@ -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<typeof createMockTransport>,
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();
+9
View File
@@ -4758,6 +4758,7 @@ export class DaemonClient {
requestId?: string,
acceptBinary = false,
maxBytes?: number,
timeout?: number,
): Promise<FileExplorerPayload> {
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<FileReadResult> {
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);
@@ -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);
});