fix(codemode): fail stalled scripts and expand local schema references

- A script awaiting a promise nothing can settle (no pending host call,
  no timers in the VM) now fails instead of hanging until aborted, which
  matters because pi runs scripts without a default deadline
- Declarations resolve #/$defs and #/definitions references, which MCP
  servers built on pydantic or zod emit for nested arguments
This commit is contained in:
Armin Ronacher 2026-09-27 12:43:25 +02:00
parent b9bbc35844
commit 0ea585d6ca
6 changed files with 124 additions and 25 deletions

View file

@ -42,7 +42,7 @@ await sandbox.close();
- `store(key, value)` and `load(key)` read and write JSON values synchronously. See [Store](#store).
- Nothing else: no timers, `fetch`, `process`, `require`, modules, or `WebAssembly`. `eval` and `Function` work but only produce more code inside the same VM.
`timeoutMs: Infinity` disables the deadline; the script then runs until it settles or `signal` aborts it.
`timeoutMs: Infinity` disables the deadline; the script then runs until it settles or `signal` aborts it. A script that waits on a promise nothing can settle (no tool call pending, and the VM has no timers or I/O) fails right away instead of hanging.
`memoryLimitBytes` caps the VM's heap. Allocations beyond it fail inside the script as `InternalError: out of memory`.
@ -102,7 +102,7 @@ renderDeclarations({ tools: sandbox.tools, globals: sandbox.globals });
// };
```
Schemas only shape the declarations; values are not validated against them.
Schemas only shape the declarations; values are not validated against them. Local references (`#/$defs/...`, `#/definitions/...`) are expanded; recursive and remote references render as `unknown`.
`execute()` never rejects for script failures. `result.error.kind` is one of:

View file

@ -13,7 +13,7 @@ export interface RenderDeclarationsOptions {
* description. Tools become members of `declare const tools`, globals become
* `declare function` statements, and `ns.member` globals members of `declare const ns`. Descriptions become doc comments; schemas become types
* (`unknown` where a schema is missing or uses features TypeScript cannot express, such as
* `$ref`).
* recursive `$ref`s).
*
* ```ts
* declare const tools: {
@ -94,28 +94,61 @@ function union(types: string[]): string {
/**
* Convert a JSON Schema to a TypeScript type expression. `indent` is the indentation of the line
* the type starts on; nested object members are indented one level deeper.
* the type starts on; nested object members are indented one level deeper. Local references
* (`#/$defs/...`, `#/definitions/...`) resolve against `schema` itself; recursive references and
* references elsewhere render as `unknown`.
*/
export function schemaToType(schema: CodemodeJsonSchema, indent = ""): string {
return toType(schema, indent, { root: schema, resolving: new Set() });
}
interface SchemaContext {
root: CodemodeJsonSchema;
/** References being expanded on the current path, to stop at recursive types. */
resolving: Set<string>;
}
function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined {
if (ref !== "#" && !ref.startsWith("#/")) return undefined;
let current: unknown = root;
for (const segment of ref.slice(2).split("/").filter(Boolean)) {
const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~");
if (!isObject(current) || !(key in current)) return undefined;
current = current[key];
}
return typeof current === "boolean" || isObject(current) ? current : undefined;
}
function toType(schema: CodemodeJsonSchema, indent: string, context: SchemaContext): string {
if (schema === true) return "unknown";
if (schema === false) return "never";
if (!isObject(schema)) return "unknown";
if (typeof schema.$ref === "string") return "unknown";
if (typeof schema.$ref === "string") {
const ref = schema.$ref;
const target = context.resolving.has(ref) ? undefined : resolveRef(ref, context.root);
if (target === undefined) return "unknown";
context.resolving.add(ref);
try {
return toType(target, indent, context);
} finally {
context.resolving.delete(ref);
}
}
if ("const" in schema) return JSON.stringify(schema.const) ?? "unknown";
if (Array.isArray(schema.enum)) return union(schema.enum.map((value) => JSON.stringify(value) ?? "unknown"));
const variants = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
if (variants) return union(variants.map((variant) => schemaToType(variant as CodemodeJsonSchema, indent)));
if (variants) return union(variants.map((variant) => toType(variant as CodemodeJsonSchema, indent, context)));
if (Array.isArray(schema.allOf)) {
const parts = schema.allOf.map((part) => schemaToType(part as CodemodeJsonSchema, indent));
const parts = schema.allOf.map((part) => toType(part as CodemodeJsonSchema, indent, context));
const meaningful = parts.filter((part) => part !== "unknown");
return meaningful.length === 0 ? "unknown" : meaningful.map(asElement).join(" & ");
}
const type = schema.type;
if (Array.isArray(type)) {
return union(type.map((entry) => schemaToType({ ...schema, type: entry }, indent)));
return union(type.map((entry) => toType({ ...schema, type: entry }, indent, context)));
}
switch (type) {
case "string":
@ -128,33 +161,35 @@ export function schemaToType(schema: CodemodeJsonSchema, indent = ""): string {
case "null":
return "null";
case "array":
return arrayType(schema, indent);
return arrayType(schema, indent, context);
case "object":
return objectType(schema, indent);
return objectType(schema, indent, context);
case undefined:
if (isObject(schema.properties) || isObject(schema.additionalProperties)) return objectType(schema, indent);
if (schema.items !== undefined || schema.prefixItems !== undefined) return arrayType(schema, indent);
if (isObject(schema.properties) || isObject(schema.additionalProperties)) {
return objectType(schema, indent, context);
}
if (schema.items !== undefined || schema.prefixItems !== undefined) return arrayType(schema, indent, context);
return "unknown";
default:
return "unknown";
}
}
function arrayType(schema: Record<string, unknown>, indent: string): string {
function arrayType(schema: Record<string, unknown>, indent: string, context: SchemaContext): string {
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: [];
if (tuple.length > 0) {
return `[${tuple.map((item) => schemaToType(item as CodemodeJsonSchema, indent)).join(", ")}]`;
return `[${tuple.map((item) => toType(item as CodemodeJsonSchema, indent, context)).join(", ")}]`;
}
const items = schema.items;
if (items === undefined || Array.isArray(items)) return "unknown[]";
return `${asElement(schemaToType(items as CodemodeJsonSchema, indent))}[]`;
return `${asElement(toType(items as CodemodeJsonSchema, indent, context))}[]`;
}
function objectType(schema: Record<string, unknown>, indent: string): string {
function objectType(schema: Record<string, unknown>, indent: string, context: SchemaContext): string {
const properties = isObject(schema.properties) ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const additional = schema.additionalProperties;
@ -163,11 +198,11 @@ function objectType(schema: Record<string, unknown>, indent: string): string {
for (const [name, property] of Object.entries(properties)) {
const description = isObject(property) && typeof property.description === "string" ? property.description : "";
const optional = required.has(name) ? "" : "?";
const type = schemaToType(property as CodemodeJsonSchema, inner);
const type = toType(property as CodemodeJsonSchema, inner, context);
members.push(`${docComment(description, inner)}${inner}${propertyKey(name)}${optional}: ${type};`);
}
if (additional !== undefined && additional !== false) {
const type = additional === true ? "unknown" : schemaToType(additional as CodemodeJsonSchema, inner);
const type = additional === true ? "unknown" : toType(additional as CodemodeJsonSchema, inner, context);
members.push(`${inner}[key: string]: ${type};`);
} else if (members.length === 0 && additional === undefined) {
return "Record<string, unknown>";

View file

@ -10,7 +10,9 @@
* JSON text passed in as `storeJson`, and the keys the script wrote are reported
* with a successful "done".
*
* Evaluates to a function `(bridge, toolNamesJson, globalsJson, storeJson) => { settle, run }`.
* Evaluates to a function `(bridge, toolNamesJson, globalsJson, storeJson) => { settle, run, stalled }`.
* `stalled()` reports a script that has not finished while no host call is pending: with no timers
* or I/O in the VM, nothing can ever resume it.
* `globalsJson` lists `{ name, spread }`; `a.b` names are grouped into a frozen `a` object.
* `bridge(kind, a, b, c)` with kind "call" or "global" (id, name, argsJson),
* "log" (level, message) or "done" (ok, valueJsonOrErrorJson, writesJson).
@ -26,6 +28,13 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolNamesJson, globals
const ErrorCtor = Error;
const pending = new Map();
let nextId = 1;
let finished = false;
function done(ok, payload, writes) {
if (finished) return;
finished = true;
bridge("done", ok, payload, writes);
}
function serialize(value) {
return value === undefined ? undefined : stringify(value);
@ -187,7 +196,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolNamesJson, globals
try {
promise = fn(tools, console);
} catch (error) {
bridge("done", false, describeError(error));
done(false, describeError(error));
return;
}
promiseThen.call(
@ -197,15 +206,27 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolNamesJson, globals
try {
json = serialize(value);
} catch (error) {
bridge("done", false, describeError(error));
done(false, describeError(error));
return;
}
bridge("done", true, json, serializeWrites());
done(true, json, serializeWrites());
},
(error) => {
bridge("done", false, describeError(error));
done(false, describeError(error));
},
);
},
stalled() {
if (finished || pending.size > 0) return false;
done(
false,
stringify({
name: "Error",
message:
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
}),
);
return true;
},
};
})`;

View file

@ -108,6 +108,12 @@ async function main(data: WorkerData): Promise<void> {
);
const settle = api.getProp("settle");
const run = api.getProp("run");
const stalled = api.getProp("stalled");
/** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
const drain = () => {
vm.executePendingJobs();
vm.callFunction(stalled, api).dispose();
};
parentPort?.on("message", (message: unknown) => {
if (!isHostToWorkerMessage(message)) return;
@ -121,7 +127,7 @@ async function main(data: WorkerData): Promise<void> {
message.payload === undefined ? vm.undefined : vm.newString(message.payload),
);
});
vm.executePendingJobs();
drain();
} catch (error) {
crash(error);
}
@ -139,7 +145,7 @@ async function main(data: WorkerData): Promise<void> {
}
vm.callFunction(run, api, fn).dispose();
fn.dispose();
vm.executePendingJobs();
drain();
}
if (parentPort) {

View file

@ -17,6 +17,29 @@ describe("schemaToType", () => {
expect(schemaToType(false)).toBe("never");
});
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(
'{\n item: {\n id: string;\n parent?: unknown;\n };\n legacy?: "a" | "b";\n remote?: unknown;\n}',
);
});
it("renders arrays and tuples", () => {
expect(schemaToType({ type: "array", items: { type: "string" } })).toBe("string[]");
expect(schemaToType({ type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } })).toBe(

View file

@ -354,6 +354,20 @@ describe("limits and lifetime", () => {
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 });