Skip to content

Commit 87ae81c

Browse files
committed
computer: Expose ranged workspace reads
Mirror readRange on the Workers RPC filesystem facade and have WorkspaceFileStore request fixed-size byte windows instead of streaming and discarding every skipped byte.
1 parent b85296c commit 87ae81c

4 files changed

Lines changed: 106 additions & 70 deletions

File tree

packages/computer/src/stub.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ describe("WorkspaceStub", () => {
205205
});
206206
});
207207

208+
it("fs.readRange forwards bounded byte reads", async () => {
209+
await withStub(async (ws) => {
210+
const stub = ws.stub();
211+
await stub.fs.writeFile("/bin", new Uint8Array([1, 2, 3, 4, 5]));
212+
expect(Array.from(await stub.fs.readRange("/bin", 1, 3))).toEqual([2, 3, 4]);
213+
});
214+
});
215+
208216
it("fs.readdir forwards bounded-read options", async () => {
209217
await withStub(async (ws) => {
210218
const stub = ws.stub();

packages/computer/src/stub.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ export class WorkspaceFilesystemStub extends RpcTarget {
114114
);
115115
}
116116

117+
readRange(path: string, offset: number, length: number): Promise<Uint8Array> {
118+
return withSpan(
119+
this.#ws.observer,
120+
"workspace.fs.readRange",
121+
{ "workspace.fs.path": path, "workspace.fs.offset": offset, "workspace.fs.length": length },
122+
() => this.#ws.fs.readRange(path, offset, length),
123+
);
124+
}
125+
117126
exists(path: string): Promise<boolean> {
118127
return withSpan(
119128
this.#ws.observer,

packages/computer/src/tools/ai.test.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,33 +179,63 @@ function memoryStore(options: {
179179
}
180180

181181
describe("WorkspaceFileStore", () => {
182-
it("slices byte ranges while reading chunks from Workspace.fs", async () => {
183-
const workspace = makeWorkspace();
184-
await workspace.fs.mkdir("/workspace", { recursive: true });
185-
await workspace.fs.writeFile("/workspace/range.txt", bytes("abcdefghij"));
182+
it("uses readRange without streaming skipped bytes from Workspace.fs", async () => {
183+
const calls: Array<{ offset: number; length: number }> = [];
184+
const content = bytes("abcdefghij");
185+
const workspace = {
186+
fs: {
187+
async stat() {
188+
return {
189+
size: content.length,
190+
mtime: 1,
191+
mode: 0o100644,
192+
isFile: true,
193+
isDirectory: false,
194+
};
195+
},
196+
async readRange(_path: string, offset: number, length: number) {
197+
calls.push({ offset, length });
198+
return content.slice(offset, offset + length);
199+
},
200+
async readFile(): Promise<ReadableStream<Uint8Array>> {
201+
throw new Error("readFile must not be called for ranged reads");
202+
},
203+
async writeFile() {},
204+
async mkdir() {},
205+
async rm() {},
206+
async readdir() {
207+
return [];
208+
},
209+
},
210+
};
186211
const store = new WorkspaceFileStore(workspace);
187212

188213
await expect(
189214
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
190215
).resolves.toBe("cdefg");
216+
expect(calls).toEqual([{ offset: 2, length: 5 }]);
191217
});
192218

193-
it("cancels read streams when a byte range stops before EOF", async () => {
194-
let cancelled = false;
219+
it("splits unbounded reads into fixed-size ranges", async () => {
220+
const calls: Array<{ offset: number; length: number }> = [];
221+
const content = new Uint8Array(150_000).fill(7);
195222
const workspace = {
196223
fs: {
197224
async stat() {
198-
return { size: 10, mtime: 1, mode: 0o100644, isFile: true, isDirectory: false };
225+
return {
226+
size: content.length,
227+
mtime: 1,
228+
mode: 0o100644,
229+
isFile: true,
230+
isDirectory: false,
231+
};
199232
},
200-
async readFile() {
201-
return new ReadableStream<Uint8Array>({
202-
start(controller) {
203-
controller.enqueue(bytes("abcdefghij"));
204-
},
205-
cancel() {
206-
cancelled = true;
207-
},
208-
});
233+
async readRange(_path: string, offset: number, length: number) {
234+
calls.push({ offset, length });
235+
return content.slice(offset, offset + length);
236+
},
237+
async readFile(): Promise<ReadableStream<Uint8Array>> {
238+
throw new Error("readFile must not be called for ranged reads");
209239
},
210240
async writeFile() {},
211241
async mkdir() {},
@@ -217,10 +247,14 @@ describe("WorkspaceFileStore", () => {
217247
};
218248
const store = new WorkspaceFileStore(workspace);
219249

220-
await expect(
221-
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
222-
).resolves.toBe("cdefg");
223-
expect(cancelled).toBe(true);
250+
await expect(drainChunks(store.readChunks("/workspace/large.bin"))).resolves.toHaveLength(
251+
content.length,
252+
);
253+
expect(calls).toEqual([
254+
{ offset: 0, length: 65_536 },
255+
{ offset: 65_536, length: 65_536 },
256+
{ offset: 131_072, length: 18_928 },
257+
]);
224258
});
225259
});
226260

packages/computer/src/tools/fs/store.ts

Lines changed: 35 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
* class. This adapter is the bridge from that contract to the public
77
* `workspace.fs` surface.
88
*
9-
* Reads go through `fs.readFile(path)` as a `ReadableStream<Uint8Array>`
10-
* and are stitched together either chunk-by-chunk (`readChunks`) or all
11-
* at once (`readAll`).
9+
* Bounded reads go through `fs.readRange`; whole-file reads used by edit
10+
* and multimodal output still drain `fs.readFile(path)`.
1211
*/
1312

1413
import type { FileStat, FileStore } from "./types.js";
1514

15+
const RANGE_CHUNK_BYTES = 64 * 1024;
16+
1617
/**
1718
* Structural subset of `@cloudflare/computer.Workspace` the tools
1819
* depend on.
@@ -27,10 +28,23 @@ export interface WorkspaceLike {
2728
isDirectory: boolean;
2829
}>;
2930
readFile(path: string): Promise<ReadableStream<Uint8Array>>;
31+
readRange(path: string, offset: number, length: number): Promise<Uint8Array>;
3032
writeFile(path: string, content: Uint8Array, options?: { mode?: number }): Promise<void>;
3133
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
3234
rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
33-
readdir(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean }>>;
35+
readdir(
36+
path: string,
37+
options?: { limit?: number; offset?: number },
38+
): Promise<
39+
Array<{
40+
name: string;
41+
size: number;
42+
mtime: number;
43+
isFile: boolean;
44+
isDirectory: boolean;
45+
isSymbolicLink: boolean;
46+
}>
47+
>;
3448
};
3549
}
3650

@@ -64,55 +78,26 @@ export class WorkspaceFileStore implements FileStore {
6478
}
6579

6680
async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable<Uint8Array> {
67-
if (byteOffset < 0) throw new Error("readChunks: byteOffset must be non-negative");
68-
if (byteLength !== undefined && byteLength < 0) {
69-
throw new Error("readChunks: byteLength must be non-negative");
81+
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
82+
throw new Error("readChunks: byteOffset must be a non-negative safe integer");
83+
}
84+
if (byteLength !== undefined && (!Number.isSafeInteger(byteLength) || byteLength < 0)) {
85+
throw new Error("readChunks: byteLength must be a non-negative safe integer");
7086
}
7187
if (byteLength === 0) return;
7288

73-
const stream = await this.ws.fs.readFile(path);
74-
const reader = stream.getReader();
75-
let skipped = 0;
76-
let yielded = 0;
77-
let completed = false;
78-
try {
79-
while (true) {
80-
const { value, done } = await reader.read();
81-
if (done) {
82-
completed = true;
83-
break;
84-
}
85-
if (!value || value.byteLength === 0) continue;
86-
87-
let start = 0;
88-
if (skipped < byteOffset) {
89-
const needed = byteOffset - skipped;
90-
if (value.byteLength <= needed) {
91-
skipped += value.byteLength;
92-
continue;
93-
}
94-
start = needed;
95-
skipped = byteOffset;
96-
}
97-
98-
let end = value.byteLength;
99-
if (byteLength !== undefined) {
100-
const remaining = byteLength - yielded;
101-
if (remaining <= 0) break;
102-
end = Math.min(end, start + remaining);
103-
}
104-
105-
if (end > start) {
106-
const chunk = value.slice(start, end);
107-
yielded += chunk.byteLength;
108-
yield chunk;
109-
}
110-
111-
if (byteLength !== undefined && yielded >= byteLength) break;
112-
}
113-
} finally {
114-
if (!completed) await reader.cancel();
115-
reader.releaseLock();
89+
const stat = await this.ws.fs.stat(path);
90+
let remaining = Math.max(0, stat.size - byteOffset);
91+
if (byteLength !== undefined) remaining = Math.min(remaining, byteLength);
92+
let offset = byteOffset;
93+
while (remaining > 0) {
94+
const requested = Math.min(remaining, RANGE_CHUNK_BYTES);
95+
const chunk = await this.ws.fs.readRange(path, offset, requested);
96+
if (chunk.byteLength === 0) return;
97+
yield chunk;
98+
offset += chunk.byteLength;
99+
remaining -= chunk.byteLength;
100+
if (chunk.byteLength < requested) return;
116101
}
117102
}
118103
}

0 commit comments

Comments
 (0)