sandboxd
tastyeffectco/sandboxd · 源码级 基于 control-plane/ 源码文本

临时沙箱的 控制面与内核

sandboxd 是临时沙箱开发环境的控制面——单一 Go 二进制跑在 Docker 容器内,与每个沙箱内的 runtimed 进程对话。核心信条:SQLite 是唯一真相源,Docker 状态向 SQLite 收敛,绝不反向。

# tastyeffectco/sandboxd · Go · control-plane/cmd/sandboxd/main.go

SQLite
唯一真相源
2 进程
sandboxd + runtimed
0.0.0.0:9000
HTTP API(OSS 默认)
idempotent
Wake / Reconcile / Provision
向下探索
01
two processes · the topology

两进程架构

宿主级 sandboxd 控制生命周期;沙箱内的 runtimed 监管开发服务器与编程任务。两者通过 Unix 域套接字上的 HTTP/1.1 通信。

两进程拓扑
┌─────────────────────────────────────────────────────────┐
│                    HOST (sandboxd container)            │
│                                                          │
│  ┌─────────────────────────────────────────────────┐    │
│  │  sandboxd (cmd/sandboxd/main.go)                │    │
│  │  • HTTP API server (127.0.0.1:9000)             │    │
│  │  • SQLite store (single-writer goroutine)       │    │
│  │  • Reconciler.Once (boot-time, runs once)        │    │
│  │  • Idle Reaper (30 s ticker)                     │    │
│  │  • Pressure Reaper (10 s ticker)                 │    │
│  │  • Access-log tailer                             │    │
│  │  • Wake handler                                  │    │
│  │  • runtime.Client (Unix socket → runtimed)      │    │
│  └────────────────┬────────────────────────────────┘    │
│                   │  docker start / docker stop │
│                   │  docker exec (inflight tracking)       │
│                   │  docker inspect                       │
│  ┌────────────────▼────────────────────────────────┐    │
│  │  sandbox container (sandboxd-base:1.0.0)        │    │
│  │  ┌──────────────────────────────────────────┐   │    │
│  │  │  runtimed (cmd/runtimed/main.go)         │   │    │
│  │  │  • Supervises dev server (pnpm dev) │   │    │
│  │  │  • GET /status over Unix socket          │   │    │
│  │  │  • POST /tasks (coding agent runner)     │   │    │
│  │  │  • SSE /tasks/{id}/events                │   │    │
│  │  └──────────────────────────────────────────┘   │    │
│  │ │    │
│  │  Workspace loopback mount: │    │
│  │    /home/sandbox/workspace/ ←── host: │    │
│  │    /var/lib/sandboxed/workspaces/{id}.mnt         │    │
│  └──────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

sandboxd(control-plane main.go)

宿主进程是唯一的守护进程。它独占 SQLite 数据库、管理所有 Docker 容器、暴露 HTTP API。关键职责:

  • 绑定到 0.0.0.0:9000(OSS 默认)或环境变量配置的地址。
  • 在 HTTP 监听器接受任何请求之前,运行一次启动期 Reconciler。
  • 独占 Store——单写者 goroutine,把所有写操作通过容量 64 的缓冲通道串行化,避免 SQLite "database is locked" 错误。
  • 在 reconciler 返回后,把 Idle Reaper 与 Pressure Reaper 作为后台 goroutine 启动。
  • 按需为每个沙箱构建 runtime.Client,使用宿主侧 Unix 套接字路径:<workspaces>/<id>.mnt/.runtimed/sock。
  • 支持 SIGHUP 重载鉴权配置而无需重启。

runtimed(cmd/runtimed/main.go)

runtimed 是沙箱内的监管者,作为容器主进程运行(PID 1 或其子进程)。当前 slice 1 的范围:

  • 启动并监管 Vite 开发服务器(默认 pnpm dev)。
  • 在工作区 loopback 上的 Unix 域套接字(/home/sandbox/.runtimed/sock)暴露 GET /status。
  • 通过轮询开发服务器 HTTP 端口报告预览健康度。
  • 检测入口资源编译错误(空白页检测)。
  • 管理编程任务(POST /tasks、事件流、cancel)——后续 slice。

套接字绑定在 /home/sandbox/.runtimed/ 下,位于 loopback 工作区镜像上。这意味着同一个套接字 inode 既能从容器内的 /home/sandbox/.runtimed/sock 访问,也能从宿主的 <workspaces>/<id>.mnt/.runtimed/sock 访问。没有网络端口,没有跨租户可达性。

通信协议

