413 lines · 12.6 KB · TypeScript
RawDownload
import {  recoverExecutionBody,  type CodeExecutor,  type ExecuteResult,  type SandboxToolInvoker,} from "@executor-js/codemode-core";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import {  getQuickJS,  shouldInterruptAfterDeadline,  type QuickJSContext,  type QuickJSDeferredPromise,  type QuickJSHandle,  type QuickJSRuntime,  type QuickJSWASMModule,} from "quickjs-emscripten"; export type QuickJsExecutorOptions = {  timeoutMs?: number;  memoryLimitBytes?: number;  maxStackSizeBytes?: number;}; // Allow pre-loading a QuickJS module (e.g. with custom WASM bytes for compiled binaries)let preloadedModule: QuickJSWASMModule | null = null; export const setQuickJSModule = (mod: QuickJSWASMModule) => {  preloadedModule = mod;}; const resolveQuickJS = (): Promise<QuickJSWASMModule> =>  preloadedModule ? Promise.resolve(preloadedModule) : getQuickJS(); class QuickJsExecutionError extends Data.TaggedError("QuickJsExecutionError")<{  readonly message: string;}> {} const DEFAULT_TIMEOUT_MS = 5 * 60_000;const EXECUTION_FILENAME = "executor-quickjs-runtime.js"; const toError = (cause: unknown): Error =>  cause instanceof Error ? cause : new Error(String(cause)); const toErrorMessage = (cause: unknown): string => {  if (typeof cause === "object" && cause !== null) {    const message =      "message" in cause && typeof cause.message === "string" ? cause.message : undefined;     if (message) {      return message;    }     const stack = "stack" in cause && typeof cause.stack === "string" ? cause.stack : undefined;     if (stack) {      return stack;    }  }   const error = toError(cause);  return error.stack ?? error.message;}; const serializeJson = (value: unknown, label: string): string | undefined => {  if (typeof value === "undefined") {    return undefined;  }   try {    return JSON.stringify(value);  } catch (cause) {    throw new Error(`${label} is not JSON serializable: ${toError(cause).message}`);  }}; const looksLikeInterruptedError = (message: string): boolean => /\binterrupted\b/i.test(message); const timeoutMessage = (timeoutMs: number): string =>  `QuickJS execution timed out after ${timeoutMs}ms`; const normalizeExecutionError = (cause: unknown, deadlineMs: number, timeoutMs: number): string => {  const message = toErrorMessage(cause);  return Date.now() >= deadlineMs && looksLikeInterruptedError(message)    ? timeoutMessage(timeoutMs)    : message;}; const buildExecutionSource = (code: string): string => {  const body = recoverExecutionBody(code);   return [    '"use strict";',    "const __invokeTool = __executor_invokeTool;",    "const __log = __executor_log;",    "try { delete globalThis.__executor_invokeTool; } catch {}",    "try { delete globalThis.__executor_log; } catch {}",    "const __formatLogArg = (value) => {",    "  if (typeof value === 'string') return value;",    "  try {",    "    return JSON.stringify(value);",    "  } catch {",    "    return String(value);",    "  }",    "};",    "const __formatLogLine = (args) => args.map(__formatLogArg).join(' ');",    "const __makeToolsProxy = (path = []) => new Proxy(() => undefined, {",    "  get(_target, prop) {",    "    if (prop === 'then' || typeof prop === 'symbol') {",    "      return undefined;",    "    }",    "    return __makeToolsProxy([...path, String(prop)]);",    "  },",    "  apply(_target, _thisArg, args) {",    "    const toolPath = path.join('.');",    "    if (!toolPath) {",    "      throw new Error('Tool path missing in invocation');",    "    }",    "    return Promise.resolve(__invokeTool(toolPath, args[0])).then((raw) => raw === undefined ? undefined : JSON.parse(raw));",    "  },",    "});",    "const tools = __makeToolsProxy();",    "const console = {",    "  log: (...args) => __log('log', __formatLogLine(args)),",    "  warn: (...args) => __log('warn', __formatLogLine(args)),",    "  error: (...args) => __log('error', __formatLogLine(args)),",    "  info: (...args) => __log('info', __formatLogLine(args)),",    "  debug: (...args) => __log('debug', __formatLogLine(args)),",    "};",    "const fetch = (..._args) => {",    "  throw new Error('fetch is disabled in QuickJS executor');",    "};",    "(async () => {",    body,    "})()",  ].join("\n");}; const readPropDump = (context: QuickJSContext, handle: QuickJSHandle, key: string): unknown => {  const prop = context.getProp(handle, key);  try {    return context.dump(prop);  } finally {    prop.dispose();  }}; const readResultState = (  context: QuickJSContext,  handle: QuickJSHandle,): {  settled: boolean;  value: unknown;  error: unknown;} => ({  settled: readPropDump(context, handle, "settled") === true,  value: readPropDump(context, handle, "v"),  error: readPropDump(context, handle, "e"),}); const createLogBridge = (context: QuickJSContext, logs: string[]): QuickJSHandle =>  context.newFunction("__executor_log", (levelHandle, lineHandle) => {    const level = context.getString(levelHandle);    const line = context.getString(lineHandle);    logs.push(`[${level}] ${line}`);    return context.undefined;  }); type RunPromise = <A, E>(effect: Effect.Effect<A, E>) => Promise<A>; const createToolBridge = (  context: QuickJSContext,  toolInvoker: SandboxToolInvoker,  pendingDeferreds: Set<QuickJSDeferredPromise>,  runPromise: RunPromise,): QuickJSHandle =>  context.newFunction("__executor_invokeTool", (pathHandle, argsHandle) => {    const path = context.getString(pathHandle);    const args =      argsHandle === undefined || context.typeof(argsHandle) === "undefined"        ? undefined        : context.dump(argsHandle);    const deferred = context.newPromise();    pendingDeferreds.add(deferred);    deferred.settled.finally(() => {      pendingDeferreds.delete(deferred);    });     void runPromise(toolInvoker.invoke({ path, args })).then(      (value) => {        if (!deferred.alive) {          return;        }         const serialized = serializeJson(value, `Tool result for ${path}`);        if (typeof serialized === "undefined") {          deferred.resolve();          return;        }         const valueHandle = context.newString(serialized);        deferred.resolve(valueHandle);        valueHandle.dispose();      },      (cause) => {        if (!deferred.alive) {          return;        }         const errorHandle = context.newError(toErrorMessage(cause));        deferred.reject(errorHandle);        errorHandle.dispose();      },    );     return deferred.handle;  }); const drainJobs = (  context: QuickJSContext,  runtime: QuickJSRuntime,  deadlineMs: number,  timeoutMs: number,): void => {  while (runtime.hasPendingJob()) {    if (Date.now() >= deadlineMs) {      throw new Error(timeoutMessage(timeoutMs));    }     const pending = runtime.executePendingJobs();    if (pending.error) {      const error = context.dump(pending.error);      pending.error.dispose();      throw toError(error);    }  }}; const waitForDeferreds = async (  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,  deadlineMs: number,  timeoutMs: number,): Promise<void> => {  const remainingMs = deadlineMs - Date.now();  if (remainingMs <= 0) {    throw new Error(timeoutMessage(timeoutMs));  }   let timer: ReturnType<typeof setTimeout> | undefined;  try {    await Promise.race([      Promise.race([...pendingDeferreds].map((deferred) => deferred.settled)),      new Promise<never>((_, reject) => {        timer = setTimeout(() => reject(new Error(timeoutMessage(timeoutMs))), remainingMs);      }),    ]);  } finally {    if (timer !== undefined) {      clearTimeout(timer);    }  }}; const drainAsync = async (  context: QuickJSContext,  runtime: QuickJSRuntime,  pendingDeferreds: ReadonlySet<QuickJSDeferredPromise>,  deadlineMs: number,  timeoutMs: number,): Promise<void> => {  drainJobs(context, runtime, deadlineMs, timeoutMs);   while (pendingDeferreds.size > 0) {    await waitForDeferreds(pendingDeferreds, deadlineMs, timeoutMs);    drainJobs(context, runtime, deadlineMs, timeoutMs);  }   drainJobs(context, runtime, deadlineMs, timeoutMs);}; const evaluateInQuickJs = async (  options: QuickJsExecutorOptions,  code: string,  toolInvoker: SandboxToolInvoker,  runPromise: RunPromise,): Promise<ExecuteResult> => {  const timeoutMs = Math.max(100, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);  const deadlineMs = Date.now() + timeoutMs;  const logs: string[] = [];  const pendingDeferreds = new Set<QuickJSDeferredPromise>();  const QuickJS = await resolveQuickJS();  const runtime = QuickJS.newRuntime();   try {    if (options.memoryLimitBytes !== undefined) {      runtime.setMemoryLimit(options.memoryLimitBytes);    }     if (options.maxStackSizeBytes !== undefined) {      runtime.setMaxStackSize(options.maxStackSizeBytes);    }     runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadlineMs));     const context = runtime.newContext();    try {      const logBridge = createLogBridge(context, logs);      context.setProp(context.global, "__executor_log", logBridge);      logBridge.dispose();       const toolBridge = createToolBridge(context, toolInvoker, pendingDeferreds, runPromise);      context.setProp(context.global, "__executor_invokeTool", toolBridge);      toolBridge.dispose();       const evaluated = context.evalCode(buildExecutionSource(code), EXECUTION_FILENAME);      if (evaluated.error) {        const error = context.dump(evaluated.error);        evaluated.error.dispose();        return {          result: null,          error: normalizeExecutionError(error, deadlineMs, timeoutMs),          logs,        } satisfies ExecuteResult;      }       context.setProp(context.global, "__executor_result", evaluated.value);      evaluated.value.dispose();       const stateResult = context.evalCode(        "(function(p){ var s = { v: void 0, e: void 0, settled: false }; var formatError = function(e){ if (e && typeof e === 'object') { var message = typeof e.message === 'string' ? e.message : ''; var stack = typeof e.stack === 'string' ? e.stack : ''; if (message && stack) { return stack.indexOf(message) === -1 ? message + '\\n' + stack : stack; } if (message) return message; if (stack) return stack; } return String(e); }; p.then(function(v){ s.v = v; s.settled = true; }, function(e){ s.e = formatError(e); s.settled = true; }); return s; })(__executor_result)",      );      if (stateResult.error) {        const error = context.dump(stateResult.error);        stateResult.error.dispose();        return {          result: null,          error: normalizeExecutionError(error, deadlineMs, timeoutMs),          logs,        } satisfies ExecuteResult;      }       const stateHandle = stateResult.value;      try {        await drainAsync(context, runtime, pendingDeferreds, deadlineMs, timeoutMs);        const state = readResultState(context, stateHandle);        if (!state.settled) {          return {            result: null,            error: timeoutMessage(timeoutMs),            logs,          } satisfies ExecuteResult;        }         if (typeof state.error !== "undefined") {          return {            result: null,            error: normalizeExecutionError(state.error, deadlineMs, timeoutMs),            logs,          } satisfies ExecuteResult;        }         return {          result: state.value,          logs,        } satisfies ExecuteResult;      } finally {        stateHandle.dispose();      }    } finally {      for (const deferred of pendingDeferreds) {        if (deferred.alive) {          deferred.dispose();        }      }       pendingDeferreds.clear();      context.dispose();    }  } catch (cause) {    return {      result: null,      error: normalizeExecutionError(cause, deadlineMs, timeoutMs),      logs,    } satisfies ExecuteResult;  } finally {    runtime.dispose();  }}; const runInQuickJs = (  options: QuickJsExecutorOptions,  code: string,  toolInvoker: SandboxToolInvoker,): Effect.Effect<ExecuteResult, QuickJsExecutionError> =>  Effect.gen(function* () {    const context = yield* Effect.context<never>();    const runPromise = Effect.runPromiseWith(context);    return yield* Effect.tryPromise({      try: () => evaluateInQuickJs(options, code, toolInvoker, runPromise),      catch: (cause) => new QuickJsExecutionError({ message: String(cause) }),    });  }).pipe(    Effect.withSpan("executor.code.exec.quickjs", {      attributes: { "executor.runtime": "quickjs" },    }),  ); export const makeQuickJsExecutor = (  options: QuickJsExecutorOptions = {},): CodeExecutor<QuickJsExecutionError> => ({  execute: (code: string, toolInvoker: SandboxToolInvoker) =>    runInQuickJs(options, code, toolInvoker),});