fix(files): keep large source previews responsive (#3665)
Large read-only files bypassed the editor's virtualization and eagerly mounted every highlighted token, which could stall the renderer for seconds and consume gigabytes of memory. Route source previews through bounded platform renderers and reject reads above their display budgets before accumulation.
This commit is contained in:
@@ -2212,6 +2212,61 @@ test("readFile resolves from binary file frames when the daemon supports them",
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||
});
|
||||
|
||||
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_file_budget_compat",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
|
||||
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
cwd: "/tmp/project",
|
||||
path: "large.txt",
|
||||
mode: "file",
|
||||
acceptBinary: true,
|
||||
maxBytes: 10,
|
||||
requestId: "req-budget",
|
||||
},
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: "req-budget",
|
||||
metadata: {
|
||||
mime: "text/plain",
|
||||
size: 100,
|
||||
encoding: "utf-8",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: "req-budget",
|
||||
payload: new Uint8Array(100),
|
||||
}),
|
||||
);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
|
||||
);
|
||||
|
||||
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||
});
|
||||
|
||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -831,6 +831,7 @@ interface CorrelatedResponseIdentity {
|
||||
interface PendingBinaryFileRead {
|
||||
cwd: string;
|
||||
path: string;
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
interface BinaryFileTransferState extends PendingBinaryFileRead {
|
||||
@@ -4308,6 +4309,7 @@ export class DaemonClient {
|
||||
mode: "list" | "file",
|
||||
requestId?: string,
|
||||
acceptBinary = false,
|
||||
maxBytes?: number,
|
||||
): Promise<FileExplorerPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4317,6 +4319,7 @@ export class DaemonClient {
|
||||
path,
|
||||
mode,
|
||||
...(acceptBinary ? { acceptBinary: true } : {}),
|
||||
...(maxBytes ? { maxBytes } : {}),
|
||||
},
|
||||
responseType: "file_explorer_response",
|
||||
});
|
||||
@@ -4337,11 +4340,23 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
async readFile(cwd: string, path: string, requestId?: string): Promise<FileReadResult> {
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
): Promise<FileReadResult> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path });
|
||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||
try {
|
||||
const payload = await this.requestFileExplorer(cwd, path, "file", resolvedRequestId, true);
|
||||
const payload = await this.requestFileExplorer(
|
||||
cwd,
|
||||
path,
|
||||
"file",
|
||||
resolvedRequestId,
|
||||
true,
|
||||
maxBytes,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -5761,10 +5776,33 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
if (frame.opcode === FileTransferOpcode.FileChunk) {
|
||||
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||
// Old daemons stream despite maxBytes; discard before client-side accumulation.
|
||||
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||
return;
|
||||
}
|
||||
transfer.chunks.push(frame.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// COMPAT(fileReadByteBudget): added in v0.5.0, remove after 2027-02-21 once daemon floor >= v0.5.0.
|
||||
if (transfer.maxBytes && transfer.size > transfer.maxBytes) {
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.handleSessionMessage({
|
||||
type: "file_explorer_response",
|
||||
payload: {
|
||||
cwd: transfer.cwd,
|
||||
path: transfer.path,
|
||||
mode: "file",
|
||||
directory: null,
|
||||
file: null,
|
||||
error: "File is too large to display",
|
||||
requestId: frame.requestId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.completedBinaryFileReads.set(frame.requestId, {
|
||||
|
||||
Reference in New Issue
Block a user