sandboxd 与 runtimed 通过 Unix 域套接字上的 HTTP/1.1 通信。协议类型定义在 control-plane/internal/runtime/protocol.go 和 control-plane/internal/runtime/tasks.go,被双方导入:

  • GET /status → runtime.Status —— runtimed 快照(开发服务器状态 + 活动任务)
  • POST /tasks → runtime.StartTaskRequest —— 启动一个编程任务
  • GET /tasks/{id}/events → runtime.Event 的 SSE —— 任务进度流
  • POST /tasks/{id}/cancel —— 取消任务
表 01 · runtimed 状态推导

runtimed 的 probeLoop 每 3 秒轮询开发服务器 HTTP 端口。status() 方法据此推导 runtime.PreviewState:

开发服务器状态PreviewState.Status说明
未运行"down"
运行中,HTTP ≠ 200"starting"
HTTP 200,无资源错误"ready"
HTTP 200,资源编译错误"error"空白页 —— 构建错误
双 HTTP 客户端设计:runtime.Client 维护两个 http.Client 实例——一个 5 秒超时用于 status/start/cancel,另一个无超时用于 SSE 任务事件流。这样既避免短超时杀掉事件流,又能在命令操作上防住挂死连接。
02
reconciler · docker → sqlite

启动期协调器

源码:control-plane/internal/reconcile/reconcile.go。Reconciler 在进程启动时恰好运行一次,在 HTTP 监听器接受任何请求之前。这是硬性顺序要求:半截的 reconcile 绝不能把半填充的 egress 集合发布给客户端。

协调器做什么

它把 Docker 容器状态向 SQLite 收敛。对每个状态为 running、creating 或 stopped 的沙箱行:

  1. 重挂 loopback(Phase 5 V11 修复):内核 mount namespace 不会在重启后存活。如果磁盘上的 .img 仍存在且该行不处于 error 状态,在判定容器状态前先重新配置 loopback 挂载。Loopback.Provision() 是幂等的——已存在的挂载为 no-op。
  2. 处理陈旧的 creating 行:任何超过 5 分钟的 creating 行被标记为 error(记录、标错、保留挂载留待人工审查)。
  3. inspect Docker 容器(s-<id>):
    • 容器不存在 → MarkStopped(不自动重建)。
    • 容器存在但未运行 → MarkStopped。
    • 容器运行中 → 重新推导 cgroup 路径并重写 memory.high(幂等)。把行更新为 running。
  4. 重建 egress 来源(Phase 6):从 Docker inspect 推导实时 bridge IP(真相源——Docker 可在守护进程重启时重新分配 IP)。拆除任何陈旧条目并以当前 IP 重新添加。
  5. 记录缺少 external_user_id 的遗留行(Phase 8):运行中的沙箱本应有一个。记录警告,建议 sandboxd backfill-legacy。绝不自动销毁。

孤儿容器与挂载扫描

处理完所有已知行后,reconciler 扫描孤儿:

  • 孤儿容器:任何名称前缀为 s- 但无匹配 SQLite 行的 Docker 容器。这些仅被记录——绝不销毁。Reconciler 不会自行收养或剔除。
  • 孤儿挂载:任何 workspaces/ 下以 .mnt 结尾、其基 ID 无 SQLite 行的挂载。同样仅记录。
  • 工作区属主孤儿(Phase 8):任何 workspace_owner 行,其背后的 .img 已不在磁盘上。记录;处置由运维决定。一个 6 小时 ticker 在启动后重新检查。
表 02 · reconcile.Result 字段

reconcile.Once() 返回 reconcile.Result 结构,供 main.go 用于日志与 Prometheus 指标:

字段含义
DurationReconciler 总墙钟时间。
Rows处理的沙箱行总数。
Reapplied成功的 memory.high 重写次数。
Stopped标记为 stopped 的行(容器消失或未运行)。
Errored标记为 error 的行(egress 重建失败)。
EgressAdded重新安装的 egress 来源条目数。
Orphans孤儿种类 → 计数的映射(container、mount、workspace_owner)。

memory.high 重应用

当 SetMemoryHigh 启用(可移植 OSS 构建默认关闭)时,reconciler 从容器当前 PID 重新推导 cgroup 路径并重写 memory.high。这处理了 Phase 1 遗留问题:"memory.high 不会被 docker start 自动恢复"。写入是幂等的;写失败被记录但不再把沙箱标错——一个正在运行、正在服务的容器,绝不能仅因软限流没能重应用就被拔出服务。

03
orphan scan · log, never delete

孤儿扫描细节

孤儿扫描刻意非破坏。CLAUDE.md 不变量:"SQLite 是真相源。Reconciler 把 Docker 向 SQLite 收敛,绝不反向。" 孤儿容器与挂载为运维知情而被记录,系统从不自动删除。

孤儿容器扫描

Reconciler 调用 Docker.ListByNamePrefix(ctx, "s-") 获取所有带 s- 前缀的容器名。它从处理过的 SQLite 行构建 knownIDs 映射。任何 ID 不在 knownIDs 中的容器即为孤儿:

