import type { QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core"; import { scheduler } from "node:timers/promises"; import { installConsole, type SandboxLog } from "./builtins/console.ts"; import { createSandboxRuntime } from "./runtime.ts"; import { inspect } from "./utils/error-handling.ts"; import { PendingPromiseTracker, toQuickJS } from "./utils/to-quickjs.ts"; import type { IClient } from "../client-like.ts"; function executePendingJobs(ctx: { runtime: { executePendingJobs: Function }; }) { const res = ctx.runtime.executePendingJobs(100); try { if ("unwrap" in res && typeof res.unwrap === "function") { res.unwrap(); } } finally { if ("dispose" in res && typeof res.dispose === "function") { res.dispose(); } } } async function resolvePromiseWithJobPump( ctx: QuickJSContext, promiseLikeHandle: QuickJSHandle, timeoutMs: number, ) { const startedAt = Date.now(); const hostPromise = ctx.resolvePromise(promiseLikeHandle); while (true) { executePendingJobs(ctx); const raced = await Promise.race([ hostPromise, scheduler.yield().then(() => null), ]); if (raced !== null) { return raced; } if (Date.now() - startedAt > timeoutMs) { throw new Error( `Timed out after ${timeoutMs}ms while awaiting a QuickJS promise`, ); } } } export interface RunCodeOptions { client: IClient; code: string; timeoutMs: number; memoryLimitBytes?: number; stackSizeBytes?: number; } export interface RunCodeResult { returnValue?: unknown; error?: string; consoleLogs: SandboxLog[]; } export async function runCode({ client, code, timeoutMs, memoryLimitBytes, stackSizeBytes, }: RunCodeOptions): Promise { const promiseTracker = new PendingPromiseTracker(); using runtime = await createSandboxRuntime({ memoryLimitBytes: memoryLimitBytes ?? 32 * 1024 * 1024, stackSizeBytes: stackSizeBytes ?? 512 * 1024, }); using ctx = runtime.newContext({ interruptAfterMs: timeoutMs }); using consoleBuiltin = installConsole(ctx); try { const moduleRes = ctx.evalCode(code, "index.mjs", { strip: true, strict: true, type: "module", }); const exportsOrPromiseHandle = ctx.unwrapResult(moduleRes); // Build a plain object so toQuickJS sees own enumerable properties // (IClient methods live on the prototype and would be invisible). const sandboxClient: IClient = { callTool: (params: unknown) => client.callTool(params as Parameters[0]), listTools: (params?: unknown) => client.listTools(params as Parameters[0]), listResources: (params?: unknown) => client.listResources(params as Parameters[0]), readResource: (params: unknown) => client.readResource(params as Parameters[0]), listResourceTemplates: (params?: unknown) => client.listResourceTemplates( params as Parameters[0], ), listPrompts: (params?: unknown) => client.listPrompts(params as Parameters[0]), getPrompt: (params: unknown) => client.getPrompt(params as Parameters[0]), getServerCapabilities: () => client.getServerCapabilities(), getInstructions: () => client.getInstructions(), close: () => client.close(), }; const clientHandle = toQuickJS( ctx, sandboxClient, 50, 0, new WeakSet(), promiseTracker, ); // When evaluating ES modules, QuickJS can return either: // - the module exports object, or // - a promise for the module exports (top-level await / async module init) const exportsHandle = ctx.runtime.hasPendingJob() ? ctx.unwrapResult( await resolvePromiseWithJobPump( ctx, exportsOrPromiseHandle, timeoutMs, ), ) : exportsOrPromiseHandle; if (exportsHandle !== exportsOrPromiseHandle) { exportsOrPromiseHandle.dispose(); } const userFnHandle = ctx.getProp(exportsHandle, "default"); const userFnType = ctx.typeof(userFnHandle); if (userFnType !== "function") { userFnHandle.dispose(); exportsHandle.dispose(); clientHandle.dispose(); return { error: `Code must export default a function (client). Got ${userFnType}. Example: export default async (client) => { /* ... */ }`, consoleLogs: consoleBuiltin.logs, }; } const callRes = ctx.callFunction(userFnHandle, ctx.undefined, clientHandle); clientHandle.dispose(); userFnHandle.dispose(); exportsHandle.dispose(); const callHandle = ctx.unwrapResult(callRes); const awaitedRes = await resolvePromiseWithJobPump( ctx, callHandle, timeoutMs, ); callHandle.dispose(); const awaited = ctx.unwrapResult(awaitedRes); // Drain any final microtasks (best-effort) if (ctx.runtime.hasPendingJob()) { executePendingJobs(ctx); } const value = ctx.dump(awaited); awaited.dispose(); return { returnValue: value, consoleLogs: consoleBuiltin.logs }; } catch (err) { return { error: inspect(err), consoleLogs: consoleBuiltin.logs }; } finally { // Dispose outstanding deferred promise handles before the runtime is freed. // Without this, in-flight host promises (e.g. slow client.callTool calls) // leave QuickJS GC objects alive, causing gc_obj_list assertion failures. promiseTracker.dispose(); } }