牢笼建好了,犯人也关进去了。最后的问题是:我们怎么知道他在里面干了什么?
对于 Code Interpreter 来说,用户的代码输出(Stdout/Stderr)必须实时、完整地传回给前端。而且,如果代码死循环了,我们得能把它杀掉。
这两个看似简单的需求,在实现时也让我们踩了不少坑。
坑一:IO 阻塞与 Goroutine 泄漏
最初,我们简单地用 cmd.StdoutPipe() 获取管道,然后在一个 Goroutine 里读。
// 错误示范
go func() {
io.Copy(outputChan, stdoutPipe)
}()
但在高并发场景下,我们发现 Goroutine 数量飙升。原来,如果 Python 进程异常退出或者被 kill 掉,有时候管道的 Read 操作会一直阻塞,导致 Goroutine 无法释放。
为了解决这个问题,我们在 internal/core/runner/output_capture.go 里重写了读取逻辑,引入了 sync.WaitGroup 和更细致的错误处理。
// internal/core/runner/output_capture.go
func (s *OutputCaptureRunner) CaptureOutput(cmd *exec.Cmd) error {
// ...
wg := sync.WaitGroup{}
wg.Add(2) // 等待 Stdout 和 Stderr
go func() {
defer wg.Done()
buf := make([]byte, 1024)
for {
n, err := stdout_reader.Read(buf)
if err != nil {
// 只有明确读到 EOF 或者管道关闭,才退出循环
break
}
s.WriteOutput(buf[:n])
}
}()
// ...
}
我们还特意加了一个 done 通道,确保只有当所有 IO 都读取完毕并且进程真正退出后,才算任务结束。
坑二:超时杀不掉的进程
超时控制看起来很简单:用 time.AfterFunc 设置个定时器,时间到了就 cmd.Process.Kill()。
// internal/core/runner/output_capture.go
timer := time.AfterFunc(timeout, func() {
if cmd.Process != nil {
cmd.Process.Kill()
}
})
但实际运行中,我们发现偶尔会有“僵尸进程”。因为 Kill 发送的是 SIGKILL 信号,但如果进程处于某些不可中断的系统调用(Uninterruptible Sleep)状态,它可能不会立即死去。
虽然在我们的沙箱场景下这种情况很少见(因为 Seccomp 限制了大部分可能导致 D 状态的 syscall),但这提醒我们:进程管理远比想象中复杂。
坑三:Seccomp 的“误杀”反馈
当用户代码因为违规操作(比如偷偷联网)被 Seccomp 拦截时,进程会收到 SIGSYS 信号退出。
但在 Go 的 exec.ExitError 里,默认的错误信息很含糊。用户看到“Process exited with status 159”一脸懵逼。
为了提升体验,我们在 Wait 逻辑里专门解析了退出码:
// internal/core/runner/output_capture.go
if status.ExitCode() != 0 {
exit_string := status.String()
// 如果包含 "bad system call",说明是被 Seccomp 干掉的
if strings.Contains(exit_string, "bad system call") {
s.WriteError([]byte("error: operation not permitted\n"))
}
}
这样用户就能收到明确的报错:“operation not permitted”,知道自己越界了。
结语:造轮子的快乐与痛苦
回顾 go-sandbox 的开发历程,从最初放弃 Docker 的纠结,到发现 ctypes 注入方案的惊喜,再到调试 Seccomp 规则的抓狂,每一步都充满了挑战。
虽然它现在还不够完美,但它达成了一个重要的目标:在毫秒级的启动速度下,提供足够安全的执行环境。
如果你也在做 LLM Agent,或者对系统编程感兴趣,希望这个系列的文章能给你一些启发。毕竟,在这个 AI 时代,让代码跑得既快又稳,是我们后端工程师不变的追求。