Files
IOT_SIM/server/src/sandbox.ts
T
xuyueandClaude Opus 5.5 a9af26c2d6
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
修复 CI:评分点表格前等那一行出现;程序出错时优先报异常;CPU 超时不再生成 core dump
- 评分的"点 / 修改表格单元格"步骤最多等 5 秒,让上一步(保存后重新查询)刷新出的行出现;
  CI(2 核、整套测试同时跑)上 p6-t2"删除"一项因此偶发失败
- 检查项不通过时,如果程序抛过异常,报"程序出错:…"(异常消息可能比失败的检查晚到)
- 学生程序的 CPU 时间上限软硬相同,到时直接 SIGKILL,并且不生成 core dump:
  原来先发 SIGXCPU,每个死循环都留下几 MB 的转储,本机桌面还会弹出"程序崩溃"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:20:58 +08:00

120 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 学生程序的沙箱:每次运行用 bubblewrap 放进独立的命名空间。
// 断网;只能看到系统只读文件、运行时程序和自己的工程目录;/tmp 是独立的内存盘;
// 看不到服务器上的其他进程(包括别的学生程序);服务器进程的环境变量也不会传进去。
import { lstatSync, readlinkSync, realpathSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
import { config } from './config'
const RUNNER_DIR = '/sim/runner'
const APP_DIR = '/sim/app'
const DB_DIR = '/sim/db'
/** 学生程序的全部环境变量(不继承服务器的) */
function runnerEnv(extra: Record<string, string>) {
return {
PATH: '/usr/local/bin:/usr/bin:/bin',
HOME: '/tmp',
TZ: config.timeZone,
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
// 学生写出死循环分配内存时,在这里报 OutOfMemoryException,而不是拖垮服务器
DOTNET_GCHeapHardLimit: (config.heapLimitMb * 1024 * 1024).toString(16),
...extra,
}
}
/** /lib、/bin 等在新系统上是指向 /usr 的链接,照原样建链接;老系统上是真目录,只读挂载 */
function rootLinks(): string[] {
const args: string[] = []
for (const path of ['/bin', '/sbin', '/lib', '/lib32', '/lib64']) {
let stat
try {
stat = lstatSync(path)
} catch {
continue
}
if (stat.isSymbolicLink()) args.push('--symlink', readlinkSync(path), path)
else if (stat.isDirectory()) args.push('--ro-bind', path, path)
}
return args
}
/** dotnet 不在 /usr 下时(比如装在 /opt),把它所在目录也挂进去 */
function dotnetMounts(): string[] {
const dotnet = Bun.which('dotnet')
if (!dotnet) return []
const home = dirname(realpathSync(dotnet))
return home.startsWith('/usr/') ? [] : ['--ro-bind', home, home]
}
/** 沙箱不可用的原因;可用(或没启用沙箱)时为 undefined。服务器启动时检查一次 */
export let sandboxError: string | undefined
export async function checkSandbox() {
if (config.sandbox === 'none') return
await mkdir(config.runsDir, { recursive: true })
const proc = Bun.spawn([...bwrapArgs(config.runsDir), 'true'], { stdout: 'ignore', stderr: 'pipe', env: runnerEnv({}) })
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
if (code === 0) return
sandboxError =
`沙箱(bubblewrap)无法启动:${stderr.trim().replace(/[.。]$/, '')}。` +
'学生程序不会在没有隔离的情况下运行。检查 compose.yaml 里的 security_opt;' +
'Ubuntu 23.10 及以上的宿主机还要执行 sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0(见 deploy/README.md)'
}
/**
* 启动一次学生程序运行的命令行和环境变量。
* dbDir:这次运行使用的数据库目录(学生自己的,或评分时的临时目录),学生程序可以读写;不给时程序连不上数据库。
*/
export function runCommand(projectDir: string, extraEnv: Record<string, string>, dbDir?: string) {
if (config.sandbox === 'none') {
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: dbDir } : {}) })
return { cmd: ['dotnet', config.runnerDll, 'run', projectDir], env }
}
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
const cmd = [
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
'nice', '-n', '10',
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
...bwrapArgs(projectDir),
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
]
return { cmd, env }
}
const SIGKILL = 9
/**
* 运行进程退出时,如果是因为 CPU 时间用完,给学生的说明。
* CPU 上限到时内核发 SIGKILL,bwrap 把子进程"被信号结束"转成退出码 128 + 信号编号;
* 平台自己结束程序(停止、超时、关闭页面)用的是 SIGTERM,不会误报。
*/
export function cpuLimitMessage(code: number | null, signal: string | number | null) {
return signal === 'SIGKILL' || code === 128 + SIGKILL
? `\n程序占用 CPU 太多,已被结束(累计超过 ${config.cpuLimitSeconds} 秒)。检查是否有死循环:循环里要有退出条件,等待请用 Timer 或 Thread.Sleep。\n`
: undefined
}
function bwrapArgs(projectDir: string) {
return [
'bwrap',
'--unshare-all',
'--die-with-parent',
'--new-session',
'--ro-bind', '/usr', '/usr',
...rootLinks(),
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
...dotnetMounts(),
'--proc', '/proc',
'--dev', '/dev',
'--tmpfs', '/tmp',
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
'--ro-bind', projectDir, APP_DIR,
'--chdir', APP_DIR,
]
}