// 沙箱调用(docs/adr/ADR-005-sandbox.md):每次一个子进程,权限全部撤销;超时 SIGKILL,子进程实际内存(RSS)超限 SIGKILL。 // 起子进程方式同 fsproxy:deno CLI → `deno run worker.ts`(开发 / 测试);编译二进制 → `<自身> --role sandbox`。 // 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。 import { basename } from "node:path"; import { fileURLToPath } from "node:url"; import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts"; export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn"; export class SandboxError extends Error { constructor( readonly kind: SandboxErrorKind, message: string, ) { super(message); this.name = "SandboxError"; } } export interface SandboxOptions { /** 缺省 20 s */ timeoutMs?: number; /** 子进程内存上限,缺省 512 MB */ memoryMb?: number; signal?: AbortSignal; } const WORKER_PATH = fileURLToPath(new URL("./worker.ts", import.meta.url)); let inProcessReason: string | null = null; const stats = { spawned: 0, timeouts: 0, memoryKills: 0, crashes: 0 }; export function sandboxStats() { return { ...stats, inProcessReason }; } function command(memoryMb: number): { cmd: string; args: string[] } { const exe = Deno.execPath(); if (/^deno(\.exe)?$/.test(basename(exe))) return { cmd: exe, args: [ "run", "-A", "--quiet", "--no-prompt", // V8 堆上限只作兜底(设为两倍):主要靠下面的 RSS 看门狗,编译产物不认 V8 参数,两种模式一致 `--v8-flags=--max-old-space-size=${memoryMb * 2}`, WORKER_PATH, ], }; return { cmd: exe, args: ["--role", "sandbox"] }; } /** 子进程实际内存(KB);读不到返回 null */ async function rssKb(pid: number): Promise { try { const s = await Deno.readTextFile(`/proc/${pid}/status`); const m = /VmRSS:\s+(\d+)\s+kB/.exec(s); return m ? Number(m[1]) : null; } catch { return null; } } export async function runSandbox( job: J, input: SandboxInput, opts: SandboxOptions = {}, ): Promise> { const timeoutMs = opts.timeoutMs ?? 20_000; const memoryMb = opts.memoryMb ?? 512; opts.signal?.throwIfAborted(); if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise>; const { cmd, args } = command(memoryMb); let child: Deno.ChildProcess; try { child = new Deno.Command(cmd, { args, stdin: "piped", stdout: "piped", stderr: "piped", env: {}, clearEnv: true, }).spawn(); } catch (e) { inProcessReason = (e as Error).message; console.warn(`[sandbox] 起不来子进程,退回进程内执行:${inProcessReason}`); return runInProcess(job, input, timeoutMs) as Promise>; } stats.spawned += 1; let killedFor: SandboxErrorKind | null = null; const kill = (why: SandboxErrorKind) => { if (killedFor) return; killedFor = why; try { child.kill("SIGKILL"); } catch { // 已退出 } }; const timer = setTimeout(() => kill("timeout"), timeoutMs); const watch = setInterval(async () => { const kb = await rssKb(child.pid); if (kb !== null && kb > memoryMb * 1024) kill("memory"); }, 100); const onAbort = () => kill("crash"); opts.signal?.addEventListener("abort", onAbort, { once: true }); try { const w = child.stdin.getWriter(); await w.write(new TextEncoder().encode(JSON.stringify({ job, input }))).catch(() => undefined); await w.close().catch(() => undefined); const [status, stdout, stderr] = await Promise.all([ child.status, new Response(child.stdout).text(), new Response(child.stderr).text(), ]); if (killedFor === "timeout") { stats.timeouts += 1; throw new SandboxError("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`); } if (killedFor === "memory" || /out of memory|heap limit/i.test(stderr)) { stats.memoryKills += 1; throw new SandboxError("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`); } opts.signal?.throwIfAborted(); const line = stdout.trim().split("\n").pop() ?? ""; let msg: { ok: boolean; result?: unknown; error?: string }; try { msg = JSON.parse(line); } catch { stats.crashes += 1; throw new SandboxError("crash", `沙箱任务 ${job} 异常退出(code ${status.code}):${stderr.trim().slice(-300)}`); } if (!msg.ok) throw new SandboxError("job", msg.error ?? "沙箱任务失败"); return msg.result as SandboxOutput; } finally { clearTimeout(timer); clearInterval(watch); opts.signal?.removeEventListener("abort", onAbort); } } async function runInProcess( job: J, input: SandboxInput, timeoutMs: number, ): Promise { await prepareJobs(); const fn = SANDBOX_JOBS[job] as (i: SandboxInput) => unknown; let timer: ReturnType | undefined; try { return await Promise.race([ Promise.resolve().then(() => fn(input)), new Promise((_, reject) => { timer = setTimeout(() => reject(new SandboxError("timeout", `沙箱任务 ${job} 超时(进程内)`)), timeoutMs); }), ]); } catch (e) { if (e instanceof SandboxError) throw e; throw new SandboxError("job", `${(e as Error).name}: ${(e as Error).message}`); } finally { clearTimeout(timer); } }