数据面:execd 在沙箱里跑命令、文件、PTY 与代码解释器
30 秒导读: 控制面(02)把一个沙箱容器造出来后,真正"在里面干活"的是容器里跑着的一个 Go 守护进程 execd。它监听一个端口,对外提供一组 HTTP + WebSocket 接口:跑一条命令、读写文件、开一个交互式终端、通过 Jupyter 内核执行 Python、查一次 SQL。平台承诺的每一次"执行",最后都落在 execd 上。 本章讲 execd 的启动、路由和五种执行后端的实现原理。
前置:本章假设你已读过 index(OpenSandbox 是什么)和 01(协议契约)。容器怎么被造出来见 02/03;容器里再套一层 bubblewrap 隔离(/v1/isolated 路由)单独留给 05;入站代理、出站策略见 06。
1. execd 是什么(零基础也能懂)
一句话定义: execd 是装在每个沙箱容器镜像里的一个 Go 二进制,启动后变成一个常驻 HTTP 服务,别人通过网络调它的 API 来"在这个沙箱里执行东西"。
它站在哪一层? 用一个类比:如果把整个平台想成"一台远程电脑出租服务",那么
- 控制面(网关 + runtime 后端)= 前台,负责"给你开一台机器、关一台机器";
- execd = 那台机器里已经登录好、随时听你差遣的操作员,你说"跑这条命令""把这个文件读出来""给我开个终端",它照做并把结果传回来。
它对外提供什么(五类执行后端 + 文件):
| 能力 | 路由前缀 | 大白话 |
|---|---|---|
| 一次性命令 | /command | 跑 pip install ...,前台等结果或后台跑 |
| 持久 shell 会话 | /session | 像一个记得住 cd 和环境变量的终端 |
| 代码解释器 | /code /session(context) | 通过 Jupyter 内核跑 Python/JS/… 单元格 |
| 交互式 PTY | /pty + WebSocket | 真·交互终端(vim、top、带颜色) |
| SQL | /code(language=sql) | 对沙箱本地 MySQL 跑查询 |
| 文件系统 | /files /directories | 增删查改、上传下载、搜索、内容替换 |
用起来什么样: 客户端就是发 HTTP。跑一条命令大致是:
# 示意,非源码:向沙箱里的 execd 要一次命令执行
curl -N http://<sandbox>:44772/command \
-H 'X-EXECD-ACCESS-TOKEN: <token>' \
-d '{"command":"echo hi && ls","cwd":"/workspace"}'
# 返回是一条 SSE 流:init → stdout("hi") → stdout(...) → complete
一句话直觉: execd 是沙箱的"执行数据面"。控制面搬机器,execd 在机器里动手;它把"跑东西"这件事拆成命令 / 会话 / 代码 / 终端 / SQL 五种后端,统一用会话 ID + 流式输出的形态暴露出去。
2. 顶层全景(execd 大概怎么转)
2.1 启动:一个 main 干了什么
components/execd/main.go:39 的 main() 按顺序做几件事(隔离相关的两步属于 05,这里只标出位置):
main() components/execd/main.go:39
├─ clone3compat.MaybeApply() :40 老内核 seccomp 兼容(见 05)
├─ flag.InitFlags() :44 读 env / 命令行:端口、jupyter地址、access-token…
├─ isolation.LoadConfig / Probe :47 探测 bubblewrap 能力(见 05)
├─ controller.InitCodeRunner() :63 ★造出核心 runtime.Controller,数据面的心脏
├─ InitIsolatedProbe / InitIsolatedRunner 嵌套隔离 runner(见 05)
├─ telemetry.Init() :82 OpenTelemetry 指标(失败则降级继续)
└─ web.NewRouter(flag.ServerAccessToken) :95 ★装配所有路由
└─ net.Listen("tcp4", ":44772") + RunListener :96 只监听 IPv4
两个 ★ 就是数据面的全部:一个 runtime.Controller(所有执行后端的实现),和一个 Gin router(把 HTTP 路由映射到 controller 方法)。默认端口 44772,access-token 默认空(flag.InitFlags at components/execd/pkg/flag/parser.go:37,env 名 EXECD_ACCESS_TOKEN)。
2.2 路由地图:HTTP 表面
web.NewRouter(components/execd/pkg/web/router.go:28)用 Gin 建一棵路由树,分成几组:
| 路由组 | 典型端点 | 干什么 | 落到哪个 controller |
|---|---|---|---|
/files | GET /files/download、POST /files/replace、POST /files/upload | 文件增删查改、上传下载、内容替换、chmod、搜索 | FilesystemController |
/directories | GET /directories/list、POST/DELETE "" | 列目录、建/删目录 | FilesystemController |
/code | POST /code、POST /code/context、GET /code/contexts | 跑代码 + 管理 Jupyter 上下文 | CodeInterpretingController |
/session | POST ""、POST /:id/run、DELETE /:id | 持久 bash 会话 | CodeInterpretingController |
/command | POST ""、DELETE ""、GET /status/:id、GET /:id/logs | 前台/后台命令、状态、中断、日志 | CodeInterpretingController |
/pty | POST ""、GET /:id/ws | 交互式终端 + WebSocket | PTYController / PTYSessionWebSocket |
/metrics | GET ""、GET /watch | 沙箱指标 | MetricController |
/v1/isolated | POST /session … | 嵌套 bubblewrap 隔离会话(见 05) | IsolatedSessionController |
中间件链(router.go:32,按顺序):
请求 → logMiddleware → otelHTTPMetricsMiddleware → accessTokenMiddleware → ProxyMiddleware → 路由 handler
│ │
校验 X-EXECD-ACCESS-TOKEN /proxy/<port>/... 反向代理(见 06)
不符→401(router.go:150) 其余请求放行
- access token 中间件(
router.go:150accessTokenMiddleware):token 为空时完全放行;非空时要求请求头X-EXECD-ACCESS-TOKEN(model.ApiAccessTokenHeader = "X-EXECD-ACCESS-TOKEN")精确匹配,否则 401。这是 execd 唯一的鉴权,粒度是"整个进程一把 token"。 withX闭包(router.go:120起):每个请求new一个新的 controller 实例包住当次*gin.Context,controller 本身不持有跨请求状态——所有跨请求状态都在那个单例runtime.Controller里。
2.3 一次执行请求的主线
以 POST /command(前台命令)为例,端到端走一遍(不进代码):
HTTP POST /command
│ RunCommand() controller/command.go:33
▼ 解析 body → buildExecuteCommandRequest → ExecuteCodeRequest{Language:"command",...}
│ setServerEventsHandler(ctx) 把 SSE 写出封装成 7 个回调 Hook(sse.go:57)
▼
codeRunner.Execute(req) runtime/ctrl.go:80 ← 按 Language 分发
│ switch Language { command→runCommand; bash/python…→runJupyter; sql→runSQL; background-command→… }
▼
runCommand(ctx, req) runtime/command.go:100
│ exec bash -c <code>,stdout/stderr 写进临时文件
│ 后台 goroutine tailStdPipe 每 100ms 读文件增量 → 回调 OnExecuteStdout
▼
每个回调 → writeSingleEvent → 往 HTTP 响应写一帧 SSE
关键设计:后端与传输解耦。 runtime.Controller 从不知道 SSE、Gin、HTTP 的存在——它只认识一组 ExecuteResultHook 回调(runtime/types.go:26)。是 controller 层把这些回调绑到 SSE 写出上(sse.go:57 setServerEventsHandler)。所以同一套执行逻辑,换个 hook 就能换传输(测试里就直接用打印 hook,types.go:50 SetDefaultHooks)。
3. 数据模型:Controller 和它的会话表
一切执行都挂在单例 runtime.Controller 上(components/execd/pkg/runtime/ctrl.go:37)。它的字段几乎全是并发安全的会话表:
// components/execd/pkg/runtime/ctrl.go:37 Controller(节选)
type Controller struct {
baseURL, token string // Jupyter server 地址与 token
jupyterClientMap sync.Map // sessionID → *jupyterKernel 代码解释器上下文
defaultLanguageSessions sync.Map // Language → sessionID 无 context 时的预热内核
commandClientMap sync.Map // sessionID → *commandKernel 命令执行记录
bashSessionClientMap sync.Map // sessionID → *bashSession 持久 shell 会话
ptySessionMap sync.Map // sessionID → *ptySession PTY 终端
isolatedSessionMap sync.Map // sessionID → *isolatedSession 嵌套隔离(见 05)
db *sql.DB // 懒加载的本地 MySQL 连接
}
每种后端一张 sync.Map,key 都是一个 session/context ID(UUID)。"会话"在 execd 里就是这些 map 里的一条记录 + 它背后的一个进程或内核连接。
分发中枢 Execute(ctrl.go:80)是唯一入口,按请求里的 Language 字段选后端:
// components/execd/pkg/runtime/ctrl.go:80 Execute(节选)
switch request.Language {
case Command: return c.runCommand(ctx, request) // 前台命令
case BackgroundCommand: return c.runBackgroundCommand(ctx, cancel, request) // 后台命令
case Bash, Python, Java, JavaScript, TypeScript, Go:
return c.runJupyter(ctx, request) // Jupyter 内核
case SQL: return c.runSQL(ctx, request) // SQL
}
Language 是一组常量(runtime/language.go:20:command / bash / python / sql / background-command …)。注意一个易错点:bash 走的是 Jupyter 内核(和 python 一样),而持久 bash 会话(/session)不走 Execute,而是单独的 RunInBashSession 路径(见 §5)。CreateContext 的注释也点破了这个区分(runtime/context.go:35)。
ExecuteCodeRequest(runtime/types.go:37)是统一请求体,带 Code、Context(会话 ID)、Cwd、Envs、Uid/Gid、Timeout 和 Hooks。
4. 命令执行(/command)
这是最常用、也最能看清 execd "怎么落地一次执行"的后端。分前台和后台两条路。
4.1 前台命令:跑一条,流式吐输出
runCommand(components/execd/pkg/runtime/command.go:100)的核心思路:用 bash -c 起进程,把 stdout/stderr 重定向到临时文件,再另起 goroutine 追(tail)这两个文件,把增量喂给回调。
为什么绕一圈写文件、而不直接接管道?因为写文件让"输出"有了一个可回放的落点——后台命令、状态查询都能事后再读它(见 §4.2)。
// components/execd/pkg/runtime/command.go:120 起(节选)
shell := getShell() // 有 bash 用 bash,否则 sh(Alpine 兜底)
cmd := exec.CommandContext(ctx, shell, "-c", request.Code)
cmd.Stdout, cmd.Stderr = stdout, stderr // → /tmp/<session>.stdout / .stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // ★独立进程组,便于整组收信号
Credential: cred, // 可选降权到指定 uid/gid
}
// 两个 goroutine tail 文件 → OnExecuteStdout / OnExecuteStderr
go c.tailStdPipe(stdoutPath, request.Hooks.OnExecuteStdout, done)
go c.tailStdPipe(stderrPath, request.Hooks.OnExecuteStderr, done)
- shell 兜底
getShell()(command.go:52):优先bash,找不到退回sh,专门照顾只带 sh 的 Alpine 镜像。 - 降权执行
buildCredential(command.go:59):请求给了uid就user.LookupId补上主组和附加组,让命令以非 root 身份跑。 - 输出追随
tailStdPipe(command_common.go:29):100ms tick,用readFromPos(command_common.go:110)从上次位置读文件增量;readFromPos还小心处理了跨两次读被切开的\r\n,不至于多冒一行空行。
4.2 后台命令:发了就走,靠 cursor 拉日志
runBackgroundCommand(command.go:260)与前台的差别:
| 维度 | 前台 runCommand | 后台 runBackgroundCommand |
|---|---|---|
| stdout/stderr | 分成两个文件 | 合并到一个 .output 文件 |
| stdin | 继承 | 接到 /dev/null(command.go:304),交互程序立刻退出 |
| 返回 | 阻塞到进程结束 | cmd.Start() 后立即返回,cmd.Wait() 在 goroutine 里 |
| 拉输出 | SSE 实时流 | GET /command/:id/logs?cursor=N 增量拉 |
后台输出靠 SeekBackgroundCommandOutput(command_status.go:78):按 cursor(字节偏移)Seek 到文件位置,读到末尾,返回新数据 + 新 cursor(通过响应头 EXECD-COMMANDS-TAIL-CURSOR 回传,controller/command.go:151)。客户端拿新 cursor 下次接着拉,实现断点续读。
4.3 状态、中断与"进程组信号"的坑
每条命令在 commandClientMap 里存一个 commandKernel(ctrl.go:58):pid、两个输出文件路径、起止时间、退出码、是否后台。
- 查状态
GetCommandStatus(command_status.go:59)返回运行中/退出码/错误。 - 中断 走
Interrupt(runtime/interrupt.go:30),它先判断 session 属于哪类后端,命令类走killPid。
killPid(interrupt.go:66)不是简单 kill 一个 pid,而是对整个进程组发信号(因为命令是 Setpgid 起的,pid 同时是 pgid):先 SIGTERM 给 -pid,探活 3 秒还在就升级 SIGKILL。
这里藏着一个真实的**"陈旧 PID"竞态**,代码里用大段注释和防护处理了,值得学:
命令结束后
markCommandFinished(command_status.go:116)会把kernel.pid清零。为什么?因为syscall.Kill(-pid, ...)是打一整组,一旦某条命令已结束、pid 被系统回收复用,一个迟到的Interrupt若还按老 pid 发 SIGKILL,就可能误杀一个不相干的进程组。所以Interrupt里先commandSnapshot拿一致快照,running==false || pid<=0直接拒绝(interrupt.go:43)。
同样的防护在前台 runCommand 的 ctx.Done() 分支(command.go:180-209):用一个 done channel 做门,确认 cmd.Wait() 尚未返回才对 -pid 发 SIGKILL,避免信号打到复用的 pgid 上。这类"信号 vs 回收"的时序防护,是沙箱执行器最容易出安全事故的地方。
5. 持久 bash 会话(/session)
/session 想解决的小问题:命令彼此独立,cd /foo 之后再跑一条命令又回到原点;用户要的是记得住 cwd 和环境变量的终端。
直觉上你会以为它常驻一个 bash 进程、把命令喂进去。但 execd 没这么做——看 bashSession(runtime/types.go:100)的字段:只有 env map、cwd string、currentProcessPid,没有一个长活的 stdin 管道。
真正的机制在 bashSession.run(components/execd/pkg/runtime/bash_session.go:159):每跑一条命令,都新起一个一次性 bash 进程执行一个"包裹脚本",脚本尾部把最新的环境和 cwd 打印出来,execd 解析回来存进会话——下次运行时再重新注入。 "持久"是逻辑上的持久(状态在 execd 的 map 里),不是一个常驻进程。
包裹脚本由 buildWrappedScript(bash_session.go:322)拼出,结构是:
export K=V … # 把上次会话记住的所有环境变量重新导出
cd <上次的 cwd> # 恢复工作目录
<用户的命令>
__USER_EXIT_CODE__=$? # 记住用户命令的退出码
printf __ENV_DUMP_START__ ; export -p ; printf __ENV_DUMP_END__ # 把新环境全 dump 出来
printf __PWD__:$(pwd) # 把新 cwd 打出来
printf __EXIT_CODE__:$__USER_EXIT_CODE__
exit $__USER_EXIT_CODE__
run 用 scanner 扫子进程输出,靠这几个 marker 分流:__ENV_DUMP_*__ 之间的 declare -x 行交给 parseExportDump(bash_session.go:372)解析成新 env;__PWD__: 更新 cwd;__EXIT_CODE__: 取退出码;其余行才是真正的用户输出,喂给 OnExecuteStdout(bash_session.go:236-258)。解析完把新 env/cwd 写回 s.env/s.cwd(bash_session.go:279),下一次 run 就带上了。
几个务实的细节:
- 不通过
cmd.Env传环境,而是写进脚本顶部的export(bash_session.go:208-212的注释):会话环境可能很大,走cmd.Env会触发 "argument list too long"; 写文件里 export 就没这问题。 - 过滤持久化的变量:
PS1/PS2/PROMPT_COMMAND等提示符变量不跨命令保留(envKeysNotPersisted,bash_session.go:364),单个值超 8KiB 也不留(maxPersistedEnvValueSize)。 DeleteSession/ 中断:close()(bash_session.go:459)对当前跑着的进程组发 SIGKILL,并清空 env/cwd。
诚实说明: 本章的授权任务描述把 "replay_buffer 输出重放" 归在 bash_session 名下,但源码里
replayBuffer实际服务的是 PTY 会话(见 §7),bash 会话并不用它——bash 会话的"重放"是上面这套 env/cwd 的 dump-and-reinject。
6. 代码解释器(/code + Jupyter 内核)
6.1 context 与 session 两个概念
代码解释器有两组会话概念,容易混:
| 概念 | 创建端点 | 背后是什么 | 用途 |
|---|---|---|---|
| context | POST /code/context | 一个 Jupyter 内核会话(Python/JS/…) | 有状态地跑代码单元格,变量跨 cell 保留 |
| (bash)session | POST /session | 一个持久 bash 会话(§5) | 有状态地跑 shell 命令 |
| default context | 隐式,首次无 context 跑代码时 | 每语言一个预热内核 | 无状态、一次性执行 |
POST /code(RunCode,controller/codeinterpreting.go:111)执行一段代码:请求带 context.id 就用那个内核;不带就用/建该语言的 default context。
6.2 无 context 时:预热一个默认内核
runJupyter(runtime/jupyter.go:27)的开头逻辑:请求没给 context,就查该语言有没有 default session,没有就 createDefaultLanguageJupyterContext(context.go:148)现建一个并缓存。这样无状态调用不必每次付"建内核"的钱。
建内核的实际流程 createJupyterContext(context.go:182):
searchKernel(language) jupyter.go:148 从 kernelspecs 里按语言找内核名(跳过 python3)
→ newContextID() 生成 UUID
→ newIpynbPath() 在 cwd 下建一个 <id>.ipynb 落点
→ client.CreateSession() 在 Jupyter server 上建 session + kernel
→ ListKernels() 校验 kernel 确实起来了
6.3 真正的执行:说 Jupyter 协议
execd 自己不跑内核——内核由容器里另一个 Jupyter server 托管(地址来自 JUPYTER_HOST)。execd 是 Jupyter 的WebSocket 客户端。
runJupyterCode(jupyter.go:60)的骨架:
// components/execd/pkg/runtime/jupyter.go:60(节选)
if !kernel.mu.TryLock() { return errors.New("session is busy") } // 一个内核同时只跑一段
kernel.client.ConnectToKernel(kernel.kernelID) // 连 ws
results := make(chan *execute.ExecutionResult, 10)
kernel.client.ExecuteCodeStream(kernel.kernelID, request.Code, results)
for {
select {
case r := <-results: dispatchExecutionResultHooks(request, r) // 结果→hook→SSE
case <-ctx.Done(): kernel.client.InterruptKernel(...) // 超时/取消→中断内核
}
}
ConnectToKernel(jupyter/client.go:166)把 baseURL 转成 ws(s)://host/api/kernels/<id>/channels?token=...。底层协议实现在 jupyter/execute/execute.go:
buildExecuteMessage(execute.go:160)拼一个 Jupyter v5.3 的execute_request(Silent:false, StoreHistory:true, AllowStdin:false, StopOnError:true),发到shellchannel。- 一个接收 goroutine(
receiveMessages,execute.go:462)按消息类型分发:execute_result→结果数据、stream→stdout/stderr、error→异常、status→内核忙/闲。 - 终局判定
handleExecutionStatus(execute.go:275):收到idle状态才认为"这段执行完了",触发finalizeExecution(execute.go:293)算耗时、等一会儿捞可能迟到的execute_result/error,再关闭resultschannel。轮询间隔是可配的JupyterIdlePollInterval(默认 100ms)。
dispatchExecutionResultHooks(jupyter.go:103)把这些内核事件翻译成统一的 ExecuteResultHook 回调,和命令执行走同一套 SSE 出口。
7. PTY:交互式终端(/pty + WebSocket)
前面几种后端都是"发一段、收结果"。但 vim、top、带颜色的 REPL 需要真·终端:字符级双向、能改窗口大小、能发 Ctrl-C。这就是 PTY 会话。
7.1 两种模式:PTY vs pipe
ptySession(runtime/pty_session.go:75)可以两种方式起 bash:
| 模式 | 启动 | 特点 |
|---|---|---|
| PTY | StartPTY(pty_session.go:224)用 pty.StartWithSize | 分配伪终端,程序以为连着真终端;stdout/stderr 合流;能 resize |
| pipe | StartPipe(pty_session.go:266)用三个 os.Pipe | 普通管道;stdout/stderr 分开;无终端语义 |
一个细节坑写在注释里(pty_session.go:244):PTY 模式不能再设 Setpgid,因为 pty.StartWithSize 内部已 Setsid+Setctty,对会话首进程再 setpgid 会 EPERM。
7.2 输出重放:1 MiB 环形缓冲
PTY 的难点是客户端可能断开重连(网络抖动、切标签页),重连后不能丢掉这期间的输出。replayBuffer(runtime/replay_buffer.go:25)解决它:
- 一个 1 MiB 定长环形缓冲 + 一个单调递增的总字节计数
total。 - 不变式
head == total % size(replay_buffer.go:22注释):绝对偏移o的字节永远在buf[o % size],不管环有没有绕回。 ReadFrom(offset)(replay_buffer.go:85):返回从某偏移到当前的所有字节 + 实际起点;若 offset 太旧(已被覆盖)就 clamp 到最老可用位置。
进程每吐一段输出,writeAndFanout(pty_session.go:373)同时做两件事:写进 replay buffer,并转发给当前连着的 WebSocket 客户端。两步在 outMu 锁内原子完成,专门堵住"写进 replay 之后、attach 之前"那段字节被漏掉的窗口(pty_session.go:367 注释)。
7.3 WebSocket 协议与"接管"
PTYSessionWebSocket(components/execd/pkg/web/controller/pty_ws.go:71)处理 GET /pty/:id/ws,是全章并发最密的一段。它用一个自定义二进制帧协议(model/pty_ws.go):
| 首字节 | 方向 | 含义 |
|---|---|---|
0x00 BinStdin | 客户端→服务端 | 原始 stdin 字节 |
0x01 BinStdout | 服务端→客户端 | stdout 字节 |
0x02 BinStderr | 服务端→客户端 | stderr(仅 pipe 模式) |
0x03 BinReplay | 服务端→客户端 | [8字节 BE 偏移][重放字节] |
握手后 execd 先发一帧 BinReplay(把断连期间的输出补上,pty_ws.go:231),再发 connected 帧,然后起几个 goroutine:ping 保活、stdout/stderr 泵、退出监视、客户端读循环。文本帧还支持 signal(发信号)、resize(改窗口)、ping。
独占与接管:一个 PTY 同时只允许一个 WebSocket 客户端(LockWS/wsConnected 原子标志)。新客户端带 ?takeover=1 时,TakeoverWS(pty_session.go:175)会反复"驱逐"当前持有者(触发它注册的 eviction hook 关掉旧连接)直到抢到锁——其间 bash 进程一直活着,新客户端靠 replay 无缝续上。整个握手/驱逐顺序被精心排过(pty_ws.go:71 上方那段编号注释),保证"握手没成功就绝不驱逐别人"。
8. SQL 会话
runSQL(runtime/sql.go:42)是最轻的后端:对沙箱本地的 MySQL 跑一句 SQL。
- 懒连接
initDB(sql.go:174):首次用sync.Once连root@tcp(127.0.0.1:3306),CREATE DATABASE IF NOT EXISTS sandbox并USE它。 - 粗粒度分流
getQueryType(sql.go:165):取 SQL 第一个 token,SELECT走executeSelectSQLQuery(返回 columns+rows),其余走executeUpdateSQLQuery(返回 affected_rows)。 - 结果统一 JSON 化后经
OnExecuteResult回调,和别的后端共用 SSE 出口。
注意源码显式标注了 // lgtm[go/sql-injection](sql.go:72):这是有意的——沙箱的定位就是"跑用户提交的任意 SQL 程序",不存在"可信模板 vs 注入"之分,整个沙箱就是信任边界。
9. 巧妙之处(值得借鉴)
- 执行逻辑与传输彻底解耦:后端只认 7 个
ExecuteResultHook回调(runtime/types.go:26),SSE/WebSocket 只是把回调接到不同写出口。测试可用打印 hook,生产用 SSE hook,后端零改动(sse.go:57)。 - 信号打进程组,并防陈旧 PID:命令一律
Setpgid起(command.go:132),中断时Kill(-pid)收整组子孙进程;命令结束即清零 pid、快照校验 running(command_status.go:137、interrupt.go:43),堵住"误杀复用 pid 的无关进程组"这个真实事故点。 - bash 会话用 marker 协议做无状态持久:不常驻进程,靠包裹脚本尾部 dump
export -p/pwd/退出码再解析回注(bash_session.go:322),既拿到跨命令的状态,又不背一个长命进程的管理成本。 - PTY 重连的原子重放:环形缓冲不变式
head==total%size让"从任意偏移续读"变成一次取模(replay_buffer.go),写 replay 与转发在同锁内原子完成杜绝丢字节(pty_session.go:373)。 - SSE 头懒提交:首个事件才写
text/event-stream头(sse.go:181),于是"执行还没开始就同步失败"的情况仍能返回结构化 JSON 错误而非半截 SSE(controller/command.go:83注释)。
10. 边界与局限
- 鉴权只有一把 token:
X-EXECD-ACCESS-TOKEN精确匹配,token 空则全放行(router.go:150)。execd 假定自己跑在受信反向代理后面(WebSocket upgrader 也CheckOrigin恒真,pty_ws.go:39)。真正的多租户边界在控制面 + 容器隔离,不在 execd。 - 只监听 IPv4:
net.Listen("tcp4", ...)(main.go:97)。 - 一个 Jupyter 内核串行执行:
runJupyterCode用TryLock,同一 context 并发第二段代码直接 "session is busy"(jupyter.go:61)。 - 一个 PTY 同时一个 WS 客户端:多开要靠
?takeover=1顶掉前一个,不是多路复用。 - SQL 写死本地 MySQL:
127.0.0.1:3306root 无密码(sql.go:177),镜像里没有 MySQL 就DBInitError。 - 命令输出经临时文件中转:
/tmp/<session>.stdout等;代码专门MkdirAll兜底/tmp被删重建(command_common.go:66),但极高吞吐场景下文件 tail 有 100ms 粒度延迟(command_common.go:31)。
11. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 进程启动 | components/execd/main.go | main |
| CLI/env 配置 | components/execd/pkg/flag/parser.go | InitFlags、accessTokenEnv |
| 路由装配 + 鉴权 | components/execd/pkg/web/router.go | NewRouter、accessTokenMiddleware |
| 执行分发中枢 | components/execd/pkg/runtime/ctrl.go | Controller、Execute |
| 请求/回调模型 | components/execd/pkg/runtime/types.go | ExecuteCodeRequest、ExecuteResultHook |
| 语言常量 | components/execd/pkg/runtime/language.go | Command、Bash、BackgroundCommand |
| 前台/后台命令 | components/execd/pkg/runtime/command.go | runCommand、runBackgroundCommand、buildCredential |
| 输出 tail | components/execd/pkg/runtime/command_common.go | tailStdPipe、readFromPos |
| 命令状态/后台日志 | components/execd/pkg/runtime/command_status.go | GetCommandStatus、SeekBackgroundCommandOutput、markCommandFinished |
| 中断与信号 | components/execd/pkg/runtime/interrupt.go | Interrupt、killPid |
| 持久 bash 会话 | components/execd/pkg/runtime/bash_session.go | bashSession.run、buildWrappedScript、parseExportDump |
| Jupyter 分发 | components/execd/pkg/runtime/jupyter.go | runJupyter、runJupyterCode、searchKernel |
| 内核/上下文管理 | components/execd/pkg/runtime/context.go | CreateContext、createJupyterContext、createDefaultLanguageJupyterContext |
| Jupyter 协议客户端 | components/execd/pkg/jupyter/execute/execute.go | ExecuteCodeStream、handleExecutionStatus、finalizeExecution |
| Jupyter HTTP/WS 门面 | components/execd/pkg/jupyter/client.go | Client、ConnectToKernel |
| PTY 会话 | components/execd/pkg/runtime/pty_session.go | ptySession、StartPTY、StartPipe、writeAndFanout、TakeoverWS |
| 输出重放缓冲 | components/execd/pkg/runtime/replay_buffer.go | replayBuffer、ReadFrom |
| PTY WebSocket 处理 | components/execd/pkg/web/controller/pty_ws.go | PTYSessionWebSocket、ptyStreamPump |
| PTY REST | components/execd/pkg/web/controller/pty_controller.go | CreatePTYSession、GetPTYSessionStatus |
| SQL 会话 | components/execd/pkg/runtime/sql.go | runSQL、initDB、getQueryType |
| 代码/命令/会话 controller | components/execd/pkg/web/controller/codeinterpreting.go | RunCode、CreateSession、RunInSession |
| 命令 controller | components/execd/pkg/web/controller/command.go | RunCommand、GetBackgroundCommandOutput |
| SSE 出口 | components/execd/pkg/web/controller/sse.go | setServerEventsHandler、writeSingleEvent |
| 文件系统 | components/execd/pkg/web/controller/filesystem.go | ListDirectory、SearchFiles、ReplaceContent |
| 反向代理中间件(见 06) | components/execd/pkg/web/proxy.go | ProxyMiddleware |