reconcile.go · orphan container scan
for _, name := range names {
    id := strings.TrimPrefix(name, "s-")
    if knownIDs[id] { continue }
    log.Warn("reconcile: orphan container (no matching sandbox row)",
        "container", name, "sandbox_id", id)
    res.Orphans["container"]++
}

孤儿挂载扫描

Loopback.ListMounts() 返回 <workspacesRoot>/ 下所有以 .mnt 结尾的挂载路径。Reconciler 提取基 ID(.mnt 之前的部分)并与 knownIDs 比对:

reconcile.go · orphan mount scan
for _, mnt := range mounts {
    base := mnt[strings.LastIndexByte(mnt, '/')+1:]
    id := strings.TrimSuffix(base, ".mnt")
    if knownIDs[id] { continue }
    log.Warn("reconcile: orphan mount (no matching sandbox row)",
        "mount", mnt, "sandbox_id", id)
    res.Orphans["mount"]++
}

Phase 7 崩溃残骸清扫

在孤儿挂载扫描之前,reconciler 调用 Snapshot.SweepStale(),移除两类崩溃残骸:

  • *.img.zst.tmp —— 被中断的快照操作留下的半写压缩快照。
  • *.img.bak-* —— 被中断的恢复操作留下的备份文件。

两者都被记录,而非静默删除。计数通过 stale_tmp 和 stale_bak 指标字段报告。

工作区属主孤儿检查(6 小时 ticker)

启动后,main.go 启动一个带 6 小时 ticker 的 goroutine,调用 CheckWorkspaceOwnerOrphans()。它遍历所有 workspace_owner 行,记录任何背后 .img 已不在磁盘上的行。该函数从不删除——处置由运维决定。检查运行时机:

  • 启动时由 reconcile.Once() 运行一次。
  • 之后每 6 小时由后台 goroutine 运行。
04
idle reaper · stop on inactivity

空闲回收器

源码:control-plane/internal/reaper/idle.go。Idle Reaper 是一个长生命周期 goroutine,在可配置的不活动周期后停止沙箱。属于 roadmap Phase 5。

表 03 · 配置(env 可调)
环境变量默认值含义
SANDBOXD_IDLE_THRESHOLD_SECONDS2100(35 分钟)不活动截止——无任何活动信号超过此时长即视为空闲。
SANDBOXD_IDLE_REAP_INTERVAL_SECONDS30回收器多久 tick 一次检查候选。
SANDBOXD_WAKE_GRACE_SECONDS60唤醒后 / SSE 断连后的宽限窗口,之后空闲时钟才恢复。

跳过规则(内存中,逐候选)

停止任何沙箱前,回收器按顺序应用这些检查:

  1. 在飞 exec:若 activity.InflightExec.Active(sb.ID) 返回 true,跳过。exec 是用户活动的直接信号。
  2. 显式 keepalive:若 sb.KeepaliveUntil 已设置且 keepalive_until > now.Unix(),跳过。上游可在长操作期间延长沙箱寿命。
  3. 活动编程任务:若 Store.SandboxHasRunningTask(sb.ID) 返回 true,跳过。任务中途回收会打断 agent 运行。
  4. 开放连接信号:由 access-log tailer / poller 在 WakeGrace 窗口内 bump last_active_at 处理。DB 已通过 ListIdleCandidates 截止比较反映之。
  5. 时钟漂移二次确认:若 !sb.LastActiveAt.IsZero() 且 sb.LastActiveAt.After(cutoff),跳过。时钟漂移的安全网。

tick 循环

idle.go · tick()
func (i *Idle) tick(ctx context.Context) error {
  metrics.IdleReaperRuns.Inc()
  now := time.Now().UTC()
  cutoff := now.Add(-i.Cfg.Threshold)

  candidates, err := i.Store.ListIdleCandidates(ctx, cutoff)
  // SQL: ... WHERE status='running' AND last_active_at < cutoff
  //      ORDER BY last_active_at ASC
  for _, sb := range candidates {
    // apply skip rules...
    // docker stop s-,10s timeout
    // egress.Remove + Store.ClearContainerIP
    // Store.MarkStoppedAt
    metrics.IdleReaperStops.WithLabelValues("idle").Inc()
  }
}

阻止空闲的活动信号

last_active_at 由三个来源 bump:

  • Access-log tailer —— 监视 traefik-access.log 中匹配预览域名的请求,为匹配的沙箱 bump last_active_at。
  • 开放连接 poller —— 若设置了 SANDBOXD_POLLER_METRIC_RE,轮询指标端点并为有活动连接的沙箱 bump last_active_at。env 缺失时回退到仅 tailer。
  • Wake handler —— 每次成功唤醒都 bump。
  • 在飞 exec 跟踪 —— 活动 docker exec 调用期间 bump。

