164 lines
5.6 KiB
TypeScript
164 lines
5.6 KiB
TypeScript
// 沙箱调用(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<number | null> {
|
|
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<J extends SandboxJobName>(
|
|
job: J,
|
|
input: SandboxInput<J>,
|
|
opts: SandboxOptions = {},
|
|
): Promise<SandboxOutput<J>> {
|
|
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
const memoryMb = opts.memoryMb ?? 512;
|
|
opts.signal?.throwIfAborted();
|
|
if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise<SandboxOutput<J>>;
|
|
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<SandboxOutput<J>>;
|
|
}
|
|
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<J>;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
clearInterval(watch);
|
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
}
|
|
}
|
|
|
|
async function runInProcess<J extends SandboxJobName>(
|
|
job: J,
|
|
input: SandboxInput<J>,
|
|
timeoutMs: number,
|
|
): Promise<unknown> {
|
|
await prepareJobs();
|
|
const fn = SANDBOX_JOBS[job] as (i: SandboxInput<J>) => unknown;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
Promise.resolve().then(() => fn(input)),
|
|
new Promise<never>((_, 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);
|
|
}
|
|
}
|