chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
328
third_party/pi/packages/codemode/test/declarations.test.ts
vendored
Normal file
328
third_party/pi/packages/codemode/test/declarations.test.ts
vendored
Normal file
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderDeclarations, renderToolSample, renderToolSignature, schemaToType } from "../src/index.ts";
|
||||
|
||||
const execute = () => undefined;
|
||||
|
||||
describe("schemaToType", () => {
|
||||
it("renders primitives, literals, and unions", () => {
|
||||
expect(schemaToType({ type: "string" })).toBe("string");
|
||||
expect(schemaToType({ type: "integer" })).toBe("number");
|
||||
expect(schemaToType({ type: ["string", "null"] })).toBe("string | null");
|
||||
expect(schemaToType({ const: "a" })).toBe('"a"');
|
||||
expect(schemaToType({ enum: ["a", 1, null] })).toBe('"a" | 1 | null');
|
||||
expect(schemaToType({ anyOf: [{ type: "string" }, { type: "number" }] })).toBe("string | number");
|
||||
expect(schemaToType({ anyOf: [{ type: "string" }, {}] })).toBe("unknown");
|
||||
expect(schemaToType({ allOf: [{ anyOf: [{ type: "string" }, { type: "number" }] }, { const: 1 }] })).toBe(
|
||||
"(string | number) & 1",
|
||||
);
|
||||
expect(schemaToType({ $ref: "#/defs/x" })).toBe("unknown");
|
||||
expect(schemaToType(true)).toBe("unknown");
|
||||
expect(schemaToType(false)).toBe("never");
|
||||
});
|
||||
|
||||
it("renders objects on one line with sorted properties", () => {
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: { city: { type: "string" }, "max-lines": { type: "number" } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
).toBe('{ city: string; "max-lines"?: number; }');
|
||||
expect(schemaToType({ type: "object", additionalProperties: { type: "number" } })).toBe(
|
||||
"{ [key: string]: number; }",
|
||||
);
|
||||
expect(schemaToType({ type: "object" })).toBe("{ [key: string]: unknown; }");
|
||||
expect(schemaToType({ type: "object", properties: {}, additionalProperties: false })).toBe("{}");
|
||||
});
|
||||
|
||||
it("puts property descriptions on comment lines", () => {
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
weather: {
|
||||
type: "array",
|
||||
description: "look up weather for a given list of locations",
|
||||
items: { type: "object", properties: { location: { type: "string" } }, required: ["location"] },
|
||||
},
|
||||
},
|
||||
required: ["weather"],
|
||||
}),
|
||||
).toBe("{\n // look up weather for a given list of locations\n weather: Array<{ location: string; }>;\n}");
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
outer: {
|
||||
type: "object",
|
||||
description: "Outer",
|
||||
properties: { inner: { type: "string", description: "Inner" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("{\n // Outer\n outer?: {\n // Inner\n inner?: string;\n };\n}");
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/10707
|
||||
it("puts constraints the type cannot express on property comment lines", () => {
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
timeoutMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 30000,
|
||||
default: 0,
|
||||
description: "Wait in milliseconds.",
|
||||
},
|
||||
id: { type: "string", pattern: "^[a-z]+$", maxLength: 8 },
|
||||
tags: { type: "array", items: { type: "string" }, maxItems: 5, uniqueItems: true, deprecated: true },
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" // @maxLength 8 @pattern ^[a-z]+$",
|
||||
" id: string;",
|
||||
" name?: string;",
|
||||
" // @maxItems 5 @uniqueItems @deprecated",
|
||||
" tags?: Array<string>;",
|
||||
" // Wait in milliseconds.",
|
||||
" // @integer @minimum 0 @maximum 30000 @default 0",
|
||||
" timeoutMs?: number;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads constraints of optional fields from the non-null branch", () => {
|
||||
// Pydantic's `limit: int | None = Field(None, ge=1, le=100)`.
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: {
|
||||
anyOf: [{ type: "integer", minimum: 1, maximum: 100 }, { type: "null" }],
|
||||
default: null,
|
||||
title: "Limit",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("{\n // @integer @minimum 1 @maximum 100\n limit?: number | null;\n}");
|
||||
// Constraints of the property and the branch both apply.
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
n: {
|
||||
anyOf: [{ type: "integer", minimum: 10, maximum: 50, format: "int32" }, { type: "null" }],
|
||||
minimum: 0,
|
||||
maximum: 20,
|
||||
format: "port",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("{\n // @integer @minimum 10 @maximum 20 @format port @format int32\n n?: number | null;\n}");
|
||||
});
|
||||
|
||||
it("reads draft-04 boolean exclusive bounds", () => {
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
n: { type: "integer", minimum: 0, exclusiveMinimum: true, maximum: 10, exclusiveMaximum: false },
|
||||
m: { anyOf: [{ type: "number", maximum: 5, exclusiveMaximum: true }, { type: "null" }], maximum: 8 },
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" // @maximum 8 @exclusiveMaximum 5",
|
||||
" m?: number | null;",
|
||||
" // @integer @exclusiveMinimum 0 @maximum 10",
|
||||
" n?: number;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out long tags and escapes line terminators", () => {
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: { config: { type: "object", default: { text: "x".repeat(100) } } },
|
||||
}),
|
||||
).toBe("{ config?: { [key: string]: unknown; }; }");
|
||||
expect(
|
||||
schemaToType({
|
||||
type: "object",
|
||||
properties: {
|
||||
sep: { type: "string", pattern: "^[\r\n\u2028\u2029]$", default: "\u2028", description: "a\u2028b" },
|
||||
},
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"{",
|
||||
" // a",
|
||||
" // b",
|
||||
' // @pattern ^[\\u000d\\u000a\\u2028\\u2029]$ @default "\\u2028"',
|
||||
" sep?: string;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops constraint tags before rendering a type over the budget as unknown", () => {
|
||||
const schema = { type: "object", properties: { n: { type: "integer", minimum: 1 } } };
|
||||
expect(schemaToType(schema)).toBe("{\n // @integer @minimum 1\n n?: number;\n}");
|
||||
expect(schemaToType(schema, { maxChars: 20 })).toBe("{ n?: number; }");
|
||||
expect(schemaToType(schema, { maxChars: 10 })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("resolves local references and stops at recursive ones", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
item: { $ref: "#/$defs/Item" },
|
||||
legacy: { $ref: "#/definitions/Legacy" },
|
||||
remote: { $ref: "https://example.com/schema.json" },
|
||||
},
|
||||
required: ["item"],
|
||||
$defs: {
|
||||
Item: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, parent: { $ref: "#/$defs/Item" } },
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
definitions: { Legacy: { enum: ["a", "b"] } },
|
||||
};
|
||||
expect(schemaToType(schema)).toBe(
|
||||
'{ item: { id: string; parent?: unknown; }; legacy?: "a" | "b"; remote?: unknown; }',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders arrays and tuples", () => {
|
||||
expect(schemaToType({ type: "array", items: { type: "string" } })).toBe("Array<string>");
|
||||
expect(schemaToType({ type: "array", prefixItems: [{ type: "string" }, { type: "number" }] })).toBe(
|
||||
"[string, number]",
|
||||
);
|
||||
expect(schemaToType({ type: "array" })).toBe("unknown[]");
|
||||
});
|
||||
|
||||
it("renders types over the budget as unknown", () => {
|
||||
const properties = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`field${i}`, { type: "string" }]));
|
||||
const schema = { type: "object", properties };
|
||||
expect(schemaToType(schema, { maxChars: 100 })).toBe("unknown");
|
||||
expect(schemaToType(schema)).toContain("field49?: string;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool declarations", () => {
|
||||
it("renders signatures with normalized identifiers", () => {
|
||||
expect(
|
||||
renderToolSignature({
|
||||
name: "hidden-dynamic-tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] },
|
||||
}),
|
||||
).toBe("hidden_dynamic_tool(args: { city: string; }): Promise<{ ok: boolean; }>;");
|
||||
expect(renderToolSignature({ name: "free" })).toBe("free(args: unknown): Promise<unknown>;");
|
||||
});
|
||||
|
||||
it("renders output schemas shaped like an MCP CallToolResult as plain types", () => {
|
||||
const outputSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "array", items: { type: "object" } },
|
||||
isError: { type: "boolean" },
|
||||
_meta: { type: "object" },
|
||||
},
|
||||
required: ["content"],
|
||||
};
|
||||
expect(
|
||||
renderToolSignature({ name: "plain", inputSchema: { type: "object", properties: {} }, outputSchema }),
|
||||
).toBe(
|
||||
"plain(args: { [key: string]: unknown; }): Promise<{ _meta?: { [key: string]: unknown; }; content: Array<{ [key: string]: unknown; }>; isError?: boolean; }>;",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the per-tool sample", () => {
|
||||
expect(renderToolSample({ name: "foo", description: "bar", inputSchema: { type: "string" } })).toBe(
|
||||
"bar\n\ncodemode tool declaration:\n```ts\ndeclare const tools: { foo(args: string): Promise<unknown>; };\n```",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDeclarations", () => {
|
||||
it("renders tools and globals", () => {
|
||||
const text = renderDeclarations({
|
||||
tools: [
|
||||
{
|
||||
name: "read",
|
||||
description: "Read a file.\nSecond line.",
|
||||
inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
||||
outputSchema: { type: "string" },
|
||||
execute,
|
||||
},
|
||||
{ name: "remote-api", execute },
|
||||
],
|
||||
globals: [{ name: "attach", description: "Attach it.", inputSchema: { type: "string" }, execute }],
|
||||
});
|
||||
expect(text).toBe(
|
||||
[
|
||||
"declare const tools: {",
|
||||
" /**",
|
||||
" * Read a file.",
|
||||
" * Second line.",
|
||||
" */",
|
||||
" read(args: { path: string; }): Promise<string>;",
|
||||
" remote_api(args: unknown): Promise<unknown>;",
|
||||
"};",
|
||||
"",
|
||||
"/** Attach it. */",
|
||||
"declare function attach(args: string): Promise<unknown>;",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders namespaced globals and explicit signatures", () => {
|
||||
const text = renderDeclarations({
|
||||
globals: [
|
||||
{
|
||||
name: "models.list",
|
||||
description: "List models.",
|
||||
signature: "(type: string): Promise<string[]>",
|
||||
execute,
|
||||
},
|
||||
{ name: "models.get", inputSchema: { type: "string" }, execute },
|
||||
{ name: "plain", signature: "(): void", execute },
|
||||
],
|
||||
});
|
||||
expect(text).toBe(
|
||||
[
|
||||
"declare function plain(): void;",
|
||||
"",
|
||||
"declare const models: {",
|
||||
" /** List models. */",
|
||||
" list(type: string): Promise<string[]>;",
|
||||
" get(args: string): Promise<unknown>;",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes comment terminators in descriptions", () => {
|
||||
const text = renderDeclarations({ tools: [{ name: "x", description: "a */ b", execute }] });
|
||||
expect(text).toContain("/** a *\\/ b */");
|
||||
});
|
||||
});
|
||||
410
third_party/pi/packages/codemode/test/deterministic.test.ts
vendored
Normal file
410
third_party/pi/packages/codemode/test/deterministic.test.ts
vendored
Normal file
@@ -0,0 +1,410 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CodemodeHostCall,
|
||||
type CodemodeOutcome,
|
||||
type CodemodeRemoteExchange,
|
||||
type CodemodeRun,
|
||||
type CodemodeRunOptions,
|
||||
CodemodeSandbox,
|
||||
type CodemodeSandboxBase,
|
||||
type CodemodeSandboxBaseOptions,
|
||||
loadQuickJSWasm,
|
||||
RemoteCodemodeSandbox,
|
||||
serveCodemodeRemote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
/**
|
||||
* `start()` runs a script deterministically with the caller answering its calls, so a durable host can replay it
|
||||
* after a restart. Every test runs on the worker sandbox (Node) and the remote sandbox (Cloudflare), whose transport
|
||||
* clones every message as Workers RPC would.
|
||||
*/
|
||||
const BUDGET = 50_000;
|
||||
/** `delayFirst` holds the reply to the first result back, so later results reach the VM before it. */
|
||||
function remote(options: CodemodeSandboxBaseOptions, delayFirst = false): RemoteCodemodeSandbox {
|
||||
return new RemoteCodemodeSandbox({
|
||||
...options,
|
||||
remote: () => ({
|
||||
execute: (request, exchange) => {
|
||||
const wrapped: CodemodeRemoteExchange = async (messages) => {
|
||||
const reply = structuredClone(await exchange(structuredClone(messages)));
|
||||
if (delayFirst && reply?.type === "result" && reply.seq === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
return reply;
|
||||
};
|
||||
return serveCodemodeRemote(structuredClone(request), wrapped, {
|
||||
wasm: loadQuickJSWasm(),
|
||||
maxInterruptBudget: BUDGET,
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
const BACKENDS: Record<string, (options?: CodemodeSandboxBaseOptions) => CodemodeSandboxBase> = {
|
||||
worker: (options = {}) => new CodemodeSandbox(options),
|
||||
remote: (options = {}) => remote(options),
|
||||
};
|
||||
|
||||
const STARTED_AT = Date.UTC(2026, 0, 15, 10, 0, 0);
|
||||
|
||||
type Recorded = {
|
||||
run: CodemodeRun;
|
||||
calls: CodemodeHostCall[];
|
||||
idles: [seq: number, issued: number][];
|
||||
events: string[];
|
||||
fingerprint: () => string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start `code` with tools `echo` and `wait`. Every call but `wait` is answered at once, by default with its argument,
|
||||
* as result number 1, 2, ... with the clock at `STARTED_AT + 1000 * seq`; `wait` is left for the test to answer.
|
||||
*/
|
||||
function start(
|
||||
sandbox: CodemodeSandboxBase,
|
||||
code: string,
|
||||
options: Partial<CodemodeRunOptions> & { answer?: (call: CodemodeHostCall) => CodemodeOutcome } = {},
|
||||
): Recorded {
|
||||
const calls: CodemodeHostCall[] = [];
|
||||
const idles: [number, number][] = [];
|
||||
const events: string[] = [];
|
||||
let fingerprint: string | undefined;
|
||||
let seq = 0;
|
||||
const answer = options.answer ?? ((call: CodemodeHostCall) => ({ ok: true, value: call.args }));
|
||||
const run: CodemodeRun = sandbox.start(code, {
|
||||
startedAt: STARTED_AT,
|
||||
seed: 1,
|
||||
tools: [{ name: "echo" }, { name: "wait" }],
|
||||
timeoutMs: 20_000,
|
||||
...options,
|
||||
onStarted: (value) => {
|
||||
fingerprint = value;
|
||||
events.push("started");
|
||||
},
|
||||
onCall: (call) => {
|
||||
calls.push(call);
|
||||
events.push(`call ${call.id}`);
|
||||
if (call.name === "wait") return;
|
||||
queueMicrotask(() => {
|
||||
seq++;
|
||||
run.deliver(seq, STARTED_AT + 1000 * seq, call.id, answer(call));
|
||||
});
|
||||
},
|
||||
onIdle: (seq, issued) => {
|
||||
idles.push([seq, issued]);
|
||||
events.push(`idle ${seq}`);
|
||||
},
|
||||
});
|
||||
return { run, calls, idles, events, fingerprint: () => fingerprint };
|
||||
}
|
||||
|
||||
const savedTimezone = process.env.TZ;
|
||||
afterEach(() => {
|
||||
if (savedTimezone === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = savedTimezone;
|
||||
});
|
||||
|
||||
for (const [backend, create] of Object.entries(BACKENDS)) {
|
||||
describe(`deterministic runs (${backend})`, () => {
|
||||
it("makes the same calls and output for the same start, seed, and results", async () => {
|
||||
const code = `
|
||||
const values = [Date.now(), performance.now(), Math.random(), Math.random(), new Date().toISOString()];
|
||||
const first = await tools.echo(values);
|
||||
text(JSON.stringify([Date.now(), performance.now(), Math.random()]));
|
||||
return await tools.echo(first.concat(Math.random()));
|
||||
`;
|
||||
const sandbox = create();
|
||||
const one = start(sandbox, code);
|
||||
const two = start(sandbox, code);
|
||||
const [a, b] = await Promise.all([one.run.result, two.run.result]);
|
||||
// Call durations are wall-clock time; everything else is the same.
|
||||
const timeless = (result: typeof a) => ({
|
||||
...result,
|
||||
calls: result.calls.map(({ name, status }) => ({ name, status })),
|
||||
});
|
||||
expect(timeless(a)).toEqual(timeless(b));
|
||||
expect(one.calls).toEqual(two.calls);
|
||||
// The clock: startedAt, then the time of the result being processed; performance.now() counts from startedAt.
|
||||
const [now, elapsed, random] = one.calls[0]!.args as number[];
|
||||
expect([now, elapsed]).toEqual([STARTED_AT, 0]);
|
||||
expect((one.calls[0]!.args as unknown[])[4]).toBe("2026-01-15T10:00:00.000Z");
|
||||
const printed = JSON.parse(a.ok ? (a.output[0] as { text: string }).text : "[]") as number[];
|
||||
expect(printed.slice(0, 2)).toEqual([STARTED_AT + 1000, 1000]);
|
||||
// Pinned: a change here changes what replayed scripts see, so it must change the fingerprint too.
|
||||
expect(random).toBe(PINNED_FIRST_RANDOM);
|
||||
|
||||
// The seed decides the random values, not the start time.
|
||||
const later = start(sandbox, code, { startedAt: STARTED_AT + 5000 });
|
||||
const reseeded = start(sandbox, code, { seed: 2 });
|
||||
await Promise.all([later.run.result, reseeded.run.result]);
|
||||
expect((later.calls[0]!.args as number[])[2]).toBe(random);
|
||||
expect((reseeded.calls[0]!.args as number[])[2]).not.toBe(random);
|
||||
// Seed 0 has its own values: QuickJS turns a zero state into 1, which must not make it seed 1's.
|
||||
const zero = start(sandbox, "return [Math.random(), Math.random()]", { seed: 0 });
|
||||
const zeroResult = await zero.run.result;
|
||||
expect(zeroResult).toMatchObject({ ok: true, value: PINNED_SEED_ZERO });
|
||||
expect((zeroResult.ok ? zeroResult.value : []) as number[]).not.toContain(random);
|
||||
});
|
||||
|
||||
it("runs in UTC whatever the host's timezone, and without WeakRef or FinalizationRegistry", async () => {
|
||||
process.env.TZ = "Asia/Kolkata";
|
||||
const code =
|
||||
"return [new Date(0).getHours(), new Date(0).getTimezoneOffset(), typeof WeakRef, typeof FinalizationRegistry]";
|
||||
const sandbox = create();
|
||||
const { run } = start(sandbox, code);
|
||||
expect(await run.result).toMatchObject({ ok: true, value: [0, 0, "undefined", "undefined"] });
|
||||
// execute() is unchanged: the host's timezone and every intrinsic.
|
||||
expect(await sandbox.execute(code)).toMatchObject({ ok: true, value: [5, -330, "function", "function"] });
|
||||
});
|
||||
|
||||
it("applies results in seq order, whatever order they are delivered in", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
`const order = [];
|
||||
await Promise.all([tools.wait("a").then(() => order.push("a")), tools.wait("b").then(() => order.push("b"))]);
|
||||
return order;`,
|
||||
);
|
||||
await waitFor(() => recorded.calls.length === 2 && recorded.idles.length === 1);
|
||||
const [a, b] = recorded.calls;
|
||||
// b is result 1 and a result 2, but a arrives first.
|
||||
recorded.run.deliver(2, STARTED_AT + 2000, a!.id, { ok: true, value: null });
|
||||
recorded.run.deliver(1, STARTED_AT + 1000, b!.id, { ok: true, value: null });
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: ["b", "a"] });
|
||||
// No idle after the last result: the script finished in that drain, and the host stopped the VM.
|
||||
expect(recorded.idles).toEqual([
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports the fingerprint before any call and the calls issued after each result", async () => {
|
||||
const sandbox = create({ memoryLimitBytes: 64 * 1024 * 1024 });
|
||||
const recorded = start(sandbox, "await tools.echo(1); await Promise.all([tools.echo(2), tools.echo(3)]);");
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true });
|
||||
expect(recorded.events.slice(0, 3)).toEqual(["started", "call 1", "idle 0"]);
|
||||
expect(recorded.idles).toEqual([
|
||||
[0, 1],
|
||||
[1, 3],
|
||||
[2, 3],
|
||||
]);
|
||||
expect(recorded.calls.map((call) => [call.id, call.target, call.name, call.args])).toEqual([
|
||||
[1, "tool", "echo", 1],
|
||||
[2, "tool", "echo", 2],
|
||||
[3, "tool", "echo", 3],
|
||||
]);
|
||||
const fingerprint = recorded.fingerprint()!;
|
||||
expect(fingerprint).toMatch(
|
||||
/^quickjs-wasi \S+, quickjs \S+, prelude [0-9a-f]{8}, deterministic \d+, stack \d+, heap 67108864, budget \S+$/,
|
||||
);
|
||||
if (backend === "remote") expect(fingerprint).toContain(`budget ${BUDGET}`);
|
||||
else expect(fingerprint).toContain("budget none");
|
||||
|
||||
const same = start(sandbox, "return 1");
|
||||
const other = start(create({ memoryLimitBytes: 32 * 1024 * 1024 }), "return 1");
|
||||
await Promise.all([same.run.result, other.run.result]);
|
||||
expect(same.fingerprint()).toBe(fingerprint);
|
||||
expect(other.fingerprint()).not.toBe(fingerprint);
|
||||
});
|
||||
|
||||
it("numbers calls to globals with tools, and answers errors as rejections", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
`const found = await search("x", 2);
|
||||
try { await tools.echo("fail"); } catch (error) { text(error.message); }
|
||||
return found;`,
|
||||
{
|
||||
globals: [{ name: "search", spread: true }],
|
||||
answer: (call) =>
|
||||
call.args === "fail"
|
||||
? { ok: false, message: "no" }
|
||||
: { ok: true, value: call.name === "search" ? ["found"] : call.args },
|
||||
},
|
||||
);
|
||||
const result = await recorded.run.result;
|
||||
expect(recorded.calls[0]).toEqual({ id: 1, target: "global", name: "search", args: ["x", 2] });
|
||||
expect(result).toMatchObject({ ok: true, value: ["found"], output: [{ type: "text", text: "no" }] });
|
||||
expect(recorded.calls[1]).toMatchObject({ id: 2, target: "tool", name: "echo" });
|
||||
// Calls to globals count as issued calls too.
|
||||
expect(recorded.idles.slice(0, 2)).toEqual([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
]);
|
||||
expect(result.calls).toMatchObject([{ name: "echo", status: "error" }]);
|
||||
});
|
||||
|
||||
it("keeps the clock monotonic and rejects a clock value that is not finite", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
"const before = Date.now(); await tools.wait(1); return [before, Date.now(), performance.now()]",
|
||||
);
|
||||
await waitFor(() => recorded.calls.length === 1);
|
||||
expect(() => recorded.run.deliver(1, Number.NaN, 1, { ok: true, value: null })).toThrow("not a finite number");
|
||||
// A resumed host whose clock is behind the run's start.
|
||||
recorded.run.deliver(1, STARTED_AT - 2000, 1, { ok: true, value: null });
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: [STARTED_AT, STARTED_AT, 0] });
|
||||
});
|
||||
|
||||
it("reports the result the VM was processing when the script ended", async () => {
|
||||
const sandbox = create();
|
||||
const returned = start(sandbox, "await tools.echo(1); return await tools.echo(2)");
|
||||
const thrown = start(
|
||||
sandbox,
|
||||
"await tools.echo(1); await Promise.all([tools.echo(2), tools.echo(3)]); throw new Error('x')",
|
||||
);
|
||||
const first = start(sandbox, "return 1");
|
||||
expect(await returned.run.result).toMatchObject({ ok: true, value: 2, during: { seq: 2, issued: 2 } });
|
||||
expect(await thrown.run.result).toMatchObject({ ok: false, during: { seq: 3, issued: 3 } });
|
||||
expect(await first.run.result).toMatchObject({ ok: true, during: { seq: 0, issued: 0 } });
|
||||
expect(await sandbox.execute("return 1")).not.toHaveProperty("during");
|
||||
});
|
||||
|
||||
it("leaves a call waiting when its answer cannot be serialized", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(sandbox, "return await tools.wait(1)");
|
||||
await waitFor(() => recorded.calls.length === 1);
|
||||
expect(() => recorded.run.deliver(1, STARTED_AT, 1, { ok: true, value: 1n })).toThrow();
|
||||
expect(() =>
|
||||
recorded.run.deliver(1, STARTED_AT, 1, { ok: false, message: "x", properties: { big: 1n } }),
|
||||
).toThrow();
|
||||
recorded.run.deliver(1, STARTED_AT, 1, { ok: true, value: "fine" });
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: "fine" });
|
||||
});
|
||||
|
||||
it("rejects with an Error carrying the answer's properties", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
"try { await tools.echo(1); } catch (e) { return [e instanceof Error, e.message, e.name, e.structuredOutput]; }",
|
||||
{
|
||||
answer: () => ({
|
||||
ok: false,
|
||||
message: "denied",
|
||||
properties: { structuredOutput: { code: 7 }, name: "Ignored", message: "ignored" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: [true, "denied", "Error", { code: 7 }] });
|
||||
});
|
||||
|
||||
it("rejects rejection properties that are not an object, and tools sharing a script name", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(sandbox, "return await tools.wait(1)");
|
||||
await waitFor(() => recorded.calls.length === 1);
|
||||
for (const properties of [null, [1]] as unknown as Record<string, unknown>[]) {
|
||||
expect(() => recorded.run.deliver(1, STARTED_AT, 1, { ok: false, message: "x", properties })).toThrow(
|
||||
"not an object",
|
||||
);
|
||||
}
|
||||
recorded.run.deliver(1, STARTED_AT, 1, { ok: true, value: 1 });
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: 1 });
|
||||
expect(() =>
|
||||
sandbox.start("", {
|
||||
startedAt: STARTED_AT,
|
||||
seed: 1,
|
||||
tools: [{ name: "a-b" }, { name: "a_b" }],
|
||||
onCall: () => {},
|
||||
}),
|
||||
).toThrow("script names must be unique");
|
||||
});
|
||||
|
||||
it("uses the script names the run passes", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
"return [typeof tools.renamed, Object.keys(tools), ALL_TOOLS.map((tool) => tool.name)]",
|
||||
{
|
||||
tools: [{ name: "my-tool", jsName: "renamed" }],
|
||||
},
|
||||
);
|
||||
expect(await recorded.run.result).toMatchObject({
|
||||
ok: true,
|
||||
value: ["function", ["renamed", "my-tool"], ["renamed"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("ends with an error the script cannot catch when the caller aborts", async () => {
|
||||
const sandbox = create();
|
||||
const controller = new AbortController();
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
`try { await tools.wait(1); } catch { text("caught"); }
|
||||
text("after");`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await waitFor(() => recorded.calls.length === 1);
|
||||
controller.abort(new Error("diverged"));
|
||||
const result = await recorded.run.result;
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "aborted", message: "diverged" }, output: [] });
|
||||
expect(() => recorded.run.deliver(1, STARTED_AT, 1, { ok: true, value: null })).not.toThrow();
|
||||
});
|
||||
|
||||
it("is finished once it has its outcome", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(sandbox, "await tools.wait(1); return 2");
|
||||
await waitFor(() => recorded.calls.length === 1);
|
||||
expect(recorded.run.finished).toBe(false);
|
||||
recorded.run.deliver(1, STARTED_AT, 1, { ok: true, value: null });
|
||||
await recorded.run.result;
|
||||
expect(recorded.run.finished).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a call that is not waiting and a result number used twice", async () => {
|
||||
const sandbox = create();
|
||||
const recorded = start(sandbox, "await Promise.all([tools.wait(1), tools.wait(2)])");
|
||||
await waitFor(() => recorded.calls.length === 2);
|
||||
const { run } = recorded;
|
||||
expect(() => run.deliver(1, STARTED_AT, 99, { ok: true, value: null })).toThrow("Call 99 is not waiting");
|
||||
run.deliver(1, STARTED_AT, 1, { ok: true, value: null });
|
||||
expect(() => run.deliver(1, STARTED_AT, 2, { ok: true, value: null })).toThrow("used once");
|
||||
expect(() => run.deliver(0, STARTED_AT, 2, { ok: true, value: null })).toThrow("used once");
|
||||
run.deliver(2, STARTED_AT, 2, { ok: true, value: null });
|
||||
expect(await run.result).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("deterministic runs (remote transport)", () => {
|
||||
it("applies results in seq order when the transport reorders replies sent in order", async () => {
|
||||
const sandbox = remote({}, true);
|
||||
const recorded = start(
|
||||
sandbox,
|
||||
`const order = [];
|
||||
await Promise.all([tools.wait("a").then(() => order.push("a")), tools.wait("b").then(() => order.push("b"))]);
|
||||
return order;`,
|
||||
);
|
||||
await waitFor(() => recorded.calls.length === 2);
|
||||
const [a, b] = recorded.calls;
|
||||
recorded.run.deliver(1, STARTED_AT + 1000, a!.id, { ok: true, value: null });
|
||||
recorded.run.deliver(2, STARTED_AT + 2000, b!.id, { ok: true, value: null });
|
||||
expect(await recorded.run.result).toMatchObject({ ok: true, value: ["a", "b"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute() is not deterministic", () => {
|
||||
it("reads the real clock and real randomness", async () => {
|
||||
const sandbox = new CodemodeSandbox();
|
||||
const before = Date.now();
|
||||
const first = await sandbox.execute("return [Date.now(), Math.random()]");
|
||||
const second = await sandbox.execute("return [Date.now(), Math.random()]");
|
||||
const [now, random] = (first.ok ? first.value : []) as number[];
|
||||
expect(now).toBeGreaterThanOrEqual(before);
|
||||
expect(now).toBeLessThanOrEqual(Date.now());
|
||||
expect((second.ok ? second.value : []) as number[]).not.toContain(random);
|
||||
});
|
||||
});
|
||||
|
||||
async function waitFor(check: () => boolean): Promise<void> {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!check()) {
|
||||
if (Date.now() > deadline) throw new Error("Condition was not reached");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
/** The first two `Math.random()` values with seed 0. */
|
||||
const PINNED_SEED_ZERO = [0.6711372530266764, 0.7258461452833667];
|
||||
|
||||
/** The first `Math.random()` with seed 1. */
|
||||
const PINNED_FIRST_RANDOM = 0.34227450605335275;
|
||||
8
third_party/pi/packages/codemode/test/fixtures/raw-worker.ts
vendored
Normal file
8
third_party/pi/packages/codemode/test/fixtures/raw-worker.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Stands in for the real worker in host tests: posts the JSON message (or array of messages) passed
|
||||
* as the script source, so tests can send the host payloads the real prelude never produces.
|
||||
*/
|
||||
import { parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
const messages: unknown = JSON.parse((workerData as { code: string }).code);
|
||||
for (const message of Array.isArray(messages) ? messages : [messages]) parentPort?.postMessage(message);
|
||||
51
third_party/pi/packages/codemode/test/fixtures/worker-process-exit.ts
vendored
Normal file
51
third_party/pi/packages/codemode/test/fixtures/worker-process-exit.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CodemodeSandbox } from "../../src/runtime/host.ts";
|
||||
|
||||
const how = process.argv[2];
|
||||
if (how !== "abort" && how !== "timeout" && how !== "close") throw new Error(`Unknown stop mode: ${how}`);
|
||||
const started = performance.now();
|
||||
process.on("exit", () => {
|
||||
console.log(JSON.stringify({ type: "exit", elapsedMs: performance.now() - started }));
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let workerExited = false;
|
||||
const sandbox = new CodemodeSandbox({
|
||||
memoryLimitBytes: 256 * 1024 * 1024,
|
||||
onWorkerStarted: (exited) => {
|
||||
void exited.then(() => {
|
||||
workerExited = true;
|
||||
});
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: "ready",
|
||||
execute: () => {
|
||||
if (how !== "timeout") {
|
||||
timer = setTimeout(() => {
|
||||
if (how === "abort") controller.abort();
|
||||
else void sandbox.close();
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const result = await sandbox.execute(
|
||||
"await tools.ready(); return 'a'.repeat(300000).indexOf('a'.repeat(10000) + 'b')",
|
||||
{ signal: controller.signal, timeoutMs: how === "timeout" ? 300 : Number.POSITIVE_INFINITY },
|
||||
);
|
||||
if (result.ok) throw new Error("The script completed instead of being cancelled");
|
||||
await sandbox.close();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
kind: result.error.kind,
|
||||
elapsedMs: performance.now() - started,
|
||||
workerExited,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await sandbox.close();
|
||||
}
|
||||
159
third_party/pi/packages/codemode/test/portable.test.ts
vendored
Normal file
159
third_party/pi/packages/codemode/test/portable.test.ts
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type CodemodeRemote,
|
||||
type CodemodeRemoteExchange,
|
||||
type CodemodeRemoteRequest,
|
||||
type CodemodeServeOptions,
|
||||
InlineCodemodeSandbox,
|
||||
loadQuickJSWasm,
|
||||
RemoteCodemodeSandbox,
|
||||
serveCodemodeRemote,
|
||||
} from "../src/index.ts";
|
||||
|
||||
const wasm = loadQuickJSWasm();
|
||||
|
||||
/** An in-process remote that records what crosses the boundary, cloned as Workers RPC would. */
|
||||
function recordingRemote(options: Partial<CodemodeServeOptions> = {}) {
|
||||
const exchanges: number[] = [];
|
||||
const served: Promise<void>[] = [];
|
||||
const remote: CodemodeRemote = {
|
||||
execute(request: CodemodeRemoteRequest, exchange: CodemodeRemoteExchange) {
|
||||
const done = serveCodemodeRemote(
|
||||
structuredClone(request),
|
||||
async (messages) => {
|
||||
exchanges.push(messages.length);
|
||||
return structuredClone(await exchange(structuredClone(messages)));
|
||||
},
|
||||
{ wasm, ...options },
|
||||
);
|
||||
served.push(done);
|
||||
return done;
|
||||
},
|
||||
};
|
||||
return { remote, exchanges, served };
|
||||
}
|
||||
|
||||
describe("InlineCodemodeSandbox", () => {
|
||||
it("fails a script that computes past its interrupt budget as a timeout", async () => {
|
||||
const sandbox = new InlineCodemodeSandbox({ wasm, interruptBudget: 1_000 });
|
||||
for (const code of ["while (true) {}", "while (true) await null"]) {
|
||||
const result = await sandbox.execute(code, { timeoutMs: Number.POSITIVE_INFINITY });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "timeout", message: "Execution exceeded its interrupt budget of 1000" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("counts the budget across waits on tools", async () => {
|
||||
const sandbox = new InlineCodemodeSandbox({
|
||||
wasm,
|
||||
interruptBudget: 1_000,
|
||||
tools: [{ name: "tick", execute: () => 1 }],
|
||||
});
|
||||
const result = await sandbox.execute("for (;;) { for (let i = 0; i < 1e5; i++); await tools.tick(); }");
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
|
||||
expect(result.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RemoteCodemodeSandbox", () => {
|
||||
it("runs tools in the caller and the script on the remote", async () => {
|
||||
const { remote, served } = recordingRemote();
|
||||
const seen: unknown[] = [];
|
||||
const sandbox = new RemoteCodemodeSandbox({
|
||||
remote: () => remote,
|
||||
tools: [
|
||||
{
|
||||
name: "add",
|
||||
execute: (args) => {
|
||||
seen.push(args);
|
||||
return (args as { a: number }).a + 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await sandbox.execute("text('hi'); return await tools.add({ a: 1 })", { store: { k: 1 } });
|
||||
expect(result).toMatchObject({ ok: true, value: 2, output: [{ type: "text", text: "hi" }] });
|
||||
expect(seen).toEqual([{ a: 1 }]);
|
||||
expect(result.calls).toMatchObject([{ name: "add", status: "ok" }]);
|
||||
await Promise.all(served);
|
||||
});
|
||||
|
||||
it("batches output instead of making one exchange per line", async () => {
|
||||
const { remote, exchanges } = recordingRemote();
|
||||
const sandbox = new RemoteCodemodeSandbox({ remote: () => remote });
|
||||
const result = await sandbox.execute("for (let i = 0; i < 5000; i++) console.log(i); return 'ok'");
|
||||
expect(result).toMatchObject({ ok: true, value: "ok" });
|
||||
expect(result.output).toHaveLength(5000);
|
||||
expect(result.output[4999]).toEqual({ type: "text", text: "4999", console: true });
|
||||
expect(exchanges.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("caps the interrupt budget and memory at the remote's limits", async () => {
|
||||
const { remote } = recordingRemote({ maxInterruptBudget: 1_000, maxMemoryLimitBytes: 8 * 1024 * 1024 });
|
||||
const sandbox = new RemoteCodemodeSandbox({ remote: () => remote, interruptBudget: 1e9 });
|
||||
expect(await sandbox.execute("while (true) {}")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "timeout", message: "Execution exceeded its interrupt budget of 1000" },
|
||||
});
|
||||
const memory = await sandbox.execute(
|
||||
"const parts = []; for (;;) parts.push('x'.repeat(1 << 20) + parts.length);",
|
||||
);
|
||||
expect(memory).toMatchObject({ ok: false });
|
||||
expect(JSON.stringify(memory)).toContain("out of memory");
|
||||
});
|
||||
|
||||
it("tells the remote to stop when the caller aborts", async () => {
|
||||
const { remote, served } = recordingRemote();
|
||||
let started: () => void = () => {};
|
||||
const call = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
const sandbox = new RemoteCodemodeSandbox({
|
||||
remote: () => remote,
|
||||
tools: [
|
||||
{
|
||||
name: "hang",
|
||||
execute: () => {
|
||||
started();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = sandbox.execute("await tools.hang(); return 'never'", { signal: controller.signal });
|
||||
await call;
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({ ok: false, error: { kind: "aborted" } });
|
||||
// The remote's pending exchange is answered with "stop", so its VM is disposed and execute() returns.
|
||||
await Promise.all(served);
|
||||
});
|
||||
|
||||
it("reports a failing or vanishing remote as a sandbox error", async () => {
|
||||
const failing = new RemoteCodemodeSandbox({
|
||||
remote: () => ({ execute: () => Promise.reject(new Error("object reset")) }),
|
||||
});
|
||||
expect(await failing.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "Remote sandbox failed: object reset" },
|
||||
});
|
||||
|
||||
const silent = new RemoteCodemodeSandbox({ remote: () => ({ execute: () => Promise.resolve() }) });
|
||||
expect(await silent.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "Remote sandbox returned before the script settled" },
|
||||
});
|
||||
|
||||
const unreachable = new RemoteCodemodeSandbox({
|
||||
remote: () => {
|
||||
throw new Error("no binding");
|
||||
},
|
||||
});
|
||||
expect(await unreachable.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "Failed to reach the remote sandbox: no binding" },
|
||||
});
|
||||
});
|
||||
});
|
||||
869
third_party/pi/packages/codemode/test/sandbox.test.ts
vendored
Normal file
869
third_party/pi/packages/codemode/test/sandbox.test.ts
vendored
Normal file
@@ -0,0 +1,869 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CodemodeSandbox,
|
||||
type CodemodeSandboxBase,
|
||||
type CodemodeSandboxBaseOptions,
|
||||
type CodemodeTool,
|
||||
InlineCodemodeSandbox,
|
||||
loadQuickJSWasm,
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_OUTPUT_ITEMS,
|
||||
RemoteCodemodeSandbox,
|
||||
serveCodemodeRemote,
|
||||
} from "../src/index.ts";
|
||||
import { PRELUDE_SOURCE } from "../src/runtime/prelude-source.ts";
|
||||
|
||||
/**
|
||||
* Every behavior test runs against each place a VM can run. The remote backend serves executions in
|
||||
* process and structured-clones every message, as Workers RPC would. The in-thread backends get a
|
||||
* small interrupt budget so runaway scripts end quickly.
|
||||
*/
|
||||
const INTERRUPT_BUDGET = 20_000;
|
||||
const BACKENDS: Record<string, (options: CodemodeSandboxBaseOptions) => CodemodeSandboxBase> = {
|
||||
worker: (options) => new CodemodeSandbox(options),
|
||||
inline: (options) =>
|
||||
new InlineCodemodeSandbox({ ...options, wasm: loadQuickJSWasm(), interruptBudget: INTERRUPT_BUDGET }),
|
||||
remote: (options) =>
|
||||
new RemoteCodemodeSandbox({
|
||||
...options,
|
||||
remote: () => ({
|
||||
execute: (request, exchange) =>
|
||||
serveCodemodeRemote(
|
||||
structuredClone(request),
|
||||
async (messages) => structuredClone(await exchange(structuredClone(messages))),
|
||||
{ wasm: loadQuickJSWasm(), maxInterruptBudget: INTERRUPT_BUDGET },
|
||||
),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
let backend = "worker";
|
||||
const sandboxes: CodemodeSandboxBase[] = [];
|
||||
|
||||
function newSandbox(options: CodemodeSandboxBaseOptions = {}): CodemodeSandboxBase {
|
||||
const sandbox = BACKENDS[backend](options);
|
||||
sandboxes.push(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function createSandbox(tools: CodemodeTool[] = [], timeoutMs = 10_000): CodemodeSandboxBase {
|
||||
return newSandbox({ tools, timeoutMs });
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(sandboxes.splice(0).map((sandbox) => sandbox.close()));
|
||||
});
|
||||
|
||||
const echo: CodemodeTool = { name: "echo", execute: (args) => args };
|
||||
|
||||
// Base64 of the leading bytes of each format. image() only inspects the signature.
|
||||
const PNG = "iVBORw0KGgo=";
|
||||
const JPEG = "/9j/4A==";
|
||||
const GIF = "R0lGODlh";
|
||||
const WEBP = "UklGRgAAAABXRUJQ";
|
||||
|
||||
describe("embedded sources", () => {
|
||||
it("parse as JavaScript", () => {
|
||||
expect(() => new vm.Script(PRELUDE_SOURCE, { filename: "prelude.js" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(Object.keys(BACKENDS))("%s backend", (name) => {
|
||||
beforeEach(() => {
|
||||
backend = name;
|
||||
});
|
||||
|
||||
describe("script execution", () => {
|
||||
it("returns the script's return value after a JSON round trip", async () => {
|
||||
const sandbox = createSandbox();
|
||||
expect(await sandbox.execute("return { a: 1, b: [true, 'x'] }")).toMatchObject({
|
||||
ok: true,
|
||||
value: { a: 1, b: [true, "x"] },
|
||||
output: [],
|
||||
calls: [],
|
||||
});
|
||||
expect(await sandbox.execute("return 'plain'")).toMatchObject({ ok: true, value: "plain" });
|
||||
expect(await sandbox.execute("")).toMatchObject({ ok: true, value: undefined });
|
||||
});
|
||||
|
||||
it("supports top-level await", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("const x = await Promise.resolve(41); return x + 1");
|
||||
expect(result).toMatchObject({ ok: true, value: 42 });
|
||||
});
|
||||
|
||||
it("collects text(), image(), and console output in order", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
console.log("hello", 1, { a: 1 });
|
||||
text({ json: true });
|
||||
text(undefined);
|
||||
text(7);
|
||||
image("data:image/png;base64,${PNG}");
|
||||
image({ image_url: "data:image/jpeg;base64,${JPEG}" });
|
||||
image({ type: "image", data: "${GIF}", mimeType: "image/gif" });
|
||||
image("data:image/png;base64,${WEBP}");
|
||||
image({ type: "image", data: "${PNG}" });
|
||||
console.error(new Error("bad"));
|
||||
return null;
|
||||
`);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output.slice(0, -1)).toEqual([
|
||||
{ type: "text", text: 'hello 1 {"a":1}', console: true },
|
||||
{ type: "text", text: '{"json":true}' },
|
||||
{ type: "text", text: "undefined" },
|
||||
{ type: "text", text: "7" },
|
||||
{ type: "image", data: PNG, mimeType: "image/png" },
|
||||
{ type: "image", data: JPEG, mimeType: "image/jpeg" },
|
||||
{ type: "image", data: GIF, mimeType: "image/gif" },
|
||||
// The MIME type comes from the data, not from the declared type.
|
||||
{ type: "image", data: WEBP, mimeType: "image/webp" },
|
||||
{ type: "image", data: PNG, mimeType: "image/png" },
|
||||
]);
|
||||
expect(result.output.at(-1)).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^Error: bad/),
|
||||
console: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid text() and image() arguments", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
const errors = [];
|
||||
const circular = {};
|
||||
circular.self = circular;
|
||||
for (const run of [
|
||||
() => text(circular),
|
||||
() => image(""),
|
||||
() => image("https://example.com/a.png"),
|
||||
() => image("data:image/png,raw"),
|
||||
() => image({ type: "text", text: "x" }),
|
||||
() => image({ type: "image", data: "" }),
|
||||
() => image(42),
|
||||
() => image("data:image/png;base64,AAAA!"),
|
||||
() => image("data:image/png;base64,AAAAA"),
|
||||
() => image("data:image/png;base64,AA=A"),
|
||||
() => image("data:image/png;base64,"),
|
||||
() => image("data:image/png;base64,AAAA\\n[Output truncated]"),
|
||||
() => image({ type: "image", data: "AAAA!", mimeType: "image/png" }),
|
||||
() => image("data:image/png;base64,AAAA"),
|
||||
() => image("data:image/png;base64,QUJD"),
|
||||
() => image("data:image/jpeg;base64,/9j/9w=="),
|
||||
]) {
|
||||
try { run(); errors.push("no error"); } catch (error) { errors.push(error.name + ": " + error.message); }
|
||||
}
|
||||
return errors;
|
||||
`);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.output).toEqual([]);
|
||||
const errors = result.value as string[];
|
||||
expect(errors[0]).toMatch(/^TypeError: .*circular/);
|
||||
expect(errors.slice(1)).toEqual([
|
||||
'TypeError: image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }',
|
||||
"TypeError: remote image URLs are not supported in tool outputs. Pass a base64 data URI instead",
|
||||
"TypeError: invalid image output. Pass a base64 data URI instead",
|
||||
'TypeError: image only accepts image blocks, got "text"',
|
||||
"TypeError: image expected image block data",
|
||||
'TypeError: image expects a non-empty image URL string, an object with image_url, or an image block { type: "image", data, mimeType }',
|
||||
...Array(6).fill(
|
||||
"TypeError: invalid image output. The image data is not valid base64 (truncated or corrupted?)",
|
||||
),
|
||||
...Array(3).fill("TypeError: invalid image output. The image data is not a PNG, JPEG, GIF, or WebP image"),
|
||||
]);
|
||||
});
|
||||
|
||||
// https://github.com/earendil-works/pi/issues/10215
|
||||
it("accepts wrapped and large base64 image data", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const large = `iVBORw0KGgoA${"QUJD".repeat(256 * 1024)}`;
|
||||
const result = await sandbox.execute(`
|
||||
image("data:image/png;base64,iVBORw0K\\r\\nGgo=\\n");
|
||||
image("data:image/png;base64,${large}");
|
||||
`);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.output).toEqual([
|
||||
{ type: "image", data: PNG, mimeType: "image/png" },
|
||||
{ type: "image", data: large, mimeType: "image/png" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends the script successfully on exit(), keeping output and store writes", async () => {
|
||||
const sandbox = createSandbox([echo]);
|
||||
const result = await sandbox.execute(`
|
||||
text("before");
|
||||
store("k", 1);
|
||||
await tools.echo(1);
|
||||
try { exit(); } catch {}
|
||||
text("after");
|
||||
return "unreachable";
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
output: [{ type: "text", text: "before" }],
|
||||
storeWrites: { set: { k: 1 }, delete: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps output produced before a failure", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute('text("partial");\nthrow new Error("boom")');
|
||||
expect(result).toMatchObject({ ok: false, output: [{ type: "text", text: "partial" }] });
|
||||
});
|
||||
|
||||
it("reports syntax errors with the script's line number", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("const a = 1;\nconst b = ;\nreturn a");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.kind).toBe("script");
|
||||
expect(result.error.name).toBe("SyntaxError");
|
||||
expect(result.error.stack).toMatch(/codemode\.js:2/);
|
||||
});
|
||||
|
||||
it("reports thrown errors with the script's line number", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("const a = 1;\nthrow new TypeError('boom ' + a)");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({ kind: "script", name: "TypeError", message: "boom 1" });
|
||||
expect(result.error.stack).toMatch(/codemode\.js:2/);
|
||||
});
|
||||
|
||||
it("formats stacks like V8 without prelude frames", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("console.log(new Error('inner'));\nthrow new RangeError('outer')");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.stack).toMatch(/^RangeError: outer\n {4}at .*codemode\.js:2/);
|
||||
expect(result.error.stack).not.toContain("codemode-prelude.js");
|
||||
expect(result.output[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringMatching(/^Error: inner\n {4}at .*codemode\.js:1/),
|
||||
});
|
||||
expect(JSON.stringify(result.output)).not.toContain("codemode-prelude.js");
|
||||
});
|
||||
|
||||
it("reports non-Error throws", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("throw { code: 7 }");
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "script", message: '{"code":7}' } });
|
||||
});
|
||||
|
||||
it("reports a non-serializable return value as a script error", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("return 10n");
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "script", name: "TypeError" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tools", () => {
|
||||
it("exposes tools as async functions and records calls", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const sandbox = createSandbox([
|
||||
{
|
||||
name: "add",
|
||||
execute: (args) => {
|
||||
seen.push(args);
|
||||
const { a, b } = args as { a: number; b: number };
|
||||
return { sum: a + b };
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await sandbox.execute(`
|
||||
const first = await tools.add({ a: 1, b: 2 });
|
||||
const second = await tools.add({ a: first.sum, b: 10 });
|
||||
return second.sum;
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: 13 });
|
||||
expect(seen).toEqual([
|
||||
{ a: 1, b: 2 },
|
||||
{ a: 3, b: 10 },
|
||||
]);
|
||||
expect(result.calls.map((call) => [call.name, call.status])).toEqual([
|
||||
["add", "ok"],
|
||||
["add", "ok"],
|
||||
]);
|
||||
expect(result.calls.every((call) => call.durationMs >= 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("runs concurrent calls and lists tool names", async () => {
|
||||
const sandbox = createSandbox([
|
||||
echo,
|
||||
{ name: "delay", execute: (args) => new Promise((resolve) => setTimeout(() => resolve(args), 20)) },
|
||||
]);
|
||||
const result = await sandbox.execute(`
|
||||
const [a, b, c] = await Promise.all([tools.delay(1), tools.delay(2), tools.echo(3)]);
|
||||
return { values: [a, b, c], names: Object.keys(tools) };
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: { values: [1, 2, 3], names: ["echo", "delay"] } });
|
||||
});
|
||||
|
||||
it("exposes tools under normalized identifiers and lists them in ALL_TOOLS", async () => {
|
||||
const sandbox = createSandbox([
|
||||
{ name: "my-tool", description: "Dashes", execute: () => "dash" },
|
||||
{ name: "my_tool", description: "Shadowed", execute: () => "underscore" },
|
||||
{ name: "mcp__docs__search", execute: () => "mcp" },
|
||||
]);
|
||||
const result = await sandbox.execute(`
|
||||
try { ALL_TOOLS.push({}); } catch {}
|
||||
return {
|
||||
all: ALL_TOOLS,
|
||||
calls: [await tools.my_tool(), await tools["my-tool"](), await tools.mcp__docs__search()],
|
||||
};
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
all: [
|
||||
{ name: "my_tool", description: "Dashes" },
|
||||
{ name: "mcp__docs__search", description: "" },
|
||||
],
|
||||
calls: ["dash", "dash", "mcp"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes undefined arguments and results through", async () => {
|
||||
const sandbox = createSandbox([{ name: "noop", execute: (args) => args }]);
|
||||
const result = await sandbox.execute("return [await tools.noop(), await tools.noop(null)]");
|
||||
expect(result).toMatchObject({ ok: true, value: [null, null] });
|
||||
});
|
||||
|
||||
it("turns tool errors into catchable Errors in the script", async () => {
|
||||
const sandbox = createSandbox([
|
||||
{
|
||||
name: "fail",
|
||||
execute: () => {
|
||||
throw new Error("tool exploded");
|
||||
},
|
||||
},
|
||||
]);
|
||||
const result = await sandbox.execute(`
|
||||
try {
|
||||
await tools.fail();
|
||||
return "no error";
|
||||
} catch (error) {
|
||||
return { isError: error instanceof Error, message: error.message };
|
||||
}
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: { isError: true, message: "tool exploded" } });
|
||||
expect(result.calls).toMatchObject([{ name: "fail", status: "error" }]);
|
||||
});
|
||||
|
||||
it("rejects calls to unknown tools", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("return await tools.missing()");
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "script", name: "TypeError" } });
|
||||
});
|
||||
|
||||
it("aborts unawaited calls when the script returns", async () => {
|
||||
let aborted = false;
|
||||
const sandbox = createSandbox([
|
||||
{
|
||||
name: "slow",
|
||||
execute: (_args, { signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const result = await sandbox.execute("tools.slow(); return 'early'");
|
||||
expect(result).toMatchObject({ ok: true, value: "early" });
|
||||
expect(result.calls).toMatchObject([{ name: "slow", status: "cancelled" }]);
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("names close matches when a script reads a tool that does not exist", async () => {
|
||||
const sandbox = createSandbox([echo, { name: "web-search", execute: () => "" }]);
|
||||
const attempt = async (expression: string) => {
|
||||
const result = await sandbox.execute(`return ${expression};`);
|
||||
return result.ok ? result.value : result.error.message;
|
||||
};
|
||||
expect(await attempt("tools.Echo")).toBe(
|
||||
'tools.Echo does not exist. Did you mean tools.echo? ALL_TOOLS lists every tool; searchTools(query) finds tools by topic. Check for a member with "Echo" in tools.',
|
||||
);
|
||||
expect(await attempt("tools.websearch")).toContain("Did you mean tools.web_search?");
|
||||
expect(await attempt("tools.nothing")).toContain("Available: echo, web_search.");
|
||||
expect(
|
||||
await attempt("['echo' in tools, 'nothing' in tools, String(tools.toString), JSON.stringify(tools)]"),
|
||||
).toEqual([true, false, "undefined", "{}"]);
|
||||
});
|
||||
|
||||
it("supports register and unregister between executions", async () => {
|
||||
const sandbox = createSandbox();
|
||||
sandbox.registerTool(echo);
|
||||
expect(() => sandbox.registerTool(echo)).toThrow(/already registered/);
|
||||
expect(sandbox.tools.map((tool) => tool.name)).toEqual(["echo"]);
|
||||
expect(await sandbox.execute("return await tools.echo('a')")).toMatchObject({ ok: true, value: "a" });
|
||||
expect(sandbox.unregisterTool("echo")).toBe(true);
|
||||
expect(await sandbox.execute("return 'echo' in tools")).toMatchObject({ ok: true, value: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("store and load", () => {
|
||||
it("reads the snapshot and reports writes", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(
|
||||
`
|
||||
const seen = load("counter");
|
||||
store("counter", seen + 1);
|
||||
store("list", [1, { a: null }]);
|
||||
store("old", undefined);
|
||||
return [seen, load("counter"), load("missing"), load("old")];
|
||||
`,
|
||||
{ store: { counter: 41, old: "x" } },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
// undefined array elements become null in the JSON round trip of the return value.
|
||||
value: [41, 42, null, null],
|
||||
storeWrites: { set: { counter: 42, list: [1, { a: null }] }, delete: ["old"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns copies, so mutating a loaded value does not change the store", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(
|
||||
`const value = load("obj"); value.a = 2; const kept = { b: 1 }; store("kept", kept); kept.b = 2;
|
||||
return [load("obj").a, load("kept").b];`,
|
||||
{ store: { obj: { a: 1 } } },
|
||||
);
|
||||
expect(result).toMatchObject({ ok: true, value: [1, 1], storeWrites: { set: { kept: { b: 1 } } } });
|
||||
});
|
||||
|
||||
it("rejects invalid keys, values, and oversized writes inside the script", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
const attempt = (fn) => { try { fn(); return "ok"; } catch (error) { return error.name; } };
|
||||
return [
|
||||
attempt(() => store(1, "x")),
|
||||
attempt(() => load({})),
|
||||
attempt(() => store("fn", () => 1)),
|
||||
attempt(() => store("big", "x".repeat(300 * 1024))),
|
||||
attempt(() => { for (let i = 0; i < 8; i++) store("k" + i, "x".repeat(200 * 1024)); }),
|
||||
];
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: ["TypeError", "TypeError", "TypeError", "RangeError", "RangeError"],
|
||||
});
|
||||
});
|
||||
|
||||
it("explains oversized writes", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`store("img", "x".repeat(300 * 1024));`);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toContain('store("img") value has 307202 characters of JSON');
|
||||
expect(result.error.message).toContain("Show images with image()");
|
||||
});
|
||||
|
||||
it("reserves the store and load names", () => {
|
||||
const execute = () => undefined;
|
||||
expect(() => newSandbox({ globals: [{ name: "store", execute }] })).toThrow(/Invalid global/);
|
||||
expect(() => newSandbox({ globals: [{ name: "load", execute }] })).toThrow(/Invalid global/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globals", () => {
|
||||
it("exposes globals as top-level functions without recording them as calls", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const sandbox = newSandbox({
|
||||
tools: [echo],
|
||||
globals: [{ name: "attach", execute: (args) => void seen.push(args) }],
|
||||
});
|
||||
const result = await sandbox.execute(`
|
||||
await attach({ ref: 1 });
|
||||
attach("not awaited");
|
||||
return [typeof attach, typeof globalThis.attach, await tools.echo(2)];
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: ["function", "function", 2] });
|
||||
expect(result.calls.map((call) => call.name)).toEqual(["echo"]);
|
||||
// Messages are handled in order, so an unawaited global still runs before the script settles.
|
||||
expect(seen).toEqual([{ ref: 1 }, "not awaited"]);
|
||||
});
|
||||
|
||||
it("groups namespaced globals and spreads arguments on request", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const sandbox = newSandbox({
|
||||
globals: [
|
||||
{ name: "models.list", spread: true, execute: (args) => void seen.push(args) },
|
||||
{ name: "models.first", execute: (args) => args },
|
||||
],
|
||||
});
|
||||
const result = await sandbox.execute(`
|
||||
await models.list("classifier", undefined, 3);
|
||||
await models.list();
|
||||
try { models.extra = 1; } catch {}
|
||||
return [Object.keys(models), await models.first("a", "ignored"), "extra" in models];
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: [["list", "first"], "a", false] });
|
||||
// undefined array elements become null in the JSON round trip.
|
||||
expect(seen).toEqual([["classifier", null, 3], []]);
|
||||
});
|
||||
|
||||
it("names the members of a namespace when a script reads one that does not exist", async () => {
|
||||
const execute = () => undefined;
|
||||
const sandbox = newSandbox({
|
||||
globals: [
|
||||
{ name: "models.classify", execute },
|
||||
{ name: "models.generateImages", execute },
|
||||
],
|
||||
});
|
||||
const result = await sandbox.execute("await models.generateImage();");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.message).toBe(
|
||||
'models.generateImage does not exist. Did you mean models.generateImages? Check for a member with "generateImage" in models.',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid and reserved global names", () => {
|
||||
const execute = () => undefined;
|
||||
for (const name of ["a.b.c", "a.", ".a", "tools.x", "store.x", "a.not-valid"]) {
|
||||
expect(() => newSandbox({ globals: [{ name, execute }] }), name).toThrow(/Invalid global/);
|
||||
}
|
||||
expect(() =>
|
||||
newSandbox({
|
||||
globals: [
|
||||
{ name: "models", execute },
|
||||
{ name: "models.list", execute },
|
||||
],
|
||||
}),
|
||||
).toThrow(/conflicts with the namespace/);
|
||||
expect(() => newSandbox({ globals: [{ name: "not-valid", execute }] })).toThrow(/Invalid global/);
|
||||
expect(() => newSandbox({ globals: [{ name: "tools", execute }] })).toThrow(/Invalid global/);
|
||||
expect(() => newSandbox({ globals: [{ name: "console", execute }] })).toThrow(/Invalid global/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("limits and lifetime", () => {
|
||||
it("terminates a synchronous infinite loop on timeout", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const started = performance.now();
|
||||
const result = await sandbox.execute("while (true) {}", { timeoutMs: 200 });
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
|
||||
expect(performance.now() - started).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
it("runs without a deadline when timeoutMs is Infinity", async () => {
|
||||
const sandbox = createSandbox([
|
||||
{ name: "wait", execute: () => new Promise((r) => setTimeout(() => r("late"), 50)) },
|
||||
]);
|
||||
const result = await sandbox.execute("return await tools.wait()", { timeoutMs: Number.POSITIVE_INFINITY });
|
||||
expect(result).toMatchObject({ ok: true, value: "late" });
|
||||
});
|
||||
|
||||
it("fails a script that waits on a promise nothing can settle", async () => {
|
||||
const sandbox = createSandbox([{ name: "echo", execute: (args) => args }]);
|
||||
const result = await sandbox.execute("await tools.echo(1); await new Promise(() => {}); return 'never'", {
|
||||
timeoutMs: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "script", message: expect.stringContaining("can never settle") },
|
||||
});
|
||||
// Returning while a call is still pending is not a stall.
|
||||
const returned = await sandbox.execute("tools.echo(2); return 'early'", {
|
||||
timeoutMs: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
expect(returned).toMatchObject({ ok: true, value: "early" });
|
||||
});
|
||||
|
||||
it("terminates a microtask-spinning loop on timeout", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("while (true) await null", { timeoutMs: 200 });
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "timeout" } });
|
||||
});
|
||||
|
||||
it("aborts via signal and cancels in-flight calls", async () => {
|
||||
const controller = new AbortController();
|
||||
let toolSignal: AbortSignal | undefined;
|
||||
// Resolves on each call, so the test waits for the worker instead of a fixed delay.
|
||||
let called: () => void = () => {};
|
||||
const nextCall = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
called = resolve;
|
||||
});
|
||||
const sandbox = createSandbox([
|
||||
{
|
||||
name: "hang",
|
||||
execute: (_args, { signal }) => {
|
||||
toolSignal = signal;
|
||||
called();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
]);
|
||||
const firstCall = nextCall();
|
||||
const promise = sandbox.execute("await tools.hang(); return 'never'");
|
||||
await firstCall;
|
||||
controller.abort(new Error("user cancelled"));
|
||||
expect(toolSignal?.aborted).toBe(false);
|
||||
const result = await sandbox.execute("await tools.hang()", { signal: controller.signal });
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "aborted", message: "user cancelled" } });
|
||||
|
||||
const second = new AbortController();
|
||||
const secondCall = nextCall();
|
||||
const pending = sandbox.execute("await tools.hang(); return 'never'", { signal: second.signal });
|
||||
await secondCall;
|
||||
second.abort();
|
||||
const aborted = await pending;
|
||||
expect(aborted).toMatchObject({ ok: false, error: { kind: "aborted" } });
|
||||
expect(aborted.calls).toMatchObject([{ name: "hang", status: "cancelled" }]);
|
||||
expect(toolSignal?.aborted).toBe(true);
|
||||
|
||||
await sandbox.close();
|
||||
expect(await promise).toMatchObject({ ok: false, error: { kind: "aborted", message: "Sandbox closed" } });
|
||||
});
|
||||
|
||||
// #10283: the host keeps all output, so a script that prints in a loop must not grow it without bound.
|
||||
it("fails a script whose output passes the limits, even if it catches the error", async () => {
|
||||
const sandbox = createSandbox();
|
||||
for (const print of ["text(s)", "console.log(s)", 'image("data:image/png;base64," + p)']) {
|
||||
const result = await sandbox.execute(`
|
||||
const s = "x".repeat(1 << 20);
|
||||
const p = "iVBORw0KGgoA" + "A".repeat(1 << 20);
|
||||
for (;;) { try { ${print}; } catch {} }
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "script",
|
||||
name: "RangeError",
|
||||
message: expect.stringContaining("script output exceeded"),
|
||||
},
|
||||
});
|
||||
const chars = result.output.reduce(
|
||||
(sum, item) => sum + (item.type === "text" ? item.text.length : item.data.length),
|
||||
0,
|
||||
);
|
||||
expect(chars).toBeLessThanOrEqual(MAX_OUTPUT_CHARS);
|
||||
expect(chars).toBeGreaterThan(MAX_OUTPUT_CHARS - (2 << 20));
|
||||
}
|
||||
|
||||
const empty = await sandbox.execute(`for (;;) text("");`);
|
||||
expect(empty).toMatchObject({ ok: false, error: { name: "RangeError" } });
|
||||
expect(empty.output).toHaveLength(MAX_OUTPUT_ITEMS);
|
||||
});
|
||||
|
||||
it("rejects execute after close", async () => {
|
||||
const sandbox = createSandbox();
|
||||
await sandbox.close();
|
||||
await expect(sandbox.execute("return 1")).rejects.toThrow(/closed/);
|
||||
});
|
||||
|
||||
it("runs executions in parallel without sharing state", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const results = await Promise.all([
|
||||
sandbox.execute("globalThis.shared = 'a'; await null; return globalThis.shared"),
|
||||
sandbox.execute("globalThis.shared = 'b'; await null; return globalThis.shared"),
|
||||
sandbox.execute("return typeof globalThis.shared"),
|
||||
]);
|
||||
expect(results.map((result) => (result.ok ? result.value : result.error))).toEqual(["a", "b", "undefined"]);
|
||||
});
|
||||
|
||||
it("turns deep recursion into a catchable RangeError", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
let depth = 0;
|
||||
function dive() { depth++; dive(); }
|
||||
try { dive(); } catch (error) { return [error.name, depth > 1000]; }
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: ["RangeError", true] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape hatches", () => {
|
||||
it("has no host globals", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
return [
|
||||
typeof process, typeof require, typeof module, typeof setTimeout, typeof fetch,
|
||||
typeof WebAssembly, typeof std, typeof os, typeof globalThis.constructor,
|
||||
]
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: [
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"undefined",
|
||||
"function",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps eval and Function inside the VM", async () => {
|
||||
// Code generation is allowed: it can only produce more code in the same wasm instance.
|
||||
const sandbox = createSandbox([echo]);
|
||||
const result = await sandbox.execute(`
|
||||
return [
|
||||
eval("typeof process"),
|
||||
new Function("return typeof process")(),
|
||||
tools.echo.constructor("return typeof require")(),
|
||||
(async () => {}).constructor("return typeof setTimeout")() instanceof Promise,
|
||||
];
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: ["undefined", "undefined", "undefined", true] });
|
||||
});
|
||||
|
||||
it("rejects dynamic import", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
try { await import("node:fs"); return "imported"; } catch (error) { return error.constructor.name; }
|
||||
`);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value).not.toBe("imported");
|
||||
});
|
||||
|
||||
// #10444: the prelude shares the built-ins with the script, so patching them could corrupt what
|
||||
// the prelude sends to the host.
|
||||
it("ignores patches to built-ins and built-in globals", async () => {
|
||||
const sandbox = createSandbox([echo]);
|
||||
const result = await sandbox.execute(`
|
||||
Array.prototype.toJSON = () => null;
|
||||
Object.prototype.toJSON = () => 5;
|
||||
Promise.prototype.then = () => {};
|
||||
Map.prototype.get = () => undefined;
|
||||
globalThis.JSON = { stringify: () => "x", parse: () => "x" };
|
||||
store("k", [1]);
|
||||
return [await tools.echo([2]), JSON.stringify({ a: 1 })];
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: [[2], '{"a":1}'], storeWrites: { set: { k: [1] } } });
|
||||
});
|
||||
|
||||
it("freezes intrinsics that are only reachable from instances", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
return [
|
||||
Object.getPrototypeOf(function* () {}).prototype,
|
||||
Object.getPrototypeOf(async function () {}),
|
||||
Object.getPrototypeOf(Int8Array).prototype,
|
||||
Object.getPrototypeOf([][Symbol.iterator]()),
|
||||
Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]())),
|
||||
Object.getPrototypeOf(new Map()[Symbol.iterator]()),
|
||||
Object.getPrototypeOf(/a/[Symbol.matchAll]("")),
|
||||
].every((object) => Object.isFrozen(object));
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: true });
|
||||
});
|
||||
|
||||
it("still lets instances override properties of frozen prototypes", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute(`
|
||||
const object = {};
|
||||
object.toString = () => "custom";
|
||||
function Legacy() {}
|
||||
Legacy.prototype = Object.create(Error.prototype);
|
||||
Legacy.prototype.constructor = Legacy;
|
||||
const bare = new Error();
|
||||
bare.message = "set later";
|
||||
class MyError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "MyError";
|
||||
}
|
||||
}
|
||||
let patched = "silent";
|
||||
try { Error.prototype.name = "Patched"; } catch (error) { patched = error.constructor.name; }
|
||||
return [String(object), new Legacy().constructor === Legacy, bare.message, new MyError("x").name, Error.prototype.name, patched];
|
||||
`);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: ["custom", true, "set later", "MyError", "Error", "TypeError"],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports errors whose name or message is not a string", async () => {
|
||||
const sandbox = createSandbox();
|
||||
const result = await sandbox.execute("const error = new Error('x'); error.message = 42; throw error;");
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "script", name: "Error", message: "42" } });
|
||||
});
|
||||
|
||||
it("keeps tools and console frozen", async () => {
|
||||
const sandbox = createSandbox([echo]);
|
||||
const result = await sandbox.execute(`
|
||||
try { tools.echo = () => 'nope'; } catch {}
|
||||
try { tools.extra = () => 'nope'; } catch {}
|
||||
try { globalThis.tools = null; } catch {}
|
||||
return ["extra" in tools, await tools.echo('still')];
|
||||
`);
|
||||
expect(result).toMatchObject({ ok: true, value: [false, "still"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker host", () => {
|
||||
beforeEach(() => {
|
||||
backend = "worker";
|
||||
});
|
||||
|
||||
it("accepts a worker path string", async () => {
|
||||
const workerPath = fileURLToPath(new URL("../src/runtime/worker.ts", import.meta.url));
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: workerPath });
|
||||
sandboxes.push(sandbox);
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({ ok: true, value: 1 });
|
||||
});
|
||||
|
||||
it("reports a missing worker file as a sandbox error", async () => {
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: new URL("./does-not-exist.js", import.meta.url) });
|
||||
sandboxes.push(sandbox);
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({ ok: false, error: { kind: "sandbox" } });
|
||||
});
|
||||
|
||||
it("reports a failing wasm module as a sandbox error", async () => {
|
||||
const sandbox = new CodemodeSandbox({ wasm: Promise.reject(new Error("no wasm")) });
|
||||
sandboxes.push(sandbox);
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "Failed to load QuickJS: no wasm" },
|
||||
});
|
||||
});
|
||||
|
||||
// #10725: Node 24/26 `--watch` posts dependency reports on the worker channel.
|
||||
it("ignores Node --watch notifications from the worker", async () => {
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: new URL("./fixtures/raw-worker.ts", import.meta.url) });
|
||||
sandboxes.push(sandbox);
|
||||
const result = await sandbox.execute(
|
||||
JSON.stringify([
|
||||
{ "watch:require": ["/worker.js"] },
|
||||
{ "watch:import": ["file:///worker.js"] },
|
||||
{ type: "done", ok: true, value: "42", writes: "[]" },
|
||||
]),
|
||||
);
|
||||
expect(result).toMatchObject({ ok: true, value: 42 });
|
||||
});
|
||||
|
||||
// #10444: a malformed payload from the worker must fail the run as a sandbox error instead of
|
||||
// throwing in the host's message listener and never settling.
|
||||
it.each([
|
||||
[{ type: "done", ok: true, value: "1", writes: "null" }, "store writes are not an array"],
|
||||
[{ type: "done", ok: true, value: "1", writes: "[1]" }, "store writes contain a malformed entry"],
|
||||
[{ type: "done", ok: true, value: "1", writes: '[["k", "{"]]' }, 'store value for "k" is not valid JSON'],
|
||||
[{ type: "done", ok: true, value: "{", writes: "[]" }, "return value is not valid JSON"],
|
||||
[{ type: "done", ok: false, error: "5" }, "script error is not an object"],
|
||||
[{ type: "done", ok: false, error: "{}" }, "script error is malformed"],
|
||||
[{ type: "nonsense" }, "unknown message from the worker"],
|
||||
])("reports a broken bridge as a sandbox error: %j", async (message, reason) => {
|
||||
const sandbox = new CodemodeSandbox({ workerUrl: new URL("./fixtures/raw-worker.ts", import.meta.url) });
|
||||
sandboxes.push(sandbox);
|
||||
const result = await sandbox.execute(JSON.stringify(message));
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "sandbox" } });
|
||||
expect(result.ok ? undefined : result.error.message).toContain(`Sandbox bridge broken: ${reason}`);
|
||||
});
|
||||
});
|
||||
78
third_party/pi/packages/codemode/test/source.test.ts
vendored
Normal file
78
third_party/pi/packages/codemode/test/source.test.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CodemodeSourceError, parseCodemodeSource } from "../src/source.ts";
|
||||
|
||||
describe("parseCodemodeSource", () => {
|
||||
it("returns plain code unchanged", () => {
|
||||
expect(parseCodemodeSource("text('hi')")).toEqual({ code: "text('hi')", options: {} });
|
||||
expect(parseCodemodeSource("// just a comment\nreturn 1")).toEqual({
|
||||
code: "// just a comment\nreturn 1",
|
||||
options: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the options line and keeps line numbers", () => {
|
||||
expect(parseCodemodeSource('// @options: {"timeout_ms": 10}\nconst a = 1;\ntext(a)')).toEqual({
|
||||
code: "\nconst a = 1;\ntext(a)",
|
||||
options: { timeoutMs: 10 },
|
||||
});
|
||||
expect(parseCodemodeSource(' // @options:{"max_output_tokens":0,"timeout_ms":1500}\r\ntext(1)').options).toEqual(
|
||||
{
|
||||
maxOutputTokens: 0,
|
||||
timeoutMs: 1500,
|
||||
},
|
||||
);
|
||||
expect(parseCodemodeSource("// @options: {}\ntext(1)")).toEqual({ code: "\ntext(1)", options: {} });
|
||||
});
|
||||
|
||||
it("only treats the first line as an options line", () => {
|
||||
expect(parseCodemodeSource("// @optionsx {}\ntext(1)").options).toEqual({});
|
||||
expect(parseCodemodeSource('text("// @options: {}")').options).toEqual({});
|
||||
});
|
||||
|
||||
// #10631: an options line after the first line used to be ignored, so its timeout never applied.
|
||||
it("rejects an options line that is not the first line", () => {
|
||||
const cases: [string, number][] = [
|
||||
['// count things\n// @options: {"timeout_ms": 1000}\nwhile (true) {}', 2],
|
||||
['\n // @options: {"timeout_ms": 1000}\nwhile (true) {}', 2],
|
||||
['text(1)\r\n// @options: {"timeout_ms": 1}', 2],
|
||||
['// @options: {"timeout_ms": 1}\ntext(1)\n// @options: {"timeout_ms": 2}', 3],
|
||||
];
|
||||
for (const [input, line] of cases) {
|
||||
expect(() => parseCodemodeSource(input), input).toThrow(CodemodeSourceError);
|
||||
expect(() => parseCodemodeSource(input), input).toThrow(
|
||||
`The // @options: line must be the first line of the input; found one on line ${line}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty input and invalid options", () => {
|
||||
const cases: [string, string | RegExp][] = [
|
||||
["", /Expected JavaScript source text \(non-empty\)/],
|
||||
[" \n", /Expected JavaScript source text \(non-empty\)/],
|
||||
["// @options:\ntext(1)", /@options must be a JSON object with supported fields/],
|
||||
["// @options: {timeout_ms: 1}\ntext(1)", /@options must be valid JSON with supported fields/],
|
||||
["// @options: [1]\ntext(1)", /@options must be a JSON object with supported fields/],
|
||||
[
|
||||
'// @options: {"yield": 1}\ntext(1)',
|
||||
"@options only supports `max_output_tokens` and `timeout_ms`; got `yield`",
|
||||
],
|
||||
[
|
||||
'// @options: {"max_output_tokens": 1.5}\ntext(1)',
|
||||
"@options field `max_output_tokens` must be a non-negative safe integer",
|
||||
],
|
||||
['// @options: {"timeout_ms": 0}\ntext(1)', /@options field `timeout_ms` must be a positive integer/],
|
||||
[
|
||||
'// @options: {"timeout_ms": 1}',
|
||||
"The @options line must be followed by JavaScript source on subsequent lines",
|
||||
],
|
||||
[
|
||||
'// @options: {"timeout_ms": 1}\n \n',
|
||||
"The @options line must be followed by JavaScript source on subsequent lines",
|
||||
],
|
||||
];
|
||||
for (const [input, message] of cases) {
|
||||
expect(() => parseCodemodeSource(input), input).toThrow(CodemodeSourceError);
|
||||
expect(() => parseCodemodeSource(input), input).toThrow(message);
|
||||
}
|
||||
});
|
||||
});
|
||||
229
third_party/pi/packages/codemode/test/worker-lifetime.test.ts
vendored
Normal file
229
third_party/pi/packages/codemode/test/worker-lifetime.test.ts
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type CodemodeResult, CodemodeSandbox } from "../src/index.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("worker lifetime", () => {
|
||||
// #10748: reject admission before creating a worker, not by terminating an excess worker.
|
||||
it("does not start a worker when admission is denied", async () => {
|
||||
const onWorkerStarted = vi.fn();
|
||||
const acquireWorker = vi.fn(() => {
|
||||
throw new Error("worker capacity exhausted");
|
||||
});
|
||||
const sandbox = new CodemodeSandbox({ acquireWorker, onWorkerStarted });
|
||||
try {
|
||||
expect(await sandbox.execute("while (true) {}")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "worker capacity exhausted" },
|
||||
});
|
||||
expect(acquireWorker).toHaveBeenCalledTimes(1);
|
||||
expect(onWorkerStarted).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await sandbox.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #10748: a failed Worker constructor must return its reserved capacity.
|
||||
it("releases admission when worker construction fails", async () => {
|
||||
const releaseWorker = vi.fn();
|
||||
const acquireWorker = vi.fn(() => releaseWorker);
|
||||
const sandbox = new CodemodeSandbox({
|
||||
acquireWorker,
|
||||
workerUrl: new URL("https://invalid.example/worker.js"),
|
||||
});
|
||||
try {
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: expect.stringContaining("Failed to start worker:") },
|
||||
});
|
||||
expect(acquireWorker).toHaveBeenCalledTimes(1);
|
||||
expect(releaseWorker).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await sandbox.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #10748: loading failures must not reserve worker capacity.
|
||||
it("does not acquire admission when wasm loading fails", async () => {
|
||||
const acquireWorker = vi.fn(() => vi.fn());
|
||||
const sandbox = new CodemodeSandbox({
|
||||
acquireWorker,
|
||||
wasm: Promise.reject(new Error("wasm unavailable")),
|
||||
});
|
||||
try {
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "Failed to load QuickJS: wasm unavailable" },
|
||||
});
|
||||
expect(acquireWorker).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await sandbox.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #10748: Bun can leave terminate() pending while a non-interruptible built-in runs.
|
||||
it.each(["abort", "timeout", "close"] as const)("settles %s without waiting for worker exit", async (how) => {
|
||||
vi.useFakeTimers();
|
||||
const terminate = Worker.prototype.terminate;
|
||||
let worker: Worker | undefined;
|
||||
let releaseTermination: (code: number) => void = () => {};
|
||||
const termination = new Promise<number>((resolve) => {
|
||||
releaseTermination = resolve;
|
||||
});
|
||||
const terminateSpy = vi.spyOn(Worker.prototype, "terminate").mockImplementation(function (this: Worker) {
|
||||
worker = this;
|
||||
// Like the real Node/Bun implementation, termination refs the worker while waiting for exit.
|
||||
this.ref();
|
||||
return termination;
|
||||
});
|
||||
const refSpy = vi.spyOn(Worker.prototype, "ref");
|
||||
const unrefSpy = vi.spyOn(Worker.prototype, "unref");
|
||||
let ready: () => void = () => {};
|
||||
const called = new Promise<void>((resolve) => {
|
||||
ready = resolve;
|
||||
});
|
||||
let toolSignal: AbortSignal | undefined;
|
||||
let exited: Promise<void> | undefined;
|
||||
let didExit = false;
|
||||
const sandbox = new CodemodeSandbox({
|
||||
timeoutMs: 100,
|
||||
onWorkerStarted: (promise) => {
|
||||
exited = promise;
|
||||
void promise.then(() => {
|
||||
didExit = true;
|
||||
});
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: "hang",
|
||||
execute: (_args, { signal }) => {
|
||||
toolSignal = signal;
|
||||
ready();
|
||||
return new Promise(() => {});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const controller = new AbortController();
|
||||
let result: CodemodeResult | undefined;
|
||||
let didClose = false;
|
||||
const pending = sandbox.execute('text("partial"); await tools.hang()', { signal: controller.signal });
|
||||
void pending.then((value) => {
|
||||
result = value;
|
||||
});
|
||||
try {
|
||||
await called;
|
||||
if (how === "abort") controller.abort(new Error("cancelled"));
|
||||
if (how === "timeout") await vi.advanceTimersByTimeAsync(100);
|
||||
if (how === "close") {
|
||||
void sandbox.close().then(() => {
|
||||
didClose = true;
|
||||
});
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: how === "timeout" ? "timeout" : "aborted" },
|
||||
output: [{ type: "text", text: "partial" }],
|
||||
calls: [{ name: "hang", status: "cancelled" }],
|
||||
});
|
||||
expect(toolSignal?.aborted).toBe(true);
|
||||
expect(terminateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unrefSpy).toHaveBeenCalledTimes(1);
|
||||
expect(refSpy).toHaveBeenCalledTimes(1);
|
||||
expect(unrefSpy.mock.invocationCallOrder[0]).toBeGreaterThan(refSpy.mock.invocationCallOrder[0]);
|
||||
expect(exited).toBeDefined();
|
||||
expect(didExit).toBe(false);
|
||||
if (how === "close") expect(didClose).toBe(true);
|
||||
// Neither close() nor late worker messages may replace the decided outcome or wait for exit.
|
||||
await sandbox.close();
|
||||
worker?.emit("message", { type: "done", ok: true, value: "42", writes: "[]" });
|
||||
expect(await pending).toBe(result);
|
||||
expect(terminateSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
terminateSpy.mockRestore();
|
||||
if (worker) releaseTermination(await terminate.call(worker));
|
||||
await sandbox.close();
|
||||
}
|
||||
await exited;
|
||||
expect(didExit).toBe(true);
|
||||
});
|
||||
|
||||
// #10748: exit accounting applies to successful executions and host-controlled runs too.
|
||||
it("reports the actual exit of execute() and start() workers", async () => {
|
||||
const exits: Promise<void>[] = [];
|
||||
const releaseWorker = vi.fn();
|
||||
const acquireWorker = vi.fn(() => releaseWorker);
|
||||
const sandbox = new CodemodeSandbox({ acquireWorker, onWorkerStarted: (exited) => void exits.push(exited) });
|
||||
try {
|
||||
expect(await sandbox.execute("return 1")).toMatchObject({ ok: true, value: 1 });
|
||||
const run = sandbox.start("return 2", { startedAt: 0, seed: 0, onCall: () => {} });
|
||||
expect(await run.result).toMatchObject({ ok: true, value: 2 });
|
||||
expect(run.finished).toBe(true);
|
||||
expect(exits).toHaveLength(2);
|
||||
await Promise.all(exits);
|
||||
expect(acquireWorker).toHaveBeenCalledTimes(2);
|
||||
expect(releaseWorker).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await sandbox.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #10748: a failing lifetime hook must not leak the worker it was handed.
|
||||
it("requests termination when onWorkerStarted throws", async () => {
|
||||
let exited: Promise<void> | undefined;
|
||||
const releaseWorker = vi.fn();
|
||||
const sandbox = new CodemodeSandbox({
|
||||
acquireWorker: () => releaseWorker,
|
||||
onWorkerStarted: (promise) => {
|
||||
exited = promise;
|
||||
throw new Error("lifetime hook failed");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(await sandbox.execute("while (true) {}")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "sandbox", message: "lifetime hook failed" },
|
||||
});
|
||||
expect(exited).toBeDefined();
|
||||
await exited;
|
||||
expect(releaseWorker).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await sandbox.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const runtimes = [
|
||||
{ name: "Node", command: process.execPath, available: true },
|
||||
{ name: "Bun", command: "bun", available: spawnSync("bun", ["--version"], { timeout: 5000 }).status === 0 },
|
||||
];
|
||||
|
||||
describe.each(runtimes)("$name process lifetime", ({ name, command, available }) => {
|
||||
// #10748: verify both prompt cancellation and, on Node, prompt process exit with a real worker.
|
||||
// Bun 1.4.0's pending terminate() keeps the process alive even after unref(), independently of codemode.
|
||||
it.skipIf(!available).each(["abort", "timeout", "close"] as const)(
|
||||
name === "Node" ? "exits promptly after %s with a real worker" : "settles %s promptly with a real worker",
|
||||
async (how) => {
|
||||
const fixture = fileURLToPath(new URL("./fixtures/worker-process-exit.ts", import.meta.url));
|
||||
const { stdout } = await promisify(execFile)(command, [fixture, how], { timeout: 20000 });
|
||||
const lines = stdout.trim().split(/\r?\n/);
|
||||
expect(lines).toHaveLength(2);
|
||||
const result = JSON.parse(lines[0]) as { type: string; kind: string; elapsedMs: number };
|
||||
const exit = JSON.parse(lines[1]) as { type: string; elapsedMs: number };
|
||||
expect(result).toMatchObject({ type: "result", kind: how === "timeout" ? "timeout" : "aborted" });
|
||||
expect(exit.type).toBe("exit");
|
||||
expect(result.elapsedMs).toBeLessThan(1000);
|
||||
if (name === "Node") expect(exit.elapsedMs - result.elapsedMs).toBeLessThan(1000);
|
||||
},
|
||||
25000,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user