停止时的 egress 清理

当 idle reaper 停止一个容器时,它还会:

  • 调用 Egress.Remove(ctx, sb.ID, sb.ContainerIP.String) 把 bridge IP 从 nftables sandbox_sources_v4 集合中移除。
  • 调用 Store.ClearContainerIP(ctx, sb.ID) 把行上的 container_ip 列置 NULL。这是 roadmap §1 DoD 的"每个 stopped 沙箱该列必为 NULL"规则。
05
pressure reaper · defend host memory

内存压力回收器

源码:control-plane/internal/reaper/pressure.go。Pressure Reaper 在三个内存带防御宿主内存,在每个阈值停止最老的空闲运行沙箱。属于 Phase 5,运行在 10 秒 ticker 上。

表 04 · 配置(env 可调)
环境变量默认值含义
SANDBOXD_PRESSURE_INTERVAL_SECONDS10tick 间隔。
SANDBOXD_MEM_HEADROOM_PCT15"健康"下限——在此之上不动作。
SANDBOXD_MEM_REFUSE_WAKES_PCT10低于此:停止最老空闲 AND 拒绝新唤醒请求。
SANDBOXD_MEM_EMERGENCY_PCT5紧急——无视活动停止 RSS 最重的沙箱。

内存带

memory bands
MemAvailable%
│
│  ≥15%  ┌────────────────────────────────────── HEALTHY (no action)
│         │
│ 10–15%  │ ┌─────────────────────────────────── STOP oldest idle-running
│         │ │  (10–15% band — advisory, doesn't refuse wakes)
│  5–10%  │ │ ┌─────────────────────────────── STOP oldest idle-running
│         │ │ │  WARN: wakes refused
│         │ │ │
│  <5%   │ │ │ ┌─────────────────────────── STOP heaviest RSS
│         │ │ │ │  CRITICAL: emergency stop, even if active
└─────────┴─┴─┴───────────────────────────────────────▶0%
  20 15    10 5     0

唤醒拒绝的滞回

Refused 原子布尔与唤醒准入代码共享。它用滞回避免边界抖动:

  • 当 avail < RefuseWakesPct(落入 5–10% 或 <5% 带的下降沿)时置 true。
  • 当 avail ≥ RefuseWakesPct + 2(升过 12% 的上升沿,略高于 HeadroomPct 的 10% 触发点)时清 false。

stopOldestIdle

从 ListIdleCandidates(ctx, now)(cutoff = now 意为"任何当前未被标记为活动的行")挑选 last_active_at 最老的沙箱。应用与 idle reaper 相同的跳过规则。每 tick 停一个——下一 tick 重读 MemAvailable 再决定。若无候选,本 tick 不动作。

stopHeaviestRSS(紧急 <5%)

当 MemAvailable 跌破 5% 时,回收器调用 stopHeaviestRSS():

  1. 列出所有 running 沙箱。
  2. 对每个有有效 cgroup_path 的沙箱,通过 ReadCgroupMemoryCurrent() 读取 /sys/fs/cgroup{path}/memory.current。
  3. 挑选 memory.current 最高的(RSS 代理)。
  4. 对它执行 docker stop,无视活动。
  5. 以完整行身份记录一条 CRITICAL 事件。
OB9 遗留:在 memory.high=4G 与 memory.swap.max=0 下,内核可能在协作型分配器跨越 memory.max=10G 之前就把它们卡在 D-state。Pressure reaper 不依赖 memory.events.oom_kill——它按 cgroup.memory.current(RSS,非 OOM 事件)排序。一个 D-state 卡死的分配器仅凭这些信号即成为击杀候选。

ReadMemInfo 与 cgroup 内存读取

ReadMemInfo()(在 control-plane/internal/reaper/meminfo.go)解析 /proc/meminfo 计算 MemAvailable 及其百分比。ReadCgroupMemoryCurrent()(在 control-plane/internal/reaper/cgmem.go)读取给定 cgroup 相对路径的 /sys/fs/cgroup{path}/memory.current。当路径不存在(cgroup 已拆除的 stopped 沙箱)时返回 (0, nil)。

06
taskresult · outlives the sandbox

TaskResult 生命周期

编程任务子系统建立在每沙箱一行的 task 表(定义于 control-plane/internal/store/tasks.go)和来自 runtimed 的 SSE 事件流之上。核心设计目标:任务的权威结果比沙箱活得久。

表 05 · 任务状态
状态含义
running任务在 runtimed 中活动。
succeeded任务以干净的 done 事件完成。
failed任务以错误完成。
cancelled上游通过 POST /tasks/{id}/cancel 取消任务。

生命周期流程

步骤 1 · submit

POST /v1/sandboxes/{id}/tasks

上游提交任务。API handler 检查沙箱为 running(若 stopped,先委派到唤醒路径——B1 "wake-on-task-submit")。生成 ULID 作为 taskID,调用 runtimeClientFor(id).StartTask(),经 Unix 套接字向 runtimed 发 POST /tasks。

步骤 2 · ack

runtimed 确认

HTTP 202 Accepted 或 409 Conflict。若已有任务在跑,runtimed 返回 ErrTaskInProgress,API 返回 409 "task_in_progress"。"一次一个任务"由 runtimed 的 taskMu 互斥锁强制。

步骤 3 · persist

持久化任务行

Store.CreateTask()。sandboxd 把一个 running 状态的 task 行写入 SQLite。行的 result_json 初始为 NULL。这确保任务结果在沙箱停止与销毁后仍存活。

步骤 4 · watch

启动结果监视者

go s.watchTask(id, taskID)。后台 goroutine 经 runtimeClientFor(id).TaskEvents(taskID, 0) 打开 SSE 流,消费事件并经 Store.FinishTask() 把终态 done 事件的 TaskResult 持久化到行。done 到达或 context 取消时关闭。

步骤 5 · stream

事件流

上游经 GET /v1/sandboxes/{id}/tasks/{taskId}/events 消费。sandboxd 代理来自 runtimed 的 SSE 流。客户端用 Last-Event-ID 头做重连续传。sandboxd 把 runtimed 的换行分隔 JSON 事件翻译成 SSE 格式(id: N\nevent: type\ndata: JSON\n\n)。

步骤 6 · done

终态 done 事件

携带 TaskResult。done 是唯一的终态事件,包含完整的 TaskResult:status、failure_reason、files_changed、agent_message_final、build_ok、preview_status_after、checkpoint_id、duration_ms、tokens 及时间戳。

步骤 7 · finish

持久化结果

Store.FinishTask(ctx, taskID, status, resultJSON) 更新行:status → 终态,result_json → JSON 编码的 TaskResult,finished_at → time.Now().Unix()。

步骤 8 · read

读权威结果

GET /v1/sandboxes/{id}/tasks/{taskId} 从 sandboxd 的 SQLite 读取——无论沙箱在跑、已停还是已销毁都可用。若 result_json 为 NULL(任务仍在跑),只返回 running 状态。

表 06 · TaskResult 字段
字段类型描述
idstring任务 ULID。
statusTaskStatussucceeded | failed | cancelled
failure_reasonstring失败的人类可读原因(agent 级)。
error_messagestring来自编程 agent 的结构化错误。
files_changed[]string任务期间修改的文件列表。
agent_message_finalstring最终 agent 摘要消息。
build_okboolpnpm build 是否成功。
build_error_messagestringpnpm build 的输出。
preview_status_afterPreviewStatus任务后开发服务器状态。
preview_error_messagestring构建干净但应用渲染失败时的实时开发服务器错误。
checkpoint_idstring任务结束时拍的快照 ID(若有)。
duration_msint64任务墙钟时间(毫秒)。
tokensTokenUsage输入/输出/推理/缓存 token 计数与成本。
created_at / started_at / finished_attime.Time任务时间戳。
表 07 · 事件类型
类型来源可靠性
statusruntimed可靠
messageprovider-derived尽力而为
toolprovider-derived尽力而为
buildruntimed可靠
doneruntimed可靠——终态事件,携带 TaskResult。

取消流程

POST /v1/sandboxes/{id}/tasks/{taskId}/cancel 向 runtimed 发 POST /tasks/{taskId}/cancel。runtimed 的 taskMu 确保一次只跑一个任务。取消是幂等的——对已完成的任务调用 cancel 返回 200 OK。

启动期任务恢复

sandboxd main.go 和 runtimed main.go 都在启动时调用 recoverInterruptedTasks()。被中断的任务被置为 failed(绝不恢复运行)。sandboxd 调用 server.ReconcileTasks(ctx),后者调用 Store.ListRunningTasks(),在 idle reaper 启动前把任何陈旧的 running 任务标记为 failed。

07
store · single source of truth

SQLite 存储

源码:control-plane/internal/store/store.go。Store 是 SQLite 支撑的沙箱生命周期真相源。所有沙箱、任务、工作区属主都存于此。

并发模型

单个写者 goroutine 从一个容量 64 的写操作缓冲通道读取。读者直接用 db.QueryRow / db.Query。这避免了 "database is locked",又不把互斥锁散落到代码各处。结构:

store.go · Store struct
type Store struct {
  db       *sql.DB
  writes   chan writeOp // buffered channel, capacity 64
  doneCh   chan struct{}
  closeCh  chan struct{}
}
表 08 · 沙箱生命周期状态
状态含义
creating行存在,容器正在创建。若超过 5 分钟,reconciler 标错。
running容器在运行并服务中。
stopped容器已停止(由 reaper、wake 或运维)。container_ip 必为 NULL。
error不可恢复错误。运维须手动调查。

关键读查询

  • Get(ctx, id) —— 按 ID 取单个沙箱。
  • List(ctx) —— 所有沙箱,按 created_at DESC 排序。
  • ListByStatuses(ctx, statuses...) —— reconciler 使用。
  • ListIdleCandidates(ctx, cutoff) —— WHERE status='running' AND last_active_at < cutoff ORDER BY last_active_at ASC。这里不加载端口(热路径优化)。
  • ListFiltered(ctx, externalUserID, externalProjectID) —— Phase 8 外部身份过滤。
  • ListAllWorkspaceOwners(ctx) —— 孤儿检查使用。
  • GetTask / CreateTask / FinishTask / ListRunningTasks —— task 表。
  • SandboxHasRunningTask(ctx, sandboxID) —— idle reaper 跳过规则使用。

关键写操作

所有写都经单写者 goroutine 通过 writes 通道漏斗化。每个写是一个 writeOp 闭包,在写者循环内对单个 DB 连接执行。这串行化了所有变更,同时允许并发读。

活动列(Phase 5)

last_active_at(unix 秒,0 = 从未观测)由 tailer/poller bump。stopped_at(unix 秒,运行中为 NULL)记录 reaper 何时停止沙箱。keepalive_until(unix 秒,NULL = 无覆盖)是来自上游的 idle-reaper 跳过信号。

工作区属主绑定(Phase 8)

workspace_owner 是一个独立表,在 DELETE /sandbox/{id} 后仍存活。它保存 sandbox_id → external_user_id 绑定,用于按用户清除与 Phase 8 孤儿检查。

08
wake · bring a sandbox back

唤醒处理器

源码:control-plane/internal/wake/handler.go。Wake handler 把一个 stopped 沙箱带回生命。它有两个入口:

  • Traefik catch-all:Host 头匹配 s-<id>-<port>.preview.<domain> 的 HTTP 请求。返回一个 HTML meta-refresh,让浏览器持续重试直到沙箱就绪。
  • 程序化 JSON:loopback API 上的 POST /wake/{id}。返回 JSON。被任务提交使用(wake-on-task-submit)。

唤醒流程(逐步)

  1. 每 id 互斥去重:若同一 ID 的唤醒已在飞行中,第二个调用者等待并复用第一个调用者的结果。这阻止双启动竞态。
  2. 每 id 锁(Phase 7):整个唤醒期间获取共享的 idlock.Registry。这排斥同一沙箱的并发 snapshot/restore/destroy。
  3. 行查找 + 状态守卫:running → 成功(no-op)。error → 错误响应。creating → 拒绝(半建行)。stopped → 继续。
  4. 私有沙箱闸门(Phase 8):对 visibility='private' 沙箱的 HTML 路径唤醒,先验证预览 token cookie。服务/运维鉴权的调用者绕过此闸门。
  5. 准入检查:Admit() 读取宿主内存。若拒绝,返回 503 带 Retry-After: 30。在 5–10% 或 <5% 带唤醒被拒。
  6. docker start:15 秒超时。幂等——对已运行容器调用安全(虽然上面的状态守卫通常已截获该情况)。
  7. inspect + memory.high 重应用:从容器 PID 重新推导 cgroup 路径并重写 memory.high。失败被记录但不使唤醒失败——--memory 上限仍生效。
  8. egress 策略(Phase 6):把(可能是新的)bridge IP 注册到 nftables sandbox_sources_v4 集合。先拆除任何陈旧条目。若 nftables 失败,宁可中止行更新也不在无 egress 策略下运行。
  9. TCP-ready 探测:对 catch-all 路径(已知端口),轮询开发服务器直到 HTTP 200 或超时。超时是信息性的,不是硬失败——meta-refresh 继续重试。
  10. 行更新:Store.MarkRunningWoke() 把行更新为 running,设置 container_id、cgroup_path,并 bump last_active_at。

Egress 注册

Egress(nftables)在可移植 OSS 构建中禁用(egressMgr == nil)。每个消费者(reconciler、reapers、wake、API)把 nil egress manager 当作"无 egress 策略"——正是默认放行行为。这就是把那一切关掉的单一开关。

09
runtimed · in-sandbox supervisor

runtimed 详解

runtimed 是沙箱内的监管者,一个作为容器主进程启动的单一 Go 二进制。当前 slice 的职责:

  • 监管开发服务器进程(默认 pnpm dev)。
  • 每 3 秒探测开发服务器 HTTP 端口。
  • 检测入口资源编译错误(空白页检测)。
  • 在 Unix 域套接字上暴露 GET /status。
  • 启动时恢复被中断的任务。

app struct

runtimed/main.go · app struct
type app struct {
  dev         *devServer     // supervisored dev server
  previewPort int
  appDir      string // /home/sandbox/workspace/app
  runtimeDir  string         // /home/sandbox/.runtimed
  log         *slog.Logger
  bootedAt    time.Time

  mu           sync.Mutex
  lastCode     int           // last HTTP status from probe
  lastAssetErr string // entry-asset compile error
  lastChecked  time.Time

  taskMu sync.Mutex         // guards task; serializes start (one task at a time)
  task   *task             // current / last task, or nil
}

devServer 监管

devServer.supervise() 启动开发服务器进程,监视退出,并以指数退避(封顶)重启。每次重启使 PreviewState 中的 restarts 计数器递增。

启动保证

  • runtimeDir(/home/sandbox/.runtimed)以 0o755 创建。
  • appDir(/home/sandbox/workspace/app)以 0o755 创建。这自愈了在此修复前配置的沙箱(它们可能只有 ~/workspace)。
  • recoverInterruptedTasks() 在接受新工作前调用。来自上次停止/崩溃的任何陈旧任务状态被清理。

信号处理

runtimed 用 signal.NotifyContext 处理 SIGTERM/SIGINT。收到信号时,开发服务器在 runtimed 退出前被干净停止。该 context 也用于取消探测循环与开发服务器监管。

10
startup · carefully ordered

启动序列

源码:sandboxd main.go。启动序列被精心排序,以确保不变量从接受第一个请求起就成立。

步骤 1

Env + dirs

解析环境变量,创建 stateDir。

步骤 2

打开 SQLite

store.Open() 应用迁移,启动单写者 goroutine。

步骤 3

回填 last_active_at

为遗留 running 行回填——迁移默认值 0 会让每个现有行成为空闲候选。

步骤 4

构建协作者

docker 客户端、loopback manager、snapshot manager、idlock registry、egress manager(OSS 构建中为 nil)。

步骤 5

Reconciler.Once

在监听器打开前运行至完成。所有容器状态收敛到 SQLite。孤儿挂载/容器被记录。指标被发布。

步骤 6

压力回收器预检

若 MemAvailable 已低于 headroomPct,在打开监听器前同步运行一次 pressure.Tick()。

步骤 7

任务协调

server.ReconcileTasks(ctx) 在 idle reaper 启动前把任何陈旧 running 任务标记为 failed。

步骤 8

启动后台 goroutine

idle reaper、pressure reaper、access-log tailer、connection poller、workspace_owner 孤儿 ticker。

步骤 9

HTTP 监听器

httpSrv.ListenAndServe()。Reconciler 已完成,故第一个请求看到完全收敛的状态。

步骤 10

信号处理

SIGHUP 重载鉴权配置;SIGINT/SIGTERM 触发优雅关停(排空 HTTP 服务器、取消后台 context、关闭 store)。

优雅关停

  1. 信号处理器调用 gcancel() —— 停止所有后台 goroutine。
  2. httpSrv.Shutdown(shutCtx),10 秒超时。
  3. store.Close() —— 排空写通道,关闭 DB。
11
architecture · the full map

完整系统架构

把前面各节的组件放回它们在系统中的位置——宿主侧 sandboxd 的全部子系统,与沙箱内 runtimed 经 Unix 套接字相连。

full system architecture
                                   ┌──────────────────────────────────────┐
                                   │         sandboxd (host container)      │
                                   │                                      │
                                   │  HTTP API (127.0.0.1:9000) │
                                   │    ├── /v1/sandboxes                  │
                                   │    ├── /v1/sandboxes/{id}/tasks       │
                                   │    ├── /wake/{id}                     │
                                   │    └── catch-all (preview domain)     │
                                   │                                      │
                                   │  ┌──────────── Store ───────────────┐  │
                                   │  │  Single-writer goroutine │  │
                                   │  │  SQLite (WAL mode, FK enabled)    │  │
                                   │  └─────────────────────────────────│  │
                                   │                                      │
                                   │  ┌──────── Reconciler.Once ────────┐  │
                                   │  │  Boot-time, runs once          │  │
                                   │  │  Converges Docker → SQLite     │  │
                                   │  │  Orphan scan (logged, not del)  │  │
                                   │  └────────────────────────────────│  │
                                   │                                      │
                                   │  ┌──────────── Idle Reaper ───────┐  │
                                   │  │  30 s ticker                  │  │
                                   │  │  last_active_at < threshold │  │
                                   │  │  Skip: inflight, keepalive,    │  │
                                   │  │         running task          │  │
                                   │  └────────────────────────────────│  │
                                   │                                      │
                                   │  ┌──────── Pressure Reaper ───────┐  │
                                   │  │  10 s ticker                  │  │
                                   │  │  Bands: ≥15% / 10-15% /       │  │
                                   │  │         5-10% / <5%           │  │
                                   │  │  Emergency: heaviest RSS │  │
                                   │  └────────────────────────────────│  │
                                   │                                      │
                                   │  ┌──────── Wake Handler ──────────┐  │
                                   │  │  docker start + inspect │  │
                                   │  │  egress.Add (Phase 6)          │  │
                                   │  │  memory.high re-apply          │  │
                                   │  │  TCP-ready probe              │  │
                                   │  └────────────────────────────────│  │
                                   │                                      │
                                   │  ┌──── Activity ─────────────────┐  │
                                   │  │  Access-log tailer │  │
                                   │  │  Connection poller (optional) │  │
                                   │  │  Inflight exec tracker │  │
                                   │  └────────────────────────────────│  │
                                   └──────────────────────────────────────┘
                                                      │
                                         docker start/stop/exec/inspect
                                                      │
                                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                    sandbox container (sandboxd-base) │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │  runtimed (cmd/runtimed/main.go)                                     │    │
│  │  ├── Dev server supervisor (pnpm dev)                                 │    │
│  │  ├── Health probe loop (3 s, HTTP port3000)                         │    │
│  │  ├── GET /status  → PreviewState                                    │    │
│  │  ├── POST /tasks  → Start coding task                                │    │
│  │  ├── GET  /tasks/{id}/events → SSE event stream                      │    │
│  │  └── POST /tasks/{id}/cancel                                        │    │
│  └────────────────────────────┬────────────────────────────────────────┘    │
│ │  Unix domain socket                         │
│                               │ /home/sandbox/.runtimed/sock              │
│                               ▼ │
│                   ┌──────────────────────────┐                             │
│                    │  Workspace loopback      │                             │
│                    │  /home/sandbox/workspace│                             │
│                    │   ↕ mount │                             │
│                    │  <workspaces>/{id}.mnt   │                             │
│                    └──────────────────────────┘                             │
│                                                                             │
│  Workspace image (.img):                                                   │
│    /var/lib/sandboxed/workspaces/{id}.img │
└─────────────────────────────────────────────────────────────────────────────┘
12
design · the non-negotiables

关键设计决策

把贯穿全文的原则收束成七条不可妥协的设计约束——它们共同解释了 sandboxd 为何这样收敛、这样回收、这样持久化。

source of truth

SQLite 是真相源

每次变更(create、wake、stop、error)都经 sandboxd 与 SQLite。Docker 是执行引擎,不是真相源。Reconciler 的职责是检测漂移(有容器无行,或行的容器消失)并校正行状态。它从不删除自己没创建的 Docker 对象。

single-writer

单写者 SQLite

SQLite 在文件层只处理一个连接。单写者 goroutine 通过缓冲通道串行化所有写,避免 "database is locked" 而无需把互斥锁散落到代码库。读直接走读连接池。

idempotent

处处幂等

docker start 幂等。Loopback.Provision() 幂等。Egress Add/Remove 优雅处理陈旧条目。Reconciler 每次启动重写 memory.high。同一沙箱的并发唤醒由内存互斥去重。

log, never delete

孤儿扫描不自动删

Reconciler 记录孤儿容器、孤儿挂载、workspace_owner 孤儿,而非删除它们。这保护了开发者的调试工作流,使系统在与手动 Docker 使用并存的开发环境中更安全。

skip rules

回收器跳过规则防中途打断

idle reaper 与 pressure reaper 在停止容器前都检查 Store.SandboxHasRunningTask()。活动 tailer 与 poller bump last_active_at,使已连接的浏览器不会在会话中途被回收。显式 keepalive 可在长操作期间延长沙箱寿命。

result outlives

任务结果比沙箱活得久

task 行在 sandboxd 的 SQLite 中,而非 runtimed 里。结果由结果监视者 goroutine 在 SSE 流关闭前写入。上游即使在沙箱已停止销毁后,仍可经 GET /tasks/{id} 读权威结果。

Phase-gated 特性 nil-safe:Egress(nftables)、snapshot 子系统、nginx 热重载器在可移植 OSS 构建中全部禁用。每个消费者在调用方法前检查 if egressMgr != nil。系统在不接入任何这些组件时仍正确工作(带默认放行行为)。
sandboxd · 源码即文档
tastyeffectco/sandboxd · Go · 单一二进制 · 双进程
基于 control-plane/ 源码文本撰写 · 含实际函数名与类型定义
github.com/tastyeffectco/sandboxd ↗