From 33a3608a39ac59218a412901a8ed7f7d8e787222 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:05:28 +0800 Subject: [PATCH 01/70] silkd: a client hang-up reaps an attached language server The pump only watched the server's stdout, so a silent server that ignored stdin EOF pinned its memory after the client left. A request frame during the stream ends it the same way, as a pty does. --- silkd/src/lsp.rs | 42 ++++++++++++++++++++++++++++++++---------- silkd/tests/lsp_e2e.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 865835ca6..0f25d52c7 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -11,9 +11,9 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; -use crate::proto::{self, ErrorKind, Request, Response}; +use crate::proto::{self, BULK_CHUNK, ErrorKind, Request, Response}; use crate::sysutil; const MANIFEST_DIR: &str = "/etc/silkd/lsp.d"; @@ -124,8 +124,9 @@ impl Broker { return Err(e); } - let feed = tokio::spawn(feed_stdin(client, stdin)); - let res = pump_stdout(stdout, w).await; + let (gone_tx, gone_rx) = oneshot::channel(); + let feed = tokio::spawn(feed_stdin(client, stdin, gone_tx)); + let res = pump_stdout(stdout, w, gone_rx).await; // aborting mid-write is safe: the child is killed next. feed.abort(); self.reap(server_id).await; @@ -183,22 +184,43 @@ async fn kill(mut server: Server) { let _ = server.child.wait().await; } -async fn feed_stdin(mut client: mpsc::Receiver, mut stdin: ChildStdin) { +async fn feed_stdin( + mut client: mpsc::Receiver, + stdin: ChildStdin, + gone: oneshot::Sender<()>, +) { + let mut stdin = Some(stdin); while let Some(req) = client.recv().await { // a stray frame closes rather than errors: this task has no writer. - let Request::Data { data } = req else { return }; - if stdin.write_all(&data).await.is_err() || stdin.flush().await.is_err() { - return; + let fed = match (req, stdin.as_mut()) { + (Request::Data { data }, Some(sink)) => { + sink.write_all(&data).await.is_ok() && sink.flush().await.is_ok() + } + _ => false, + }; + if !fed { + stdin = None; } } + let _ = gone.send(()); } async fn pump_stdout( mut stdout: ChildStdout, w: &mut W, + mut gone: oneshot::Receiver<()>, ) -> std::io::Result<()> { - if let Err(e) = proto::stream_data_frames(&mut stdout, w).await? { - return proto::err_frame(w, &e, "read lsp stdout").await; + let mut buf = vec![0u8; BULK_CHUNK]; + let mut frame = Vec::new(); + loop { + tokio::select! { + r = stdout.read(&mut buf) => match r { + Ok(0) => break, + Ok(n) => proto::write_chunk_frame(w, &mut frame, "data", &buf[..n]).await?, + Err(e) => return proto::err_frame(w, &e, "read lsp stdout").await, + }, + _ = &mut gone => break, + } } proto::write_frame(w, &Response::Done).await } diff --git a/silkd/tests/lsp_e2e.rs b/silkd/tests/lsp_e2e.rs index 904e4c465..7ed05a097 100644 --- a/silkd/tests/lsp_e2e.rs +++ b/silkd/tests/lsp_e2e.rs @@ -169,3 +169,42 @@ async fn lsp_data_end_half_closes_stdin() { cw.shutdown().await.unwrap(); handle.await.unwrap().unwrap(); } + +#[tokio::test] +async fn lsp_client_hang_up_reaps_a_silent_server() { + let _env_lock = ENV_LOCK.lock().await; + for half_closed_first in [false, true] { + let (_env, state, server_id) = + started_server("silenttest", "#!/bin/sh\nexec sleep 60\n").await; + + let (mut cw, mut out, handle) = connect(&state); + send(&mut cw, json!({"op":"lsp_request","server_id":server_id})).await; + assert_eq!(type_of(&next_frame(&mut out).await), "ready"); + if half_closed_first { + send(&mut cw, json!({"op":"data_end"})).await; + } + drop(cw); + drop(out); + + let _ = timeout(DEADLINE, handle) + .await + .expect("a client hang-up must end the stream") + .unwrap(); + assert_stopped(&state, &server_id).await; + } +} + +#[tokio::test] +async fn lsp_request_during_a_stream_ends_it_and_runs_after_it() { + let _env_lock = ENV_LOCK.lock().await; + let (_env, state, server_id) = + started_server("pipelinetest", "#!/bin/sh\nexec sleep 60\n").await; + + let (mut cw, mut out, _) = connect(&state); + send(&mut cw, json!({"op":"lsp_request","server_id":server_id})).await; + assert_eq!(type_of(&next_frame(&mut out).await), "ready"); + send(&mut cw, json!({"op":"info"})).await; + assert_eq!(type_of(&next_frame(&mut out).await), "done"); + assert_eq!(type_of(&next_frame(&mut out).await), "info"); + assert_stopped(&state, &server_id).await; +} From 790b8d93c8855823b6f4e17aa19fc49bedcb2056 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:13 +0800 Subject: [PATCH 02/70] silkd: git_branch list names an unborn branch rev-parse --abbrev-ref fails on an unborn HEAD and printed HEAD, while git_status on the same repo reports the branch. A detached HEAD still reads HEAD. --- silkd/src/git.rs | 7 +++++-- silkd/tests/git_e2e.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/silkd/src/git.rs b/silkd/src/git.rs index cc4c43eab..7a8c753d6 100644 --- a/silkd/src/git.rs +++ b/silkd/src/git.rs @@ -137,8 +137,11 @@ async fn list_branches(w: &mut W, path: &str) -> std::io: .lines() .map(str::to_string) .collect(); - let cur = git(path, None, &["rev-parse", "--abbrev-ref", "HEAD"]).await?; - let current = String::from_utf8_lossy(&cur.stdout).trim().to_string(); + let cur = git(path, None, &["symbolic-ref", "--short", "-q", "HEAD"]).await?; + let current = match String::from_utf8_lossy(&cur.stdout).trim() { + "" => "HEAD".to_string(), + name => name.to_string(), + }; proto::write_frame(w, &Response::GitBranches { current, branches }).await } diff --git a/silkd/tests/git_e2e.rs b/silkd/tests/git_e2e.rs index 364f54b80..b3da7af4b 100644 --- a/silkd/tests/git_e2e.rs +++ b/silkd/tests/git_e2e.rs @@ -108,6 +108,24 @@ async fn branch_create_list_checkout() { assert_eq!(type_of(co.last().unwrap()), "done"); } +#[tokio::test] +async fn branch_list_names_an_unborn_branch_and_a_detached_head() { + let repo = init_repo(); + let p = repo.path().to_str().unwrap(); + + let unborn = request(json!({"op":"git_branch","path":p,"action":"list"})).await; + assert_eq!(type_of(&unborn[0]), "git_branches", "{unborn:?}"); + assert_eq!(unborn[0]["current"], "main", "{unborn:?}"); + + std::fs::write(repo.path().join("f"), "x").unwrap(); + git(repo.path(), &["add", "f"]); + git(repo.path(), &["commit", "-qm", "c"]); + git(repo.path(), &["checkout", "-q", "--detach"]); + + let detached = request(json!({"op":"git_branch","path":p,"action":"list"})).await; + assert_eq!(detached[0]["current"], "HEAD", "{detached:?}"); +} + #[tokio::test] async fn commit_failure_surfaces_git_error() { let repo = init_repo(); From 4d96d048ceda8bef7dc8b4aa2abd811a446b041d Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:13 +0800 Subject: [PATCH 03/70] review: silkd docs: attach never ends in done, a dropped port_forward can linger --- docs/silkd.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/silkd.md b/docs/silkd.md index 2c9016819..65ad6466c 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -49,7 +49,7 @@ suites — a frame only one side can parse fails CI. | group | ops | response flow | |---|---|---| | exec | `exec {argv, cwd?, env?, user?, detach?, session?}` | `started{pid}` → `stdout/stderr{data}`… → `exit{code}`; the client may stream `stdin{data}` / `stdin_close`. `detach` returns after `started`; the process keeps a bounded output ring for later `logs`/`attach` and leaves the table 5 minutes after it exits, after which its pid answers `not_found`; not combinable with `session`. With `session` only `argv` is used — `cwd`, `env`, `user` and `stdin` frames are ignored (the session owns them, and its commands read `/dev/null`) — no `started` is sent, and stderr arrives merged into `stdout`. A child's environment is cleared to `PATH`, `TERM` and the lane's proxy variables, then the [claim env](#claim-env), then `env`; `HOME` and `USER` are set only with `user` | -| procs | `ps` / `kill {pid, signal?}` / `attach {pid}` / `logs {pid}` | `ps` answers one `procs{procs}` frame, `kill` a bare `done`; `logs` replays `stdout/stderr{data}`…, adds `exit{code}` once the process has exited, and closes with `done`; `attach` replays and follows `stdout/stderr{data}`… to `exit{code}`, or to `done` when the process is gone before its exit. Handles are guest pids; any connection can list, signal, replay, or re-attach live | +| procs | `ps` / `kill {pid, signal?}` / `attach {pid}` / `logs {pid}` | `ps` answers one `procs{procs}` frame, `kill` a bare `done`; `logs` replays `stdout/stderr{data}`…, adds `exit{code}` once the process has exited, and closes with `done`; `attach` replays and follows `stdout/stderr{data}`… to `exit{code}`. Handles are guest pids; any connection can list, signal, replay, or re-attach live | | sessions | `session_create {id?, cwd?, env?}` / `session_list` / `session_rm {id}` | `session_created{id}` / `sessions{sessions}` / `done`. A session is a real persistent bash; `exec` with `session` runs inside it. Idle sessions are reaped after 30 minutes | | fs | `fs_write {path, mode?}` (+`data`/`data_end` frames) / `fs_read` / `fs_list` / `fs_stat` / `fs_mkdir {parents?}` / `fs_rm {recursive?}` / `fs_rename {from, to}` | `fs_read` streams `data{data}`… → `done` for a regular file and answers `bad_request` for a directory, device or FIFO, `fs_list` streams 4096-entry `entries{entries}` batches → `done`, `fs_stat` answers one `stat{info}`, and the mutating verbs terminate with `done`. Streaming runs both directions; write commits atomically via temp+rename and inherits an overwritten file's mode and, under root, its owner | | tree | `fs_push {dest}` (+tar as `data` frames) / `fs_pull {path}` | whole trees as tar streams through the guest tar: `fs_pull` streams `data{data}`… → `done`, `fs_push` terminates with `done` | @@ -57,7 +57,7 @@ suites — a frame only one side can parse fails CI. | watch | `fs_watch {path, recursive?}` | `ready` once armed (events after it are guaranteed captured), then `event{kind, path}` until the client disconnects; watcher or delivery-queue overflow errors arrive as a terminal `error` instead of silently losing events | | pty | `pty_open {cols, rows, cwd?, env?, user?}` / `pty_resize {pid, cols, rows}` | `pty_open` answers `started{pid}`, then a shell under a pseudo-terminal: output as `stdout` frames, input as `stdin` frames, `exit{code}` terminal; `pty_resize` answers `done`. PTYs register in the proc table like any exec | | git | `git_clone {url, path, branch?, depth?, auth?}` / `git_status {path}` / `git_add {path, files}` / `git_commit {path, message, author}` / `git_push {path, auth?}` / `git_pull {path, auth?}` / `git_branch {path, action, name?}` | `git_status_result` (porcelain v2), `git_commit_result{hash}`, `git_branches{current, branches}` for `git_branch {action: "list"}`; every other verb terminates with `done`. A status past about 1 MiB of entries carries `truncated: true` with the head of the list, so one frame never exceeds the cap. `auth` is injected as an in-memory header, never written to guest disk | -| port | `port_forward {port}` | relays guest TCP 127.0.0.1:port over this connection: `ready` once connected, then `data` both ways. Each direction ends on its own: `data_end` half-closes the guest socket, `done` reports the guest closing its write side, and the RPC ends once both have. Works on both lanes — the no-network lane's only way in | +| port | `port_forward {port}` | relays guest TCP 127.0.0.1:port over this connection: `ready` once connected, then `data` both ways. Each direction ends on its own: `data_end` half-closes the guest socket, `done` reports the guest closing its write side, and the RPC ends once both have. A client that only drops the connection leaves the relay open until the guest side closes or writes. Works on both lanes — the no-network lane's only way in | | lsp | `lsp_start {language, root?}` / `lsp_request {server_id}` / `lsp_stop {server_id}` | a broker for the language server a flavor image ships: `lsp_start` spawns the argv named in `/etc/silkd/lsp.d/` (absent on the base image → `not_found` naming the flavor) and answers `lsp_started{server_id}`; `lsp_request` attaches the JSON-RPC byte stream (`ready`, then `data` both ways — silkd pipes bytes, it never parses LSP; `data_end` half-closes the server's stdin) and the stream ending reaps the server (v1 single-shot); `lsp_stop` kills it early, and a server nothing attaches within 5 minutes is reaped | | misc | `info` | `info{version, proto, uptime_secs, procs, sessions}` — sandboxd uses it for readiness and the SDKs use `proto` to negotiate connection reuse; there is no public SDK method. Distinct from the control plane's `GET /v1/info`, which reports node pools and claims | From e1705a7667ae762f53857b03f7b5fa530ff5cc3c Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:08:26 +0800 Subject: [PATCH 04/70] review: silkd and sandbox-init borrow what they only read claim_env keeps its one read buffer and parses into borrowed pairs, so an exec, pty or session drops 2N String allocations. BootCfg and IpParam borrow the cmdline, read_manifest returns its text, apply_config sets literals, pty and upload signatures take plain borrows, and scan_serials formats a path only when it reads it. Tests changed only where a literal's type or a temporary's lifetime named the old owned form. --- boot/init/src/boot.rs | 43 +++++++++--------------- boot/init/src/cfg.rs | 76 ++++++++++++++++++++----------------------- silkd/src/exec.rs | 14 ++++---- silkd/src/fs.rs | 4 +-- silkd/src/git.rs | 19 ++++------- silkd/src/lsp.rs | 30 +++++++++-------- silkd/src/pty.rs | 15 ++++++--- silkd/src/session.rs | 7 +++- silkd/src/sysutil.rs | 42 ++++++++++++------------ silkd/src/tree.rs | 4 +-- 10 files changed, 125 insertions(+), 129 deletions(-) diff --git a/boot/init/src/boot.rs b/boot/init/src/boot.rs index 0ac85470a..3bf563181 100644 --- a/boot/init/src/boot.rs +++ b/boot/init/src/boot.rs @@ -4,7 +4,7 @@ use std::fs; use std::path::Path; use std::time::{Duration, Instant}; -use crate::cfg::{self, BootCfg}; +use crate::cfg::{self, BootCfg, IpParam}; use crate::sys; const LAYER_DIR: &str = "/l"; @@ -77,17 +77,12 @@ pub fn run() -> ! { uptime(), cfg.init ); - let err = sys::exec_init(&cfg.init); + let err = sys::exec_init(cfg.init); sys::fatal(&format!("exec {}: {err}", cfg.init), cfg.debug) } -fn assemble(cfg: &BootCfg, marks: &mut Marks) -> Result<(), String> { - let ids: Vec<&str> = cfg - .layers - .iter() - .map(String::as_str) - .chain([cfg.cow.as_str()]) - .collect(); +fn assemble(cfg: &BootCfg<'_>, marks: &mut Marks) -> Result<(), String> { + let ids: Vec<&str> = cfg.layers.iter().copied().chain([cfg.cow]).collect(); let mut devs = resolve_disks(&ids, cfg.timeout)?; let cow_dev = devs .pop() @@ -119,7 +114,7 @@ fn assemble(cfg: &BootCfg, marks: &mut Marks) -> Result<(), String> { )?; marks.mark("overlay"); - if let Some(hostname) = &cfg.hostname { + if let Some(hostname) = cfg.hostname { sys::sethostname(hostname)?; } // empty machine-id => systemd generates a fresh one per VM (clone identity). @@ -140,7 +135,7 @@ fn assemble(cfg: &BootCfg, marks: &mut Marks) -> Result<(), String> { } /// Persists kernel ip= params as MAC-matched networkd units in the new root. -fn persist_network(cfg: &BootCfg) { +fn persist_network(cfg: &BootCfg<'_>) { if cfg.ips.is_empty() { return; } @@ -149,8 +144,7 @@ fn persist_network(cfg: &BootCfg) { eprintln!("sandbox-init: WARN: mkdir {dir}: {err}"); return; } - let devices: Vec<&str> = cfg.ips.iter().map(|ip| ip.device.as_str()).collect(); - for (ip, mac) in cfg.ips.iter().zip(wait_nic_macs(&devices, NIC_TIMEOUT)) { + for (ip, mac) in cfg.ips.iter().zip(wait_nic_macs(&cfg.ips, NIC_TIMEOUT)) { let Some(mac) = mac else { eprintln!( "sandbox-init: WARN: NIC {} not found, static config skipped", @@ -166,11 +160,11 @@ fn persist_network(cfg: &BootCfg) { } /// Resolves every NIC MAC against one shared deadline, so a missing NIC costs the timeout once. -fn wait_nic_macs(devices: &[&str], timeout: Duration) -> Vec> { - poll_slots(devices.len(), timeout, |found| { - for (slot, device) in found.iter_mut().zip(devices) { +fn wait_nic_macs(ips: &[IpParam<'_>], timeout: Duration) -> Vec> { + poll_slots(ips.len(), timeout, |found| { + for (slot, ip) in found.iter_mut().zip(ips) { if slot.is_none() { - *slot = read_nic_mac(device); + *slot = read_nic_mac(ip.device); } } }) @@ -231,12 +225,8 @@ fn scan_serials(ids: &[&str], found: &mut [Option]) { continue; } // the serial attribute location varies by kernel version. - let paths = [ - format!("/sys/block/{name}/serial"), - format!("/sys/block/{name}/device/serial"), - ]; - for path in paths { - let Ok(serial) = fs::read_to_string(&path) else { + for sub in ["serial", "device/serial"] { + let Ok(serial) = fs::read_to_string(format!("/sys/block/{name}/{sub}")) else { continue; }; if serial.trim_end().is_empty() { @@ -266,10 +256,9 @@ fn record_serial(ids: &[&str], found: &mut [Option], serial: &str, devic } fn uptime() -> String { - fs::read_to_string("/proc/uptime") - .ok() - .and_then(|s| s.split_ascii_whitespace().next().map(String::from)) - .unwrap_or_else(|| "?".into()) + let mut s = fs::read_to_string("/proc/uptime").unwrap_or_else(|_| "?".into()); + s.truncate(s.find(' ').unwrap_or(s.len())); + s } #[cfg(test)] diff --git a/boot/init/src/cfg.rs b/boot/init/src/cfg.rs index 6150e02ac..eff02f894 100644 --- a/boot/init/src/cfg.rs +++ b/boot/init/src/cfg.rs @@ -9,18 +9,18 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const MAX_TIMEOUT_SECS: u64 = 86_400; #[derive(Debug, PartialEq, Eq)] -pub struct BootCfg { +pub struct BootCfg<'a> { /// Layer disk serials in lowerdir order (first = uppermost lower layer). - pub layers: Vec, + pub layers: Vec<&'a str>, /// Writable ext4 COW disk ID (same resolution rules as layers). - pub cow: String, + pub cow: &'a str, /// Wait budget for the whole disk set; NICs get a fixed 200 ms. pub timeout: Duration, - pub hostname: Option, + pub hostname: Option<&'a str>, /// Static per-NIC config, persisted as networkd units and never applied in the initramfs. - pub ips: Vec, + pub ips: Vec>, /// Handoff target inside the assembled rootfs. - pub init: String, + pub init: &'a str, /// Fatal errors drop to /bin/sh (debug initramfs) instead of poweroff. pub debug: bool, /// Emit one pre-handoff line with per-phase µs timings (sandbox.trace=1). @@ -29,43 +29,39 @@ pub struct BootCfg { /// One kernel `ip=` param, as cocoon emits it per NIC. #[derive(Debug, PartialEq, Eq)] -pub struct IpParam { - pub addr: String, +pub struct IpParam<'a> { + pub addr: &'a str, pub prefix: u8, - pub gateway: Option, - pub dns: Vec, + pub gateway: Option<&'a str>, + pub dns: Vec<&'a str>, /// Initramfs-time device name, used only to look up the MAC the persisted unit matches on. - pub device: String, + pub device: &'a str, } -pub fn parse(cmdline: &str) -> Result { +pub fn parse(cmdline: &str) -> Result, String> { let mut cfg = BootCfg { layers: Vec::new(), - cow: String::new(), + cow: "", timeout: DEFAULT_TIMEOUT, hostname: None, ips: Vec::new(), - init: DEFAULT_INIT.to_string(), + init: DEFAULT_INIT, debug: false, trace: false, }; for (key, val) in params(cmdline) { match key { "cocoon.layers" => { - cfg.layers = val - .split(',') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); + cfg.layers = val.split(',').filter(|s| !s.is_empty()).collect(); } - "cocoon.cow" => cfg.cow = val.to_string(), + "cocoon.cow" => cfg.cow = val, // a junk value keeps the default, matching the old initramfs hook. "cocoon.timeout" => { if let Ok(secs) = val.parse::() { cfg.timeout = Duration::from_secs(secs.min(MAX_TIMEOUT_SECS)); } } - "cocoon.hostname" if !val.is_empty() => cfg.hostname = Some(val.to_string()), + "cocoon.hostname" if !val.is_empty() => cfg.hostname = Some(val), "sandbox.debug" => cfg.debug = debug_token(val), "sandbox.trace" => cfg.trace = debug_token(val), "ip" => { @@ -73,7 +69,7 @@ pub fn parse(cmdline: &str) -> Result { cfg.ips.push(param); } } - "sandbox.init" if !val.is_empty() => cfg.init = val.to_string(), + "sandbox.init" if !val.is_empty() => cfg.init = val, _ => {} } } @@ -128,24 +124,24 @@ fn debug_token(val: &str) -> bool { } /// Kernel ip= fields: client:server:gw:netmask:hostname:device:autoconf[:dns0[:dns1]]; anything else is ignored. -fn parse_ip_param(val: &str) -> Option { +fn parse_ip_param(val: &str) -> Option> { let f: Vec<&str> = val.split(':').collect(); if f.len() < 7 || f[0].is_empty() || f[5].is_empty() { return None; } let prefix = mask_to_prefix(f[3])?; - let gateway = (!f[2].is_empty() && f[2] != "0.0.0.0").then(|| f[2].to_string()); + let gateway = (!f[2].is_empty() && f[2] != "0.0.0.0").then_some(f[2]); let dns = f[7..] .iter() - .filter(|d| !d.is_empty() && **d != "0.0.0.0") - .map(|d| d.to_string()) + .copied() + .filter(|d| !d.is_empty() && *d != "0.0.0.0") .collect(); Some(IpParam { - addr: f[0].to_string(), + addr: f[0], prefix, gateway, dns, - device: f[5].to_string(), + device: f[5], }) } @@ -171,7 +167,7 @@ mod tests { assert_eq!(cfg.layers, vec!["a", "b"]); assert_eq!(cfg.cow, "cow"); assert_eq!(cfg.timeout, Duration::from_secs(3)); - assert_eq!(cfg.hostname.as_deref(), Some("vm1")); + assert_eq!(cfg.hostname, Some("vm1")); assert_eq!(cfg.init, "/bin/systemd"); assert!(cfg.debug); assert!(cfg.ips.is_empty()); @@ -213,11 +209,11 @@ mod tests { #[test] fn parse_huge_timeout_is_capped() { - let cfg = parse(&format!( + let cmdline = format!( "cocoon.layers=l0 cocoon.cow=cow cocoon.timeout={}", u64::MAX - )) - .unwrap(); + ); + let cfg = parse(&cmdline).unwrap(); assert_eq!(cfg.timeout, Duration::from_secs(MAX_TIMEOUT_SECS)); } @@ -277,11 +273,11 @@ mod tests { assert_eq!( cfg.ips, vec![IpParam { - addr: "172.20.100.5".into(), + addr: "172.20.100.5", prefix: 24, - gateway: Some("172.20.100.1".into()), - dns: vec!["8.8.8.8".into(), "8.8.4.4".into()], - device: "eth0".into(), + gateway: Some("172.20.100.1"), + dns: vec!["8.8.8.8", "8.8.4.4"], + device: "eth0", }] ); } @@ -315,11 +311,11 @@ mod tests { #[test] fn network_unit_layout() { let ip = IpParam { - addr: "172.20.100.5".into(), + addr: "172.20.100.5", prefix: 24, - gateway: Some("172.20.100.1".into()), - dns: vec!["8.8.8.8".into()], - device: "eth0".into(), + gateway: Some("172.20.100.1"), + dns: vec!["8.8.8.8"], + device: "eth0", }; assert_eq!( network_unit(&ip, "aa:bb:cc:dd:ee:ff"), diff --git a/silkd/src/exec.rs b/silkd/src/exec.rs index 219ebefc5..e2e1bd175 100644 --- a/silkd/src/exec.rs +++ b/silkd/src/exec.rs @@ -1,5 +1,6 @@ //! exec handler: a detached exec returns after `started` and stays in the table for attach/logs. +use std::borrow::Cow; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -34,7 +35,8 @@ pub async fn run( where W: AsyncWrite + Unpin, { - let claim = sysutil::claim_env(); + let claim_text = sysutil::claim_env(); + let claim = sysutil::parse_env_file(&claim_text); let program = if req.user.is_none() { resolve_program(&req.argv[0], effective_path(&req.env, &claim)) } else { @@ -52,7 +54,7 @@ where } cmd.env_clear(); cmd.envs(sysutil::base_env().iter().copied()); - cmd.envs(claim); + cmd.envs(claim.iter().map(|(k, v)| (*k, &**v))); cmd.envs(&req.env); if let Some(ref user) = req.user && let Err(e) = sysutil::apply_user(&mut cmd, user) @@ -232,7 +234,7 @@ async fn pump_stdin( /// The PATH the child sees: the request's, else the claim's, else the base environment's. fn effective_path<'a>( req: &'a HashMap, - claim: &'a [(String, String)], + claim: &'a [(&str, Cow<'_, str>)], ) -> Option<&'a str> { req.get("PATH") .map(String::as_str) @@ -240,8 +242,8 @@ fn effective_path<'a>( claim .iter() .rev() - .find(|(k, _)| k == "PATH") - .map(|(_, v)| v.as_str()) + .find(|(k, _)| *k == "PATH") + .map(|(_, v)| &**v) }) .or_else(|| { sysutil::base_env() @@ -309,7 +311,7 @@ mod tests { #[test] fn effective_path_prefers_the_request_then_the_claim() { let req = HashMap::from([("PATH".to_string(), "/req".to_string())]); - let claim = vec![("PATH".to_string(), "/claim".to_string())]; + let claim = vec![("PATH", Cow::from("/claim"))]; assert_eq!(effective_path(&req, &claim), Some("/req")); assert_eq!(effective_path(&HashMap::new(), &claim), Some("/claim")); assert!(effective_path(&HashMap::new(), &[]).is_some_and(|p| p.contains("/usr/bin"))); diff --git a/silkd/src/fs.rs b/silkd/src/fs.rs index b7971b80f..ae28b38ba 100644 --- a/silkd/src/fs.rs +++ b/silkd/src/fs.rs @@ -18,7 +18,7 @@ pub const LIST_BATCH: usize = 4096; /// Streams client `data` frames into `path` via a temp file renamed only on clean completion. pub async fn write( - mut reader: R, + reader: &mut R, w: &mut W, path: String, mode: Option, @@ -33,7 +33,7 @@ where Err(e) => return err_frame(w, &e, "create").await, }; - let outcome = proto::feed_data_frames(&mut reader, &mut file).await.and( + let outcome = proto::feed_data_frames(reader, &mut file).await.and( file.flush() .await .map_err(|e| proto::FeedError::Io(e, "flush")), diff --git a/silkd/src/git.rs b/silkd/src/git.rs index 7a8c753d6..d53a50af5 100644 --- a/silkd/src/git.rs +++ b/silkd/src/git.rs @@ -1,6 +1,5 @@ //! git verbs wrap the guest git binary into structured results; the none lane fails clone/push/pull with a typed error. -use std::borrow::Cow; use std::process::Stdio; use tokio::io::AsyncWrite; @@ -169,18 +168,14 @@ async fn git( /// Injects git config as GIT_CONFIG_COUNT/KEY_n/VALUE_n env pairs. fn apply_config(cmd: &mut Command, auth: Option<&str>) { - let mut pairs: Vec<(&str, Cow<'static, str>)> = - vec![("core.quotePath", Cow::Borrowed("false"))]; + cmd.env("GIT_CONFIG_COUNT", if auth.is_some() { "2" } else { "1" }) + .env("GIT_CONFIG_KEY_0", "core.quotePath") + .env("GIT_CONFIG_VALUE_0", "false"); if let Some(token) = auth { - pairs.push(( - "http.extraHeader", - Cow::Owned(format!("Authorization: Bearer {token}")), - )); - } - cmd.env("GIT_CONFIG_COUNT", pairs.len().to_string()); - for (i, (key, value)) in pairs.iter().enumerate() { - cmd.env(format!("GIT_CONFIG_KEY_{i}"), key); - cmd.env(format!("GIT_CONFIG_VALUE_{i}"), value.as_ref()); + cmd.env("GIT_CONFIG_KEY_1", "http.extraHeader").env( + "GIT_CONFIG_VALUE_1", + format!("Authorization: Bearer {token}"), + ); } } diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 0f25d52c7..4107516c6 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -41,20 +41,22 @@ impl Broker { language: &str, root: Option<&str>, ) -> std::io::Result<()> { - let argv = match read_manifest(language).await { - Some(argv) if !argv.is_empty() => argv, - _ => { - return proto::error_frame( - w, - ErrorKind::NotFound, - format!("no language server for {language:?}; a flavor image provides {}/{language}", manifest_dir()), - ) - .await; - } + let raw = read_manifest(language).await.unwrap_or_default(); + let mut argv = raw.split_whitespace(); + let Some(program) = argv.next() else { + return proto::error_frame( + w, + ErrorKind::NotFound, + format!( + "no language server for {language:?}; a flavor image provides {}/{language}", + manifest_dir() + ), + ) + .await; }; - let mut cmd = Command::new(&argv[0]); + let mut cmd = Command::new(program); sysutil::align_proxy_env(&mut cmd); - cmd.args(&argv[1..]) + cmd.args(argv) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -230,7 +232,7 @@ fn manifest_dir() -> Cow<'static, str> { std::env::var("SILKD_LSP_DIR").map_or(Cow::Borrowed(MANIFEST_DIR), Cow::Owned) } -async fn read_manifest(language: &str) -> Option> { +async fn read_manifest(language: &str) -> Option { if language.is_empty() || language.contains(['/', '\\', '\0']) || language.contains("..") { return None; // never let a language name escape the manifest dir } @@ -243,5 +245,5 @@ async fn read_manifest(language: &str) -> Option> { .ok()?; let mut raw = String::new(); f.read_to_string(&mut raw).await.ok()?; - Some(raw.split_whitespace().map(str::to_string).collect()) + Some(raw) } diff --git a/silkd/src/pty.rs b/silkd/src/pty.rs index 64a680dc5..3da985d25 100644 --- a/silkd/src/pty.rs +++ b/silkd/src/pty.rs @@ -38,6 +38,7 @@ pub async fn open( }; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); + let claim = sysutil::claim_env(); let mut cmd = Command::new(&shell); cmd.arg("-i"); if let Some(dir) = &req.cwd { @@ -45,7 +46,11 @@ pub async fn open( } cmd.env_clear() .envs(sysutil::base_env().iter().copied()) - .envs(sysutil::claim_env()) + .envs( + sysutil::parse_env_file(&claim) + .iter() + .map(|(k, v)| (*k, &**v)), + ) .envs(&req.env); if let Some(user) = &req.user && let Err(e) = sysutil::apply_user(&mut cmd, user) @@ -117,7 +122,7 @@ pub async fn resize( /// Pumps master output to the client and client stdin to the master; the caller writes the exit frame. async fn pump( master: &Master, - proc: &Arc, + proc: &Proc, client: mpsc::Receiver, out: &mut W, child: &mut tokio::process::Child, @@ -188,8 +193,8 @@ async fn pump_stdin( /// Reads the master's buffered tail after the shell exits, bounded by POST_EXIT_DRAIN. async fn drain( - master: &Master, - proc: &Arc, + master: &AsyncFd, + proc: &Proc, out: &mut W, buf: &mut [u8], frame: &mut Vec, @@ -217,7 +222,7 @@ async fn drain( .await; } -async fn write_all(master: &Master, mut data: &[u8]) -> std::io::Result<()> { +async fn write_all(master: &AsyncFd, mut data: &[u8]) -> std::io::Result<()> { while !data.is_empty() { let mut guard = master.writable().await?; match guard.try_io(|fd| nix::unistd::write(fd.get_ref(), data).map_err(Into::into)) { diff --git a/silkd/src/session.rs b/silkd/src/session.rs index ccf2c4477..7a693e51b 100644 --- a/silkd/src/session.rs +++ b/silkd/src/session.rs @@ -48,10 +48,15 @@ impl Table { format!("environment name {name:?} is not a shell identifier"), )); } + let claim = sysutil::claim_env(); let mut child = Command::new("bash") .env_clear() .envs(sysutil::base_env().iter().copied()) - .envs(sysutil::claim_env()) + .envs( + sysutil::parse_env_file(&claim) + .iter() + .map(|(k, v)| (*k, &**v)), + ) .stdin(Stdio::piped()) .stdout(Stdio::piped()) // null, not piped: the init line `exec 2>&1` repoints fd 2 at the stdout pipe. diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index 5a2ef4773..a1ee40e6f 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -1,5 +1,6 @@ //! Small OS helpers; the crate's unsafe work lives here. +use std::borrow::Cow; use std::io::Read; use std::os::fd::{AsRawFd, OwnedFd, RawFd}; use std::os::unix::fs::MetadataExt; @@ -80,11 +81,17 @@ pub fn base_env() -> &'static [(&'static str, &'static str)] { } } -/// The claim's environment every spawned child gets after base_env; empty when the host wrote none. -pub fn claim_env() -> Vec<(String, String)> { - std::fs::read_to_string(CLAIM_ENV_PATH) - .map(|text| parse_env_file(&text)) - .unwrap_or_default() +/// The claim's environment file every exec, session and pty parses after base_env; empty when the host wrote none. +pub fn claim_env() -> String { + std::fs::read_to_string(CLAIM_ENV_PATH).unwrap_or_default() +} + +/// Parses the subset the host writes: one `KEY="value"` per line, with systemd's double-quote escapes. +pub fn parse_env_file(text: &str) -> Vec<(&str, Cow<'_, str>)> { + text.lines() + .filter_map(|line| line.split_once('=')) + .map(|(key, value)| (key, unquote(value))) + .collect() } /// Applies base_env's lane rule to an env-inheriting child: a routed NIC drops the proxy variables. @@ -274,18 +281,13 @@ fn snapshot_proxy(get: impl Fn(&str) -> Option) -> Vec<(&'static str, St .collect() } -/// Parses the subset the host writes: one `KEY="value"` per line, with systemd's double-quote escapes. -fn parse_env_file(text: &str) -> Vec<(String, String)> { - text.lines() - .filter_map(|line| line.split_once('=')) - .map(|(key, value)| (key.to_owned(), unquote(value))) - .collect() -} - -fn unquote(value: &str) -> String { +fn unquote(value: &str) -> Cow<'_, str> { let Some(inner) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) else { - return value.to_owned(); + return Cow::Borrowed(value); }; + if !inner.contains('\\') { + return Cow::Borrowed(inner); + } let mut out = String::with_capacity(inner.len()); let mut chars = inner.chars(); while let Some(c) = chars.next() { @@ -302,7 +304,7 @@ fn unquote(value: &str) -> String { None => out.push('\\'), } } - out + Cow::Owned(out) } fn lookup_user(user: &str) -> Result<(u32, u32, PathBuf), String> { @@ -374,10 +376,10 @@ mod tests { fn parse_env_file_reads_what_the_host_writes() { let text = "A=\"plain\"\nB=\"a \\\"quoted\\\" \\$HOME \\\\ path \\`x\\`\"\nC=bare\nnot-an-entry\nD=\"keep \\n\"\n"; let want = vec![ - ("A".to_owned(), "plain".to_owned()), - ("B".to_owned(), "a \"quoted\" $HOME \\ path `x`".to_owned()), - ("C".to_owned(), "bare".to_owned()), - ("D".to_owned(), "keep \\n".to_owned()), + ("A", Cow::from("plain")), + ("B", Cow::from("a \"quoted\" $HOME \\ path `x`")), + ("C", Cow::from("bare")), + ("D", Cow::from("keep \\n")), ]; assert_eq!(parse_env_file(text), want); } diff --git a/silkd/src/tree.rs b/silkd/src/tree.rs index cfa34134d..d709e664f 100644 --- a/silkd/src/tree.rs +++ b/silkd/src/tree.rs @@ -23,7 +23,7 @@ const DIR_FLAGS: OFlag = OFlag::O_DIRECTORY .union(OFlag::O_CLOEXEC); /// Extracts a client tar stream into `dest`, creating it; a stream failure leaves `dest` unchanged. -pub async fn push(mut reader: R, w: &mut W, dest: String) -> io::Result<()> +pub async fn push(reader: &mut R, w: &mut W, dest: String) -> io::Result<()> where R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin, @@ -36,7 +36,7 @@ where Ok(fds) => fds, Err(e) => return err_frame(w, &e, "stage push").await, }; - let res = push_staged(&mut reader, w, staging, dest_fd).await; + let res = push_staged(reader, w, staging, dest_fd).await; // a failed push can leave a large partial tree, so cleanup runs on every exit path. let _ = tokio::fs::remove_dir_all(Path::new(&dest).join(&name)).await; res From 19ffac14b4785504b90860d641d1451ad82acf99 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:09:31 +0800 Subject: [PATCH 05/70] review: silkd drops code that cannot run Child::id is Some right after spawn, a session body is never empty, Path::file_name is never empty, abort on a finished task is a no-op, and the exec rebinds did nothing. Table::new and Broker::new only aliased Default; git hand-rolled error_frame; the imds consts and handle, DEFAULT_INIT and PROTO_VERSION had no outside user; the ignored bench asserted nothing. --- boot/init/src/cfg.rs | 2 +- silkd/src/exec.rs | 17 +++++++---------- silkd/src/forward.rs | 4 +--- silkd/src/git.rs | 19 +++++-------------- silkd/src/imds.rs | 6 +++--- silkd/src/lsp.rs | 4 ---- silkd/src/proc.rs | 18 +++--------------- silkd/src/proto.rs | 28 +--------------------------- silkd/src/pty.rs | 4 ++-- silkd/src/server.rs | 6 +++--- silkd/src/session.rs | 9 +-------- silkd/src/tree.rs | 2 +- silkd/tests/pty_e2e.rs | 2 +- 13 files changed, 29 insertions(+), 92 deletions(-) diff --git a/boot/init/src/cfg.rs b/boot/init/src/cfg.rs index eff02f894..8547935a0 100644 --- a/boot/init/src/cfg.rs +++ b/boot/init/src/cfg.rs @@ -3,7 +3,7 @@ use std::fmt::Write as _; use std::time::Duration; -pub const DEFAULT_INIT: &str = "/sbin/init"; +const DEFAULT_INIT: &str = "/sbin/init"; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); // cap the timeout: `Instant + Duration` panics on overflow, and a PID 1 panic is a kernel panic. const MAX_TIMEOUT_SECS: u64 = 86_400; diff --git a/silkd/src/exec.rs b/silkd/src/exec.rs index e2e1bd175..c3582dc7b 100644 --- a/silkd/src/exec.rs +++ b/silkd/src/exec.rs @@ -13,7 +13,7 @@ use tokio::process::Command; use tokio::sync::mpsc; use tokio::time::timeout; -use crate::proc::{Chunk, Proc, Table, synth_pid, write_chunk}; +use crate::proc::{Chunk, Proc, Table, write_chunk}; use crate::proto::{self, ErrorKind, ExecReq, Request, Response}; use crate::sysutil; @@ -49,14 +49,14 @@ where .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(!req.detach); - if let Some(ref cwd) = req.cwd { + if let Some(cwd) = &req.cwd { cmd.current_dir(cwd); } cmd.env_clear(); cmd.envs(sysutil::base_env().iter().copied()); cmd.envs(claim.iter().map(|(k, v)| (*k, &**v))); cmd.envs(&req.env); - if let Some(ref user) = req.user + if let Some(user) = &req.user && let Err(e) = sysutil::apply_user(&mut cmd, user) { return proto::error_frame(out, ErrorKind::BadRequest, e).await; @@ -69,7 +69,7 @@ where } }; - let pid = child.id().unwrap_or_else(synth_pid); + let pid = child.id().unwrap_or(0); let proc = table.register(pid, req.argv, req.detach, now_secs); if let Err(e) = proto::write_frame(out, &Response::Started { pid }).await { // the client never learned this pid, so nothing else will ever reap the child. @@ -83,10 +83,10 @@ where let stderr = child.stderr.take(); // a backpressured mpsc paces the child to the foreground client; attachers ride the lossy broadcast. - let (fg_tx, fg_rx) = mpsc::channel::(FG_CAP); + let (fg_tx, mut fg_rx) = mpsc::channel::(FG_CAP); let (pump_fg, sup_fg) = (!req.detach).then(|| (fg_tx.clone(), fg_tx)).unzip(); - let pump = tokio::spawn(pump_out(Arc::clone(&proc), stdout, stderr, pump_fg)); + let mut pump = tokio::spawn(pump_out(Arc::clone(&proc), stdout, stderr, pump_fg)); let pump_abort = pump.abort_handle(); // stdin rides its own task so a slow write cannot stall output or reaping. let stdin_task = tokio::spawn(pump_stdin(stdin, client, req.detach)); @@ -98,7 +98,6 @@ where let supervise = tokio::spawn(async move { let code = sysutil::wait_code(&mut child).await; let _ = sup_reaped.set(code); - let mut pump = pump; if timeout(POST_EXIT_DRAIN, &mut pump).await.is_err() { pump.abort(); } @@ -110,7 +109,6 @@ where }); if req.detach { - drop(fg_rx); let table = table.clone(); let proc = Arc::clone(&proc); tokio::spawn(async move { @@ -121,8 +119,7 @@ where return Ok(()); } - let mut rx = fg_rx; - if let Err(e) = stream_to_client(&mut rx, out).await { + if let Err(e) = stream_to_client(&mut fg_rx, out).await { // client gone: publish the terminal state the abort pre-empts, else an attacher waits forever. supervise.abort(); pump_abort.abort(); diff --git a/silkd/src/forward.rs b/silkd/src/forward.rs index b7f787cfe..6af00bc69 100644 --- a/silkd/src/forward.rs +++ b/silkd/src/forward.rs @@ -76,9 +76,7 @@ pub async fn run( } } }; - if !feed_done { - feed.abort(); - } + feed.abort(); res } diff --git a/silkd/src/git.rs b/silkd/src/git.rs index d53a50af5..7a422ff04 100644 --- a/silkd/src/git.rs +++ b/silkd/src/git.rs @@ -12,6 +12,8 @@ use crate::sysutil; const STATUS_FILES_BYTES: usize = proto::MAX_FRAME / 8; const STATUS_ENTRY_OVERHEAD: usize = 40; +const NO_EGRESS: &str = "no network on this sandbox; use fs.push to move a repository in"; + /// Clones `url` into `path` (optionally a branch, shallow depth), then Done. pub async fn clone( w: &mut W, @@ -22,7 +24,7 @@ pub async fn clone( auth: Option, ) -> std::io::Result<()> { if !crate::net::has_egress() { - return proto::write_frame(w, &no_egress()).await; + return proto::error_frame(w, ErrorKind::Unimplemented, NO_EGRESS).await; } let depth_arg; let mut args: Vec<&str> = vec!["clone"]; @@ -96,7 +98,7 @@ pub async fn net_verb( verb: &str, ) -> std::io::Result<()> { if !crate::net::has_egress() { - return proto::write_frame(w, &no_egress()).await; + return proto::error_frame(w, ErrorKind::Unimplemented, NO_EGRESS).await; } let out = git(&path, auth.as_deref(), &[verb]).await?; terminal(w, verb, &out).await @@ -112,11 +114,7 @@ pub async fn branch( let args: Vec<&str> = match (op, &name) { (GitBranchOp::List, _) => return list_branches(w, &path).await, (_, None) => { - return proto::write_frame( - w, - &Response::error(ErrorKind::BadRequest, "branch op needs a name"), - ) - .await; + return proto::error_frame(w, ErrorKind::BadRequest, "branch op needs a name").await; } (GitBranchOp::Create, Some(name)) => vec!["branch", name], (GitBranchOp::Delete, Some(name)) => vec!["branch", "-D", name], @@ -179,13 +177,6 @@ fn apply_config(cmd: &mut Command, auth: Option<&str>) { } } -fn no_egress() -> Response { - Response::error( - ErrorKind::Unimplemented, - "no network on this sandbox; use fs.push to move a repository in", - ) -} - /// Writes Done on success, else the git stderr as an error frame. async fn terminal( w: &mut W, diff --git a/silkd/src/imds.rs b/silkd/src/imds.rs index 1a9314879..7caef247e 100644 --- a/silkd/src/imds.rs +++ b/silkd/src/imds.rs @@ -8,9 +8,9 @@ use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; /// The metadata address; an image opts in by aliasing it on `lo`, and without the alias the responder stays off. -pub const ADDR: &str = "169.254.169.254:80"; +const ADDR: &str = "169.254.169.254:80"; /// The document the host writes per sandbox through `fs_write`. -pub const DOC_PATH: &str = "/run/silkd/instance-metadata.json"; +const DOC_PATH: &str = "/run/silkd/instance-metadata.json"; const TOKEN: &[u8] = b"silkd-instance-metadata"; const HEAD_MAX: usize = 8 * 1024; @@ -41,7 +41,7 @@ pub async fn serve() -> io::Result<()> { } /// Answers one request on `conn` and closes it: `PUT /latest/api/token` mints a token, `GET /` serves `doc` or `{}`. -pub async fn handle(mut conn: S, doc: &Path) -> io::Result<()> +async fn handle(mut conn: S, doc: &Path) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin, { diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 4107516c6..1a677ff1f 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -30,10 +30,6 @@ pub struct Broker { } impl Broker { - pub fn new() -> Self { - Self::default() - } - /// Spawns the manifested language server for `language` and returns its id. pub async fn start( &self, diff --git a/silkd/src/proc.rs b/silkd/src/proc.rs index d602f7db9..973f5e6dc 100644 --- a/silkd/src/proc.rs +++ b/silkd/src/proc.rs @@ -3,7 +3,6 @@ use std::borrow::Cow; use std::collections::{HashMap, VecDeque}; use std::os::fd::{AsRawFd, OwnedFd}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use tokio::io::AsyncWrite; @@ -15,9 +14,6 @@ use crate::sysutil; const LOG_RING_BYTES: usize = 256 * 1024; const OUTPUT_FANOUT: usize = 256; -/// Fallback pids for a child with no OS pid, masked below i32::MAX so a pid_t cast stays valid. -static SYNTH: AtomicU64 = AtomicU64::new(1 << 30); - /// A chunk of process output tagged by stream, shared with live attachers. #[derive(Clone)] pub enum Chunk { @@ -33,10 +29,6 @@ pub struct Table { } impl Table { - pub fn new() -> Self { - Self::default() - } - pub fn register(&self, pid: u32, argv: Vec, detached: bool, now: u64) -> Arc { let proc = Arc::new(Proc { pid, @@ -263,17 +255,13 @@ pub async fn write_chunk( } } -pub fn synth_pid() -> u32 { - (SYNTH.fetch_add(1, Ordering::Relaxed) & (i32::MAX as u64)) as u32 -} - #[cfg(test)] mod tests { use super::*; #[test] fn fan_out_is_built_by_the_first_attacher() { - let table = Table::new(); + let table = Table::default(); let proc = table.register(7, vec!["true".to_string()], true, 0); proc.emit_bytes(false, b"before"); assert!( @@ -295,7 +283,7 @@ mod tests { #[test] fn attach_after_exit_replays_the_tail_with_the_code() { - let table = Table::new(); + let table = Table::default(); let proc = table.register(8, vec!["true".to_string()], true, 0); proc.emit_bytes(false, b"tail"); proc.finish(5); @@ -310,7 +298,7 @@ mod tests { #[test] fn attach_before_exit_receives_the_tail_then_the_exit() { - let table = Table::new(); + let table = Table::default(); let proc = table.register(9, vec!["true".to_string()], true, 0); let (replay, mut rx, exit) = proc.attach_stream(); assert!(replay.is_empty()); diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index bd2e9ca29..0f7c4e377 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -18,7 +18,6 @@ use tokio::io::{ /// Mirrors cocoon-agent's frame cap so a malformed peer can't OOM us. pub const MAX_FRAME: usize = 8 * 1024 * 1024; -pub const PROTO_VERSION: u32 = 1; /// 2 serves RPCs back to back on one connection; 1 closed after one. pub const KEEP_ALIVE_PROTO: u32 = 2; @@ -636,6 +635,7 @@ mod tests { use super::*; const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../protocol/wire/fixtures/v1"); + const PROTO_VERSION: u32 = 1; #[test] fn request_roundtrip_and_unknown_fields_ignored() { @@ -706,32 +706,6 @@ mod tests { assert_eq!(got, want); } - #[tokio::test] - #[ignore] - async fn bench_chunk_render_old_vs_new() { - const CHUNKS: usize = 32 * 1024; - let data = vec![0xa5u8; BULK_CHUNK]; - let mut sink = tokio::io::sink(); - - let start = std::time::Instant::now(); - for _ in 0..CHUNKS { - write_frame(&mut sink, &Response::Data { data: data.clone() }) - .await - .expect("serde render"); - } - let serde_dt = start.elapsed(); - - let mut frame = Vec::new(); - let start = std::time::Instant::now(); - for _ in 0..CHUNKS { - write_chunk_frame(&mut sink, &mut frame, "data", &data) - .await - .expect("hand render"); - } - let hand_dt = start.elapsed(); - println!("{CHUNKS} x {BULK_CHUNK} B chunks: serde {serde_dt:?}, hand-rendered {hand_dt:?}"); - } - #[tokio::test] async fn write_frames_keeps_the_frames_before_an_oversized_one() { let mut sink = Vec::new(); diff --git a/silkd/src/pty.rs b/silkd/src/pty.rs index 3da985d25..5d77eb9f6 100644 --- a/silkd/src/pty.rs +++ b/silkd/src/pty.rs @@ -11,7 +11,7 @@ use tokio::process::Command; use tokio::sync::{mpsc, oneshot}; use tokio::time::timeout; -use crate::proc::{Proc, Table, synth_pid}; +use crate::proc::{Proc, Table}; use crate::proto::{self, ErrorKind, PtyReq, READ_CHUNK, Request, Response}; use crate::sysutil; @@ -77,7 +77,7 @@ pub async fn open( // drop cmd so its slave dups close, else the master never sees the shell's exit and the pump hangs. drop(cmd); - let pid = child.id().unwrap_or_else(synth_pid); + let pid = child.id().unwrap_or(0); let proc = table.register(pid, vec![shell, "-i".to_string()], false, now); proc.set_pty_master(resize_fd); diff --git a/silkd/src/server.rs b/silkd/src/server.rs index 46dfb65dd..4db98512d 100644 --- a/silkd/src/server.rs +++ b/silkd/src/server.rs @@ -22,9 +22,9 @@ pub struct State { impl State { pub fn new() -> Self { Self { - table: Table::new(), - sessions: session::Table::new(), - lsp: lsp::Broker::new(), + table: Table::default(), + sessions: session::Table::default(), + lsp: lsp::Broker::default(), started: Instant::now(), } } diff --git a/silkd/src/session.rs b/silkd/src/session.rs index 7a693e51b..9dddb94a6 100644 --- a/silkd/src/session.rs +++ b/silkd/src/session.rs @@ -27,10 +27,6 @@ pub struct Table { } impl Table { - pub fn new() -> Self { - Self::default() - } - /// Spawns a bash session, applies cwd/env, and registers it; a blank id is generated, a duplicate refused. pub async fn create( &self, @@ -204,10 +200,7 @@ impl Io { cmd: &str, mut out: Option<&mut W>, ) -> io::Result { - let body = match cmd.trim_end() { - "" => ":", - c => c, - }; + let body = cmd.trim_end(); // the marker must be unforgeable: the shell runs untrusted code that could fake a sentinel. let marker = format!("__SILK_{}__", sysutil::rand_token()?); self.cmd_buf.clear(); diff --git a/silkd/src/tree.rs b/silkd/src/tree.rs index d709e664f..8b7989392 100644 --- a/silkd/src/tree.rs +++ b/silkd/src/tree.rs @@ -46,7 +46,7 @@ where pub async fn pull(w: &mut W, path: String) -> io::Result<()> { let p = Path::new(&path); let (parent, name) = match (p.parent(), p.file_name()) { - (Some(par), Some(n)) if !n.is_empty() => (par, n), + (Some(par), Some(n)) => (par, n), _ => return proto::error_frame(w, ErrorKind::BadRequest, "invalid path").await, }; // symlink_metadata: a dangling symlink is still a valid tar source. diff --git a/silkd/tests/pty_e2e.rs b/silkd/tests/pty_e2e.rs index 75214088c..71e9da61a 100644 --- a/silkd/tests/pty_e2e.rs +++ b/silkd/tests/pty_e2e.rs @@ -149,7 +149,7 @@ async fn pty_disconnect_tears_down_without_spin() { #[tokio::test] async fn pty_started_write_failure_kills_the_shell_and_unregisters_it() { - let table = Table::new(); + let table = Table::default(); let (_client, client_rx) = mpsc::channel(1); let req = PtyReq { cols: 80, From c33556199d30579a26b302afb523b8ecf275b4eb Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:10:50 +0800 Subject: [PATCH 06/70] review: silkd statics and test helpers follow the file layout Function-local statics and consts move to the top-of-file block, and the one-caller proxy_vars and silkd_net wrappers go with them; test helpers move above the tests and private below public. --- silkd/src/net.rs | 33 +++++++++++++------------------ silkd/src/proto.rs | 16 +++++++++------ silkd/src/sysutil.rs | 19 ++++++++---------- silkd/tests/common/mod.rs | 32 +++++++++++++++--------------- silkd/tests/forward_e2e.rs | 40 +++++++++++++++++++------------------- 5 files changed, 68 insertions(+), 72 deletions(-) diff --git a/silkd/src/net.rs b/silkd/src/net.rs index 976131c3c..970ddbc6d 100644 --- a/silkd/src/net.rs +++ b/silkd/src/net.rs @@ -11,6 +11,19 @@ const LANE_FILE: &str = "/etc/silkd-lane"; static LANE_OVERRIDE: AtomicI8 = AtomicI8::new(-1); /// The lane file's verdict once read: -1 not yet present, 0 direct, 1 relay. static LANE: AtomicI8 = AtomicI8::new(-1); +// probed once: the NIC set is fixed for the guest's life +static DEVICE_BACKED: LazyLock = LazyLock::new(|| { + let Ok(entries) = std::fs::read_dir("/sys/class/net") else { + return true; + }; + entries.flatten().any(|e| e.path().join("device").exists()) +}); +/// SILKD_NET decoded once: operator config, fixed at service start. +static SILKD_NET: LazyLock = LazyLock::new(|| match std::env::var("SILKD_NET").as_deref() { + Ok("none") => 0, + Ok("egress") => 1, + _ => -1, +}); /// Reports whether the guest can reach a network; only a `device`-backed interface counts, since the kernel auto-creates virtual tunnels. pub fn has_egress() -> bool { @@ -19,18 +32,11 @@ pub fn has_egress() -> bool { 1 => return true, _ => {} } - match silkd_net() { + match *SILKD_NET { 0 => return false, 1 => return true, _ => {} } - // probed once: the NIC set is fixed for the guest's life - static DEVICE_BACKED: LazyLock = LazyLock::new(|| { - let Ok(entries) = std::fs::read_dir("/sys/class/net") else { - return true; - }; - entries.flatten().any(|e| e.path().join("device").exists()) - }); *DEVICE_BACKED } @@ -54,17 +60,6 @@ pub fn override_egress_for_tests(lane: Option) { LANE_OVERRIDE.store(lane.map_or(-1, i8::from), Ordering::Relaxed); } -/// SILKD_NET decoded once: operator config, fixed at service start. -fn silkd_net() -> i8 { - static SILKD_NET: LazyLock = - LazyLock::new(|| match std::env::var("SILKD_NET").as_deref() { - Ok("none") => 0, - Ok("egress") => 1, - _ => -1, - }); - *SILKD_NET -} - /// Reads the lane file: `Some(true)` for relay, `Some(false)` for direct, `None` while the host has not written it. fn lane_verdict(path: &str) -> Option { let mut buf = [0u8; 8]; diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index 0f7c4e377..147c596ef 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -28,6 +28,9 @@ pub const BULK_CHUNK: usize = 256 * 1024; const DATA_FRAME_HEAD: &[u8] = b"{\"v\":1,\"op\":\"data\",\"data\":\""; const STDIN_FRAME_HEAD: &[u8] = b"{\"v\":1,\"op\":\"stdin\",\"data\":\""; +const CHUNK_FRAME_PRE: &[u8] = b"{\"type\":\""; +const CHUNK_FRAME_MID: &[u8] = b"\",\"data\":\""; +const CHUNK_FRAME_END: &[u8] = b"\"}\n"; /// Client → server frames; unknown JSON fields are ignored, which is forward compatibility. #[derive(Debug, Deserialize)] @@ -447,18 +450,19 @@ pub async fn write_chunk_frame( kind: &str, data: &[u8], ) -> io::Result<()> { - const PRE: &[u8] = b"{\"type\":\""; - const MID: &[u8] = b"\",\"data\":\""; - const END: &[u8] = b"\"}\n"; let b64_len = base64::encoded_len(data.len(), true) .ok_or_else(|| io::Error::other("chunk too large to encode"))?; - let total = PRE.len() + kind.len() + MID.len() + b64_len + END.len(); + let total = CHUNK_FRAME_PRE.len() + + kind.len() + + CHUNK_FRAME_MID.len() + + b64_len + + CHUNK_FRAME_END.len(); // grow-only: buf[..total] is fully overwritten, so the zero-fill runs once at high-water. if buf.len() < total { buf.resize(total, 0); } let mut at = 0; - for part in [PRE, kind.as_bytes(), MID] { + for part in [CHUNK_FRAME_PRE, kind.as_bytes(), CHUNK_FRAME_MID] { buf[at..at + part.len()].copy_from_slice(part); at += part.len(); } @@ -466,7 +470,7 @@ pub async fn write_chunk_frame( .encode_slice(data, &mut buf[at..at + b64_len]) .map_err(io::Error::other)?; at += b64_len; - buf[at..at + END.len()].copy_from_slice(END); + buf[at..at + CHUNK_FRAME_END.len()].copy_from_slice(CHUNK_FRAME_END); w.write_all(&buf[..total]).await?; w.flush().await } diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index a1ee40e6f..85a7c2e59 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -16,6 +16,14 @@ use tokio::process::Command; static TMP_SEQ: AtomicU64 = AtomicU64::new(0); +/// The FORWARDED values in silkd's own environment, read once: the unit environment is fixed. +static PROXY_VARS: LazyLock> = + LazyLock::new(|| snapshot_proxy(|key| std::env::var(key).ok())); +static DIRECT: LazyLock> = + LazyLock::new(|| compose_env(true, &PROXY_VARS)); +static RELAYED: LazyLock> = + LazyLock::new(|| compose_env(false, &PROXY_VARS)); + /// Forwarded into every exec that has no direct route: the loopback proxy relay is its only way out. const FORWARDED: [&str; 6] = [ "http_proxy", @@ -70,10 +78,6 @@ pub fn signal_pid(pid: u32, sig: i32) { /// The environment every exec starts from; the proxy snapshot rides only where nothing routes directly. pub fn base_env() -> &'static [(&'static str, &'static str)] { - static DIRECT: LazyLock> = - LazyLock::new(|| compose_env(true, proxy_vars())); - static RELAYED: LazyLock> = - LazyLock::new(|| compose_env(false, proxy_vars())); if crate::net::routes_directly() { &DIRECT } else { @@ -264,13 +268,6 @@ fn compose_env<'a>( env } -/// The FORWARDED values in silkd's own environment, read once: the unit environment is fixed. -fn proxy_vars() -> &'static [(&'static str, String)] { - static PROXY_VARS: LazyLock> = - LazyLock::new(|| snapshot_proxy(|key| std::env::var(key).ok())); - &PROXY_VARS -} - fn snapshot_proxy(get: impl Fn(&str) -> Option) -> Vec<(&'static str, String)> { FORWARDED .iter() diff --git a/silkd/tests/common/mod.rs b/silkd/tests/common/mod.rs index f3566985f..3d5b2f892 100644 --- a/silkd/tests/common/mod.rs +++ b/silkd/tests/common/mod.rs @@ -59,22 +59,6 @@ pub async fn roundtrip(request_line: &str) -> Vec { request_on(&Arc::new(State::new()), &[request_line.to_string()]).await } -async fn request_on(state: &Arc, lines: &[String]) -> Vec { - let (mut cw, mut out, handle) = connect(state); - for line in lines { - cw.write_all(line.as_bytes()).await.unwrap(); - cw.write_all(b"\n").await.unwrap(); - } - cw.shutdown().await.unwrap(); - - let mut frames = Vec::new(); - while let Some(l) = out.next_line().await.unwrap() { - frames.push(serde_json::from_str(&l).unwrap()); - } - handle.await.unwrap().unwrap(); - frames -} - pub async fn request(frame: Value) -> Vec { roundtrip(&frame.to_string()).await } @@ -127,3 +111,19 @@ pub fn data_frames(bytes: &[u8]) -> Vec { lines.push(r#"{"op":"data_end"}"#.to_string()); lines } + +async fn request_on(state: &Arc, lines: &[String]) -> Vec { + let (mut cw, mut out, handle) = connect(state); + for line in lines { + cw.write_all(line.as_bytes()).await.unwrap(); + cw.write_all(b"\n").await.unwrap(); + } + cw.shutdown().await.unwrap(); + + let mut frames = Vec::new(); + while let Some(l) = out.next_line().await.unwrap() { + frames.push(serde_json::from_str(&l).unwrap()); + } + handle.await.unwrap().unwrap(); + frames +} diff --git a/silkd/tests/forward_e2e.rs b/silkd/tests/forward_e2e.rs index 1d3366823..5131b4191 100644 --- a/silkd/tests/forward_e2e.rs +++ b/silkd/tests/forward_e2e.rs @@ -46,26 +46,6 @@ async fn forwarded(state: &Arc, port: u16) -> (FrameWriter, FrameLines) { (cw, lines) } -#[tokio::test] -async fn forward_round_trips_and_done_on_server_close() { - timeout(DEADLINE, async { - let port = echo_listener().await; - let state = Arc::new(State::new()); - let (mut cw, mut lines) = forwarded(&state, port).await; - - send(&mut cw, json!({"v":1,"op":"data","data":b64(b"hi")})).await; - let echoed = next_frame(&mut lines).await; - assert_eq!(type_of(&echoed), "data", "got {echoed}"); - assert_eq!(decode(&echoed), b"hi"); - - send(&mut cw, json!({"v":1,"op":"data_end"})).await; - let done = next_frame(&mut lines).await; - assert_eq!(type_of(&done), "done", "got {done}"); - }) - .await - .expect("test deadline"); -} - async fn request_listener(request: usize) -> u16 { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -97,6 +77,26 @@ async fn half_close_listener() -> (u16, oneshot::Receiver>) { (port, rx) } +#[tokio::test] +async fn forward_round_trips_and_done_on_server_close() { + timeout(DEADLINE, async { + let port = echo_listener().await; + let state = Arc::new(State::new()); + let (mut cw, mut lines) = forwarded(&state, port).await; + + send(&mut cw, json!({"v":1,"op":"data","data":b64(b"hi")})).await; + let echoed = next_frame(&mut lines).await; + assert_eq!(type_of(&echoed), "data", "got {echoed}"); + assert_eq!(decode(&echoed), b"hi"); + + send(&mut cw, json!({"v":1,"op":"data_end"})).await; + let done = next_frame(&mut lines).await; + assert_eq!(type_of(&done), "done", "got {done}"); + }) + .await + .expect("test deadline"); +} + #[tokio::test] async fn forward_accepts_client_writes_after_the_guest_half_closes() { timeout(DEADLINE, async { From 61f10fe9781d86a679cef035bf7fd3b432e7af49 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:11:39 +0800 Subject: [PATCH 07/70] review: silkd comments shrink to what the code cannot carry Module narratives go to one line, the imds token fact moves to its const, a history comment goes, make_controlling_tty becomes safe because it has no memory precondition, and find's at_root stops shadowing the root param. --- boot/init/src/cfg.rs | 1 - silkd/src/find.rs | 10 +++++----- silkd/src/imds.rs | 2 +- silkd/src/lsp.rs | 4 +--- silkd/src/proto.rs | 6 +----- silkd/src/sysutil.rs | 10 +++------- silkd/src/tree.rs | 4 +--- 7 files changed, 12 insertions(+), 25 deletions(-) diff --git a/boot/init/src/cfg.rs b/boot/init/src/cfg.rs index 8547935a0..6e3220582 100644 --- a/boot/init/src/cfg.rs +++ b/boot/init/src/cfg.rs @@ -55,7 +55,6 @@ pub fn parse(cmdline: &str) -> Result, String> { cfg.layers = val.split(',').filter(|s| !s.is_empty()).collect(); } "cocoon.cow" => cfg.cow = val, - // a junk value keeps the default, matching the old initramfs hook. "cocoon.timeout" => { if let Ok(secs) = val.parse::() { cfg.timeout = Duration::from_secs(secs.min(MAX_TIMEOUT_SECS)); diff --git a/silkd/src/find.rs b/silkd/src/find.rs index b0c596013..d2839e05f 100644 --- a/silkd/src/find.rs +++ b/silkd/src/find.rs @@ -67,15 +67,15 @@ struct Walk<'a> { } impl Walk<'_> { - /// Walks `root` depth-first per matching line; a failure below the root skips only that directory. + /// Walks `root` depth-first; a failure below the root skips only that directory. fn run(&self, root: PathBuf) -> std::io::Result { let mut stack = vec![root]; - let mut root = true; + let mut at_root = true; let mut body = String::new(); while let Some(dir) = stack.pop() { let rd = match std::fs::read_dir(&dir) { Ok(rd) => rd, - Err(e) if root => return Err(e), + Err(e) if at_root => return Err(e), Err(_) => continue, }; for ent in rd { @@ -84,7 +84,7 @@ impl Walk<'_> { } let ent = match ent { Ok(ent) => ent, - Err(e) if root => return Err(e), + Err(e) if at_root => return Err(e), Err(_) => break, }; let Ok(ft) = ent.file_type() else { @@ -100,7 +100,7 @@ impl Walk<'_> { return Ok(false); } } - root = false; + at_root = false; } Ok(true) } diff --git a/silkd/src/imds.rs b/silkd/src/imds.rs index 7caef247e..b2b5d5524 100644 --- a/silkd/src/imds.rs +++ b/silkd/src/imds.rs @@ -1,5 +1,4 @@ //! The instance-metadata document guest agents read, served in the IMDSv2 shape on 169.254.169.254 where an image aliases it. -//! The token is fixed: the IMDSv2 defense is the PUT plus the custom header, not the token's secrecy. use std::io; use std::path::Path; @@ -12,6 +11,7 @@ const ADDR: &str = "169.254.169.254:80"; /// The document the host writes per sandbox through `fs_write`. const DOC_PATH: &str = "/run/silkd/instance-metadata.json"; +/// Fixed: the IMDSv2 defense is the PUT plus the custom header, not the token's secrecy. const TOKEN: &[u8] = b"silkd-instance-metadata"; const HEAD_MAX: usize = 8 * 1024; const CONN_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 1a677ff1f..6f7193e6d 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -1,6 +1,4 @@ -//! LSP broker: silkd spawns the language server named by -//! `/etc/silkd/lsp.d/` and pipes JSON-RPC bytes, never parsing LSP -//! semantics. A session is single-shot: the stream ending reaps the server. +//! LSP broker: spawns the server `/etc/silkd/lsp.d/` names and pipes JSON-RPC bytes; the stream ending reaps it. use std::borrow::Cow; use std::collections::HashMap; diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index 147c596ef..d7ac46798 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -1,8 +1,4 @@ -//! Wire protocol: newline-delimited JSON frames, RPCs back to back on one -//! connection. A request frame opens an RPC; stdin/data frames from the -//! client carry its input. Server frames stream until a terminal one -//! (exit / done / error). Binary payloads ride as base64 (`data` fields), -//! matching Go's default []byte JSON encoding for the SDK side. +//! Wire protocol: newline-delimited JSON frames, RPCs back to back on one connection, binary payloads as base64. use std::borrow::Cow; use std::collections::HashMap; diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index 85a7c2e59..c7e56ad6d 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -176,7 +176,7 @@ pub fn set_winsize(fd: RawFd, cols: u16, rows: u16) -> std::io::Result<()> { pub fn adopt_controlling_tty(cmd: &mut Command) { // SAFETY: make_controlling_tty runs only async-signal-safe syscalls, the contract for a post-fork pre_exec hook. unsafe { - cmd.pre_exec(|| make_controlling_tty()); + cmd.pre_exec(make_controlling_tty); } } @@ -200,12 +200,8 @@ pub async fn wait_code(child: &mut tokio::process::Child) -> i32 { } /// Makes the calling process a session leader and adopts fd 0 as its controlling terminal. -/// -/// # Safety -/// Only async-signal-safe syscalls; valid in a post-fork child. -unsafe fn make_controlling_tty() -> std::io::Result<()> { - // SAFETY: setsid and ioctl are async-signal-safe and take no pointers; the - // caller guarantees a post-fork child (see # Safety). +fn make_controlling_tty() -> std::io::Result<()> { + // SAFETY: setsid and ioctl take no pointers, so no memory precondition applies. unsafe { if libc::setsid() < 0 { return Err(std::io::Error::last_os_error()); diff --git a/silkd/src/tree.rs b/silkd/src/tree.rs index 8b7989392..3b7811c4c 100644 --- a/silkd/src/tree.rs +++ b/silkd/src/tree.rs @@ -1,6 +1,4 @@ -//! Whole-tree transfer through the guest tar binary: `fs.push` extracts a client -//! tar stream into a directory, `fs.pull` streams a path back as a tar. Push is -//! network-failure atomic: it stages inside `dest`, then merges by rename. +//! Whole-tree transfer through the guest tar; `fs.push` stages inside `dest` and merges by rename, so a stream failure leaves `dest` unchanged. use std::io; use std::os::fd::OwnedFd; From 67379827969a1ecc8e6cf3ca391077b1a6dba2e2 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:11:39 +0800 Subject: [PATCH 08/70] review: silkd tests use the shared request and assert_error helpers --- silkd/tests/forward_e2e.rs | 8 +++++--- silkd/tests/fs_e2e.rs | 12 ++++-------- silkd/tests/git_e2e.rs | 17 ++++++----------- silkd/tests/keepalive_e2e.rs | 7 ++++--- silkd/tests/lsp_e2e.rs | 4 +--- silkd/tests/session_e2e.rs | 6 ++---- silkd/tests/tree_e2e.rs | 14 ++------------ silkd/tests/watch_e2e.rs | 5 ++--- 8 files changed, 26 insertions(+), 47 deletions(-) diff --git a/silkd/tests/forward_e2e.rs b/silkd/tests/forward_e2e.rs index 5131b4191..b6b3fd716 100644 --- a/silkd/tests/forward_e2e.rs +++ b/silkd/tests/forward_e2e.rs @@ -13,7 +13,10 @@ use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio::time::timeout; -use common::{DEADLINE, FrameLines, FrameWriter, b64, connect, decode, next_frame, send, type_of}; +use common::{ + DEADLINE, FrameLines, FrameWriter, assert_error, b64, connect, decode, next_frame, send, + type_of, +}; async fn echo_listener() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); @@ -168,8 +171,7 @@ async fn forward_refused_port_is_not_found() { send(&mut cw, json!({"v":1,"op":"port_forward","port":1})).await; let err = next_frame(&mut lines).await; - assert_eq!(type_of(&err), "error", "got {err}"); - assert_eq!(err["kind"], "not_found", "got {err}"); + assert_error(&err, "not_found"); }) .await .expect("test deadline"); diff --git a/silkd/tests/fs_e2e.rs b/silkd/tests/fs_e2e.rs index 4987d3484..6fde8be13 100644 --- a/silkd/tests/fs_e2e.rs +++ b/silkd/tests/fs_e2e.rs @@ -124,18 +124,14 @@ async fn mkdir_rename_rm_lifecycle() { let mk = request(json!({"op":"fs_mkdir","path":sub.to_str().unwrap()})).await; assert_eq!(type_of(&mk[0]), "done"); - let mv = exchange(&[ - json!({"op":"fs_rename","from":sub.to_str().unwrap(),"to":moved.to_str().unwrap()}) - .to_string(), - ]) + let mv = request( + json!({"op":"fs_rename","from":sub.to_str().unwrap(),"to":moved.to_str().unwrap()}), + ) .await; assert_eq!(type_of(&mv[0]), "done"); assert!(moved.exists() && !sub.exists()); - let rm = exchange(&[ - json!({"op":"fs_rm","path":moved.to_str().unwrap(),"recursive":true}).to_string(), - ]) - .await; + let rm = request(json!({"op":"fs_rm","path":moved.to_str().unwrap(),"recursive":true})).await; assert_eq!(type_of(&rm[0]), "done"); assert!(!moved.exists()); } diff --git a/silkd/tests/git_e2e.rs b/silkd/tests/git_e2e.rs index b3da7af4b..7fc03ee13 100644 --- a/silkd/tests/git_e2e.rs +++ b/silkd/tests/git_e2e.rs @@ -5,7 +5,7 @@ mod common; use serde_json::json; -use common::{assert_error, exchange, request, type_of}; +use common::{assert_error, request, type_of}; fn git(dir: &std::path::Path, args: &[&str]) -> String { let out = std::process::Command::new("git") @@ -84,10 +84,8 @@ async fn branch_create_list_checkout() { git(repo.path(), &["add", "f"]); git(repo.path(), &["commit", "-qm", "c"]); - let created = exchange(&[ - json!({"op":"git_branch","path":p,"action":"create","name":"feature"}).to_string(), - ]) - .await; + let created = + request(json!({"op":"git_branch","path":p,"action":"create","name":"feature"})).await; assert_eq!(type_of(created.last().unwrap()), "done"); let list = request(json!({"op":"git_branch","path":p,"action":"list"})).await; @@ -101,10 +99,8 @@ async fn branch_create_list_checkout() { .collect(); assert!(branches.contains(&"main") && branches.contains(&"feature")); - let co = exchange(&[ - json!({"op":"git_branch","path":p,"action":"checkout","name":"feature"}).to_string(), - ]) - .await; + let co = + request(json!({"op":"git_branch","path":p,"action":"checkout","name":"feature"})).await; assert_eq!(type_of(co.last().unwrap()), "done"); } @@ -134,8 +130,7 @@ async fn commit_failure_surfaces_git_error() { "op":"git_commit","path":p,"message":"empty","author":"Dev " })) .await; - assert_eq!(type_of(&frames[0]), "error"); - assert_eq!(frames[0]["kind"], "internal", "{frames:?}"); + assert_error(&frames[0], "internal"); } #[tokio::test] diff --git a/silkd/tests/keepalive_e2e.rs b/silkd/tests/keepalive_e2e.rs index a0e41b4a7..a9fb69890 100644 --- a/silkd/tests/keepalive_e2e.rs +++ b/silkd/tests/keepalive_e2e.rs @@ -10,7 +10,9 @@ use silkd::server::State; use tokio::io::AsyncWriteExt; use tokio::time::timeout; -use common::{DEADLINE, FrameLines, b64, connect, frames_until, next_frame, send, type_of}; +use common::{ + DEADLINE, FrameLines, assert_error, b64, connect, frames_until, next_frame, send, type_of, +}; async fn types_until(lines: &mut FrameLines, terminal: &str) -> Vec { frames_until(lines, |f| type_of(f) == terminal) @@ -125,8 +127,7 @@ async fn a_leading_input_frame_is_bad_request_and_the_connection_stays() { send(&mut cw, json!({"v":1,"op":"stdin_close"})).await; let err = next_frame(&mut lines).await; - assert_eq!(type_of(&err), "error", "got {err}"); - assert_eq!(err["kind"], "bad_request", "got {err}"); + assert_error(&err, "bad_request"); send(&mut cw, json!({"v":1,"op":"info"})).await; assert_eq!(type_of(&next_frame(&mut lines).await), "info"); diff --git a/silkd/tests/lsp_e2e.rs b/silkd/tests/lsp_e2e.rs index 7ed05a097..6055cca09 100644 --- a/silkd/tests/lsp_e2e.rs +++ b/silkd/tests/lsp_e2e.rs @@ -3,7 +3,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] mod common; -use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; @@ -25,8 +24,7 @@ static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); fn manifest_env(server_body: &str) -> TempDir { let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("fake-lsp"); - let mut f = std::fs::File::create(&bin).unwrap(); - f.write_all(server_body.as_bytes()).unwrap(); + std::fs::write(&bin, server_body).unwrap(); std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); // SAFETY: ENV_LOCK, held by the caller for its whole test, serializes all access to SILKD_LSP_DIR. unsafe { std::env::set_var("SILKD_LSP_DIR", dir.path()) }; diff --git a/silkd/tests/session_e2e.rs b/silkd/tests/session_e2e.rs index bc1563649..1e73f40f5 100644 --- a/silkd/tests/session_e2e.rs +++ b/silkd/tests/session_e2e.rs @@ -83,8 +83,7 @@ async fn session_create_rejects_an_env_name_bash_cannot_export() { ) .await .expect("session_create hung on a hostile env key"); - assert_eq!(type_of(&f[0]), "error", "{f:?}"); - assert_eq!(f[0]["kind"], "bad_request", "{f:?}"); + assert_error(&f[0], "bad_request"); assert!( state.sessions.list().is_empty(), "{:?}", @@ -162,8 +161,7 @@ async fn shell_killing_command_removes_the_session() { let id = create(&state).await; let _ = sh(&state, &id, &["exit"]).await; let again = sh(&state, &id, &["echo", "hi"]).await; - assert_eq!(type_of(&again[0]), "error"); - assert_eq!(again[0]["kind"], "not_found", "dead session should be gone"); + assert_error(&again[0], "not_found"); } #[tokio::test] diff --git a/silkd/tests/tree_e2e.rs b/silkd/tests/tree_e2e.rs index f094aaca6..41ae0067d 100644 --- a/silkd/tests/tree_e2e.rs +++ b/silkd/tests/tree_e2e.rs @@ -157,23 +157,13 @@ async fn pull_missing_path_errors() { let dir = tempfile::tempdir().unwrap(); let missing = dir.path().join("xyz"); let frames = request(json!({"op":"fs_pull","path":missing.to_str().unwrap()})).await; - assert_eq!( - type_of(frames.last().unwrap()), - "error", - "missing leaf: {frames:?}" - ); - assert_eq!(frames.last().unwrap()["kind"], "not_found"); + assert_error(frames.last().unwrap(), "not_found"); } #[tokio::test] async fn pull_missing_parent_errors_not_found() { let frames = request(json!({"op":"fs_pull","path":"/no/such/dir/xyz"})).await; - assert_eq!( - type_of(frames.last().unwrap()), - "error", - "missing parent: {frames:?}" - ); - assert_eq!(frames.last().unwrap()["kind"], "not_found"); + assert_error(frames.last().unwrap(), "not_found"); } #[tokio::test] diff --git a/silkd/tests/watch_e2e.rs b/silkd/tests/watch_e2e.rs index baf9067ba..b1266a3d5 100644 --- a/silkd/tests/watch_e2e.rs +++ b/silkd/tests/watch_e2e.rs @@ -9,7 +9,7 @@ use std::time::Duration; use serde_json::json; use silkd::server::State; -use common::{connect, next_frame, request, send, type_of}; +use common::{assert_error, connect, next_frame, request, send, type_of}; #[tokio::test] async fn watch_streams_events_until_disconnect() { @@ -46,6 +46,5 @@ async fn watch_streams_events_until_disconnect() { #[tokio::test] async fn watch_missing_path_is_error() { let frames = request(json!({"op": "fs_watch", "path": "/no/such/dir/silkd-watch"})).await; - assert_eq!(type_of(&frames[0]), "error"); - assert_eq!(frames[0]["kind"], "not_found", "{frames:?}"); + assert_error(&frames[0], "not_found"); } From 2a3d04f24405ccdda8f69bded3e64f89b533cd46 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:00:45 +0800 Subject: [PATCH 09/70] sdk/python: ship the PEP 561 py.typed marker The package is fully annotated and mypy-strict, but the wheel carried no marker, so a consumer's type checker treated every cocoonsandbox import as untyped (reveal_type gave Any). Add the marker and the setuptools package-data entry; a scratch wheel now lists cocoonsandbox/py.typed and mypy on a consumer reveals cocoonsandbox.sandbox.Sandbox. --- sdk/python/cocoonsandbox/py.typed | 0 sdk/python/pyproject.toml | 3 +++ sdk/python/tests/test_packaging.py | 9 +++++++++ 3 files changed, 12 insertions(+) create mode 100644 sdk/python/cocoonsandbox/py.typed create mode 100644 sdk/python/tests/test_packaging.py diff --git a/sdk/python/cocoonsandbox/py.typed b/sdk/python/cocoonsandbox/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 52bf623e1..5e2eb3260 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -13,6 +13,9 @@ readme = "README.md" [tool.setuptools.packages.find] include = ["cocoonsandbox*"] +[tool.setuptools.package-data] +cocoonsandbox = ["py.typed"] + [tool.pytest.ini_options] pythonpath = ["."] diff --git a/sdk/python/tests/test_packaging.py b/sdk/python/tests/test_packaging.py new file mode 100644 index 000000000..07912b639 --- /dev/null +++ b/sdk/python/tests/test_packaging.py @@ -0,0 +1,9 @@ +"""The package carries the PEP 561 marker so consumers' type checkers read its annotations.""" + +import pathlib + +import cocoonsandbox + + +def test_package_ships_the_pep561_marker(): + assert (pathlib.Path(cocoonsandbox.__file__).parent / "py.typed").is_file() From 9349206f3f25dedd5249c9d55a87fca651dccf01 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:01:14 +0800 Subject: [PATCH 10/70] review: one-line docstrings where several lines restated the signature or sibling docs --- sdk/langchain/tests/test_toolkit.py | 3 +-- sdk/openai/cocoonsandbox_openai/__init__.py | 1 - sdk/openai/cocoonsandbox_openai/adapter.py | 5 +---- sdk/openai/tests/test_adapter.py | 5 +---- sdk/python/cocoonsandbox/errors.py | 3 +-- sdk/python/cocoonsandbox/template.py | 3 +-- sdk/python/tests/test_checkpoint.py | 3 +-- sdk/python/tests/test_fault_matrix.py | 6 +----- sdk/python/tests/test_frames.py | 4 +--- sdk/python/tests/test_proxy.py | 4 +--- sdk/python/tests/test_wire_binding.py | 14 ++------------ 11 files changed, 11 insertions(+), 40 deletions(-) diff --git a/sdk/langchain/tests/test_toolkit.py b/sdk/langchain/tests/test_toolkit.py index 04480b18f..12b8b8766 100644 --- a/sdk/langchain/tests/test_toolkit.py +++ b/sdk/langchain/tests/test_toolkit.py @@ -1,5 +1,4 @@ -"""Toolkit wiring against a fake sandbox: tool names/schemas, exec output -shaping, lazy claim, and double-close safety — no real node.""" +"""Toolkit wiring against a fake sandbox, with no real node.""" import asyncio import socket diff --git a/sdk/openai/cocoonsandbox_openai/__init__.py b/sdk/openai/cocoonsandbox_openai/__init__.py index 9911c959e..a39bf6199 100644 --- a/sdk/openai/cocoonsandbox_openai/__init__.py +++ b/sdk/openai/cocoonsandbox_openai/__init__.py @@ -1,6 +1,5 @@ """OpenAI Agents SDK sandbox provider for cocoon microVMs. -from agents.sandbox import SandboxRunConfig # SDK side from cocoonsandbox_openai import CocoonSandboxClient, CocoonSandboxClientOptions client = CocoonSandboxClient() diff --git a/sdk/openai/cocoonsandbox_openai/adapter.py b/sdk/openai/cocoonsandbox_openai/adapter.py index 2e82b321e..a3f50ef94 100644 --- a/sdk/openai/cocoonsandbox_openai/adapter.py +++ b/sdk/openai/cocoonsandbox_openai/adapter.py @@ -1,7 +1,4 @@ -"""OpenAI Agents SDK sandbox provider backed by cocoon microVMs: implements -the SDK's BaseSandboxClient/BaseSandboxSession pair on top of the sync -cocoonsandbox SDK, bridged with asyncio.to_thread. One session is one -claimed sandbox; delete releases it, resume reattaches by id + token.""" +"""OpenAI Agents SDK sandbox provider backed by cocoon microVMs.""" from __future__ import annotations diff --git a/sdk/openai/tests/test_adapter.py b/sdk/openai/tests/test_adapter.py index 6c4cb9c8a..2939cdeba 100644 --- a/sdk/openai/tests/test_adapter.py +++ b/sdk/openai/tests/test_adapter.py @@ -1,7 +1,4 @@ -"""Adapter unit tests: exercise the BaseSandboxSession hooks against an -in-process fake sandboxd (control plane over http.server, data plane over -the SDK's own conn). No real VM — this binds the adapter's mapping onto the -cocoonsandbox surface, not cocoon itself.""" +"""Adapter hooks against an in-process fake sandboxd, with no real VM.""" import asyncio import io diff --git a/sdk/python/cocoonsandbox/errors.py b/sdk/python/cocoonsandbox/errors.py index 4e1ed4dcc..7daf97da4 100644 --- a/sdk/python/cocoonsandbox/errors.py +++ b/sdk/python/cocoonsandbox/errors.py @@ -45,8 +45,7 @@ def __init__(self, kind: str, message: str) -> None: class ExitError(SandboxError): - """A command exited non-zero; carries the exit code, stderr, and whatever - stdout it had produced — a failing build's log is on stdout.""" + """A command exited non-zero; carries the code, stderr, and the stdout produced before it failed.""" def __init__(self, code: int, stderr: str, stdout: str = "") -> None: super().__init__(f"exit status {code}: {stderr.strip()}") diff --git a/sdk/python/cocoonsandbox/template.py b/sdk/python/cocoonsandbox/template.py index 94db0da64..205e57631 100644 --- a/sdk/python/cocoonsandbox/template.py +++ b/sdk/python/cocoonsandbox/template.py @@ -31,8 +31,7 @@ def new( *, deadline: float | None = None, ) -> Sandbox: - """Claims the template, following placement when volumes require it. - mount=False attaches the volumes without mounting them.""" + """Claims the template, following placement when volumes require it; mount=False attaches them unmounted.""" claim = _claim_body(self.name, self.net, self.size, ttl_seconds, volumes, mount) claim["require_promoted"] = True if volumes: diff --git a/sdk/python/tests/test_checkpoint.py b/sdk/python/tests/test_checkpoint.py index 0a0bb7b8a..5792db126 100644 --- a/sdk/python/tests/test_checkpoint.py +++ b/sdk/python/tests/test_checkpoint.py @@ -1,5 +1,4 @@ -"""Checkpoint claim redirect: mirrors the cluster redirect follow tested for -Client.new in test_fault_matrix.py, applied to Checkpoint.new.""" +"""Checkpoint claim redirect, mirroring the Client.new redirect in test_fault_matrix.py.""" import pytest diff --git a/sdk/python/tests/test_fault_matrix.py b/sdk/python/tests/test_fault_matrix.py index cd0c56f5c..aa1c524a0 100644 --- a/sdk/python/tests/test_fault_matrix.py +++ b/sdk/python/tests/test_fault_matrix.py @@ -1,8 +1,4 @@ -"""Fault matrix for the cluster redirect follow and the lookup scatter: -dead first candidate (connection refused), 404 first candidate, a definitive -candidate error that still tries the next one, every candidate exhausted -(then the origin fallback heals, fails, or is skipped for a definitive -error) — and lookup must not pay a hung or dead peer's full timeout.""" +"""Fault matrix for the cluster redirect follow and the lookup scatter.""" import socket import time diff --git a/sdk/python/tests/test_frames.py b/sdk/python/tests/test_frames.py index a35db0b7a..29a93f75c 100644 --- a/sdk/python/tests/test_frames.py +++ b/sdk/python/tests/test_frames.py @@ -1,6 +1,4 @@ -"""Guards the fast bulk slicer in frames.decode_response: a "data" match -inside a nested unknown field or trailing bytes after the frame must never -yield a silently wrong payload — unexpected shapes take the full parse.""" +"""Guards the fast bulk slicer in decode_response: unexpected shapes take the full parse.""" import base64 import json diff --git a/sdk/python/tests/test_proxy.py b/sdk/python/tests/test_proxy.py index 6d52a2119..7f0c27dbe 100644 --- a/sdk/python/tests/test_proxy.py +++ b/sdk/python/tests/test_proxy.py @@ -1,6 +1,4 @@ -"""proxy_port end-to-end with a fake guest PortConn: catches the class of bug -where proxy_port references a helper that does not exist — a static import -can't see it, only running the accept loop does.""" +"""proxy_port end to end with a fake guest PortConn.""" import socket import threading diff --git a/sdk/python/tests/test_wire_binding.py b/sdk/python/tests/test_wire_binding.py index f6cecac49..6d0e05bf1 100644 --- a/sdk/python/tests/test_wire_binding.py +++ b/sdk/python/tests/test_wire_binding.py @@ -1,13 +1,4 @@ -"""Binds the SDK's real call sites to the golden corpus: each case drives an -actual Sandbox method with the fixture's own field values through a real -Conn over a socketpair, then asserts the emitted frame carries exactly the -fixture's fields with the same values — both directions, so a misspelled -field name AND a silently dropped field fail here; the generic encode -round-trip alone cannot see either. - -Each CASES entry is (fixture stem, scripted guest replies, invoke(sb, fixture)); -UNSENT lists the fields no SDK call site emits by design, which silkd defaults -and the Go SDK omits too.""" +"""Drives each real Sandbox call site and asserts its frame carries exactly the golden fixture's fields.""" import contextlib import json @@ -161,8 +152,7 @@ def close(self): def fake_sandbox(monkeypatch, replies): - """A Sandbox whose _dial yields a real Conn over a socketpair; a guest - thread records inbound frames and answers the scripted replies.""" + """A Sandbox whose _dial yields a real Conn over a socketpair to a scripted guest thread.""" sent = [] client_sock, guest_sock = socket.socketpair() From 2f8d047471865f6a99ea693f1debdb2ac4c9350a Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:02:15 +0800 Subject: [PATCH 11/70] review: drop duplicate test fixtures and a subset test; share FakeConn through conftest --- sdk/python/tests/conftest.py | 30 +++++++++++++++++++++++++ sdk/python/tests/test_client.py | 14 ------------ sdk/python/tests/test_fault_matrix.py | 14 ++---------- sdk/python/tests/test_proc.py | 32 +-------------------------- sdk/python/tests/test_wire_binding.py | 14 +++--------- 5 files changed, 36 insertions(+), 68 deletions(-) diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index 171fca23d..65fdaf984 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -56,6 +56,36 @@ def _reply(self, code, payload): self.wfile.write(raw) +class FakeConn: + """Answers one RPC with a canned frame list.""" + + def __init__(self, frames): + self._frames = list(frames) + self.sent = [] + + def __enter__(self): + return self + + def __exit__(self, *exc): + pass + + def send(self, op, **fields): + self.sent.append((op, fields)) + + def recv(self): + return self._frames.pop(0) + + def recv_until(self, *terminal): + while True: + frame = self.recv() + yield frame + if frame["type"] in terminal: + return + + def close(self): + pass + + def sandbox_at(addr: str, **client_kwargs) -> Sandbox: return Sandbox(client=Client(addr, **client_kwargs), id="sb_1", token="tok", owner=addr) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 2379ae0d9..4d6c00012 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -198,20 +198,6 @@ def claim(body, path): assert sb.volumes == seen[1]["volumes"] -def test_volume_catalog(node): - want = [ - { - "name": "imagenet", - "default_mount": "/volumes/imagenet", - "size_bytes": 42, - "available": True, - "nodes": 3, - } - ] - FakeNode.routes[("GET", "/v1/volumes")] = lambda body, path: (200, {"volumes": want}) - assert Client(node).volumes() == want - - def test_volume_catalog_surfaces_writable(node): want = [ { diff --git a/sdk/python/tests/test_fault_matrix.py b/sdk/python/tests/test_fault_matrix.py index aa1c524a0..cb1875614 100644 --- a/sdk/python/tests/test_fault_matrix.py +++ b/sdk/python/tests/test_fault_matrix.py @@ -1,6 +1,5 @@ """Fault matrix for the cluster redirect follow and the lookup scatter.""" -import socket import time import pytest @@ -8,15 +7,6 @@ from cocoonsandbox import APIError, Client -@pytest.fixture -def hung_addr(): - sock = socket.socket() - sock.bind(("127.0.0.1", 0)) - sock.listen(1) - yield f"127.0.0.1:{sock.getsockname()[1]}" - sock.close() - - def test_claim_redirect_skips_dead_candidate(spawn_node, dead_addr): good = spawn_node({("POST", "/v1/claim"): lambda body, path: (200, {"id": "sb_ok", "token": "t"})}) entry = spawn_node({("POST", "/v1/claim"): lambda body, path: (200, {"redirect": [dead_addr, good]})}) @@ -180,12 +170,12 @@ def test_lookup_dead_peer_resolves_fast(spawn_node, dead_addr): assert elapsed < 2.0, elapsed -def test_lookup_hung_peer_resolves_fast(spawn_node, hung_addr): +def test_lookup_hung_peer_resolves_fast(spawn_node, black_hole): owner = spawn_node({("GET", "/v1/sandboxes/sb_1/owner"): lambda body, path: (200, {"owner_addr": "10.0.0.9:7777"})}) entry = spawn_node( { ("GET", "/v1/sandboxes/sb_1/owner"): lambda body, path: (404, {"error": "not here"}), - ("GET", "/v1/peers"): lambda body, path: (200, {"peers": [hung_addr, owner]}), + ("GET", "/v1/peers"): lambda body, path: (200, {"peers": [black_hole, owner]}), } ) start = time.monotonic() diff --git a/sdk/python/tests/test_proc.py b/sdk/python/tests/test_proc.py index 53585f0f3..e1b6327d0 100644 --- a/sdk/python/tests/test_proc.py +++ b/sdk/python/tests/test_proc.py @@ -3,7 +3,7 @@ import threading import pytest -from conftest import sandbox_at +from conftest import FakeConn, sandbox_at from cocoonsandbox import ProtocolError from cocoonsandbox.frames import FS_CHUNK @@ -75,36 +75,6 @@ def test_a_text_stdin_is_refused_before_dialing(monkeypatch, call): assert dials == [] -class FakeConn: - """Answers one RPC with a canned frame list.""" - - def __init__(self, frames): - self._frames = list(frames) - self.sent = [] - - def __enter__(self): - return self - - def __exit__(self, *exc): - pass - - def send(self, op, **fields): - self.sent.append((op, fields)) - - def recv(self): - return self._frames.pop(0) - - def recv_until(self, *terminal): - while True: - frame = self.recv() - yield frame - if frame["type"] in terminal: - return - - def close(self): - pass - - class BlockingStdinConn(FakeConn): """A guest that stops draining stdin until its output is read, so send blocks past the buffer.""" diff --git a/sdk/python/tests/test_wire_binding.py b/sdk/python/tests/test_wire_binding.py index 6d0e05bf1..226317c2c 100644 --- a/sdk/python/tests/test_wire_binding.py +++ b/sdk/python/tests/test_wire_binding.py @@ -7,7 +7,7 @@ import threading import pytest -from conftest import sandbox_at +from conftest import FakeConn, sandbox_at from cocoonsandbox import Lsp, Pty, Session from cocoonsandbox.conn import Conn @@ -122,19 +122,14 @@ def test_git_branch_actions_come_from_the_corpus(monkeypatch): assert set(sent) == set(enums["git_branch_action"]) -class BranchActionConn: +class BranchActionConn(FakeConn): """Records the action each git_branch verb puts on the wire.""" def __init__(self, sent): + super().__init__([]) self.sent = sent self.action = "" - def __enter__(self): - return self - - def __exit__(self, *exc): - pass - def send(self, op, **fields): self.action = fields.get("action") self.sent.append(self.action) @@ -147,9 +142,6 @@ def recv(self): def recv_until(self, *terminal): yield self.recv() - def close(self): - pass - def fake_sandbox(monkeypatch, replies): """A Sandbox whose _dial yields a real Conn over a socketpair to a scripted guest thread.""" From beefef6484a8f6232607481c04527a285d0c9862 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:02:48 +0800 Subject: [PATCH 12/70] review: tests before helpers in test_stream and test_client --- sdk/python/tests/test_client.py | 22 ++++---- sdk/python/tests/test_stream.py | 98 ++++++++++++++++----------------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 4d6c00012..f05fa03c5 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -340,17 +340,6 @@ def test_claim_deadline_bounds_the_entry_node(black_hole): assert elapsed < 1.5, elapsed -def recording_claim(reply): - seen = [] - - def claim(body, path): - seen.append(body) - return 200, reply - - FakeNode.routes[("POST", "/v1/claim")] = claim - return seen - - @pytest.mark.parametrize(("keep_alive", "conns"), [(30.0, 1), (0, 2)]) def test_control_calls_reuse_one_connection(kept_node, keep_alive, conns, fd_floor): KeptNode.routes[("POST", "/v1/claim")] = lambda body, path: (200, {"id": "sb_1", "token": "tok"}) @@ -377,3 +366,14 @@ def kept_node(): threading.Thread(target=server.serve_forever, daemon=True).start() yield f"127.0.0.1:{server.server_port}" server.shutdown() + + +def recording_claim(reply): + seen = [] + + def claim(body, path): + seen.append(body) + return 200, reply + + FakeNode.routes[("POST", "/v1/claim")] = claim + return seen diff --git a/sdk/python/tests/test_stream.py b/sdk/python/tests/test_stream.py index 8b723009e..55208fd2d 100644 --- a/sdk/python/tests/test_stream.py +++ b/sdk/python/tests/test_stream.py @@ -16,55 +16,6 @@ TIMEOUT = 0.2 -def legacy_sandbox(addr: str) -> Sandbox: - return sandbox_at(addr, timeout=TIMEOUT, keep_alive=0) - - -def serve_port_forward(server: socket.socket, quiet: float, ops: list[str]) -> None: - conn, _ = server.accept() - reader = accept_upgrade(conn) - ops.append(json.loads(reader.readline())["op"]) - conn.sendall(b'{"type":"ready"}\n') - time.sleep(quiet) - conn.sendall(b'{"type":"data","data":"bGF0ZQ=="}\n{"type":"done"}\n') - conn.close() - - -def serve_started_then_hang(server: socket.socket, quiet: float, kills: list[dict]) -> None: - conn, _ = server.accept() - reader = accept_upgrade(conn) - reader.readline() - conn.sendall(b'{"type":"started","pid":7}\n') - killer, _ = server.accept() - kills.append(json.loads(accept_upgrade(killer).readline())) - killer.sendall(b'{"type":"done"}\n') - killer.close() - time.sleep(quiet) - conn.close() - - -def serve_started_then_trickle_the_kill_reply(server: socket.socket, kills: list[dict]) -> None: - conn, _ = server.accept() - reader = accept_upgrade(conn) - reader.readline() - conn.sendall(b'{"type":"started","pid":7}\n') - killer, _ = server.accept() - kills.append(json.loads(accept_upgrade(killer).readline())) - with contextlib.suppress(OSError): - for byte in b'{"type":"done"}\n': - time.sleep(0.75 * TIMEOUT) - killer.sendall(bytes([byte])) - killer.close() - conn.close() - - -def serve_silence(server: socket.socket) -> None: - conn, _ = server.accept() - conn.recv(4096) - time.sleep(5 * TIMEOUT) - conn.close() - - def test_port_stream_outlives_the_client_timeout(): server = socket.create_server(("127.0.0.1", 0)) addr = f"127.0.0.1:{server.getsockname()[1]}" @@ -144,3 +95,52 @@ def test_dial_is_still_bounded_by_the_client_timeout(): @pytest.mark.parametrize(("exc", "want"), [(socket.timeout(), SandboxTimeout), (ConnectionResetError(), ProtocolError)]) def test_handshake_failures_are_typed(exc, want): assert isinstance(conn_module._handshake_error("agent upgrade", exc), want) + + +def legacy_sandbox(addr: str) -> Sandbox: + return sandbox_at(addr, timeout=TIMEOUT, keep_alive=0) + + +def serve_port_forward(server: socket.socket, quiet: float, ops: list[str]) -> None: + conn, _ = server.accept() + reader = accept_upgrade(conn) + ops.append(json.loads(reader.readline())["op"]) + conn.sendall(b'{"type":"ready"}\n') + time.sleep(quiet) + conn.sendall(b'{"type":"data","data":"bGF0ZQ=="}\n{"type":"done"}\n') + conn.close() + + +def serve_started_then_hang(server: socket.socket, quiet: float, kills: list[dict]) -> None: + conn, _ = server.accept() + reader = accept_upgrade(conn) + reader.readline() + conn.sendall(b'{"type":"started","pid":7}\n') + killer, _ = server.accept() + kills.append(json.loads(accept_upgrade(killer).readline())) + killer.sendall(b'{"type":"done"}\n') + killer.close() + time.sleep(quiet) + conn.close() + + +def serve_started_then_trickle_the_kill_reply(server: socket.socket, kills: list[dict]) -> None: + conn, _ = server.accept() + reader = accept_upgrade(conn) + reader.readline() + conn.sendall(b'{"type":"started","pid":7}\n') + killer, _ = server.accept() + kills.append(json.loads(accept_upgrade(killer).readline())) + with contextlib.suppress(OSError): + for byte in b'{"type":"done"}\n': + time.sleep(0.75 * TIMEOUT) + killer.sendall(bytes([byte])) + killer.close() + conn.close() + + +def serve_silence(server: socket.socket) -> None: + conn, _ = server.accept() + conn.recv(4096) + time.sleep(5 * TIMEOUT) + conn.close() From ed07c9ceb50ac985e1d01fc5ad4a5bcacd959fdd Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:02:59 +0800 Subject: [PATCH 13/70] review: adapters floor the SDK at 0.1.13, where the in-guest kill after a timeout cut first shipped --- sdk/langchain/pyproject.toml | 2 +- sdk/openai/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/langchain/pyproject.toml b/sdk/langchain/pyproject.toml index b0a57ede6..381b672b3 100644 --- a/sdk/langchain/pyproject.toml +++ b/sdk/langchain/pyproject.toml @@ -9,7 +9,7 @@ description = "LangChain tools backed by cocoon microVM sandboxes" requires-python = ">=3.10" license = { text = "Apache-2.0" } readme = "README.md" -dependencies = ["langchain-core>=1.0", "pydantic>=2", "cocoonstack-sandbox>=0.1.12"] +dependencies = ["langchain-core>=1.0", "pydantic>=2", "cocoonstack-sandbox>=0.1.13"] [tool.setuptools.packages.find] include = ["cocoonsandbox_langchain*"] diff --git a/sdk/openai/pyproject.toml b/sdk/openai/pyproject.toml index c2f231920..93ccff4ac 100644 --- a/sdk/openai/pyproject.toml +++ b/sdk/openai/pyproject.toml @@ -9,7 +9,7 @@ description = "OpenAI Agents SDK sandbox provider backed by cocoon microVMs" requires-python = ">=3.10" license = { text = "Apache-2.0" } readme = "README.md" -dependencies = ["openai-agents>=0.17", "cocoonstack-sandbox>=0.1.12"] +dependencies = ["openai-agents>=0.17", "cocoonstack-sandbox>=0.1.13"] [tool.setuptools.packages.find] include = ["cocoonsandbox_openai*"] From 3f487b4110657ba4aa9209774a2cf7547f9575bc Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:04:12 +0800 Subject: [PATCH 14/70] review: silkdtest's default arm calls serveStateless The arm re-spelled serveStateless byte for byte. --- sdk/go/silkd/silkdtest/fake.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sdk/go/silkd/silkdtest/fake.go b/sdk/go/silkd/silkdtest/fake.go index caeafa002..576b4f5ea 100644 --- a/sdk/go/silkd/silkdtest/fake.go +++ b/sdk/go/silkd/silkdtest/fake.go @@ -83,9 +83,7 @@ func (f *Fake) serve(conn net.Conn, r *bufio.Reader, req wire.Request) { case *wire.PtyResize: send(conn, wire.Done{}) default: - if !serveCommon(conn, r, req) { - errFrame(conn, wire.KindUnimplemented, "silkdtest: "+req.Op()) - } + serveStateless(conn, r, req) } } From 0e786e7ba533728620a54d1594d0fe1b9d689d3c Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:14 +0800 Subject: [PATCH 15/70] review: trim multi-line comments on unexported decls, capitalise own-line comments Own-line comments start uppercase per /code Comment Style; multi-line godocs on unexported names cut to one line. --- e2e/cmd/androidsmoke/main.go | 2 +- e2e/cmd/egresssmoke/main.go | 4 +-- e2e/cmd/meshsmoke/main.go | 3 +- e2e/cmd/portsmoke/main.go | 2 +- e2e/cmd/qaab/main.go | 2 +- e2e/cmd/rpcbench/main.go | 2 +- e2e/cmd/smoke/main.go | 54 ++++++++++-------------------------- e2e/cmd/volumesmoke/main.go | 2 +- sdk/go/port.go | 3 +- sdk/go/sandbox.go | 3 +- sdk/go/watch.go | 3 +- 11 files changed, 26 insertions(+), 54 deletions(-) diff --git a/e2e/cmd/androidsmoke/main.go b/e2e/cmd/androidsmoke/main.go index 6a5456c9d..40e816651 100644 --- a/e2e/cmd/androidsmoke/main.go +++ b/e2e/cmd/androidsmoke/main.go @@ -83,7 +83,7 @@ func run(addr, token, template string) error { } func initTree(ctx context.Context, sb *sandbox.Sandbox) error { - // the framework (zygote, system_server) starts minutes after init on a nested first boot; silkd answers long before + // The framework (zygote, system_server) starts minutes after init on a nested first boot; silkd answers long before deadline := time.Now().Add(5 * time.Minute) for { out, err := sb.Exec(ctx, "/system/bin/sh", "-c", shellPath+"; ps -A") diff --git a/e2e/cmd/egresssmoke/main.go b/e2e/cmd/egresssmoke/main.go index 7c92254de..aee5d61ea 100644 --- a/e2e/cmd/egresssmoke/main.go +++ b/e2e/cmd/egresssmoke/main.go @@ -70,7 +70,7 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string defer func() { _ = sb.Close() }() fmt.Printf(" claimed %s-lane sandbox %s\n", netShape, sb.ID) - // the image leaves the NIC unconfigured; a static address gives the guest a route toward -reach, so a blocked direct egress is the lock. + // The image leaves the NIC unconfigured; a static address gives the guest a route toward -reach, so a blocked direct egress is the lock. if nicAddr != "" { out, cfgErr := sb.Exec(ctx, "sh", "-c", fmt.Sprintf( "nic=$(ip -o link | awk -F': ' '$2 ~ /^(eth|en)/{sub(/@.*/,\"\",$2); print $2; exit}'); "+ @@ -93,7 +93,7 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string } fmt.Println(" direct egress blocked") - // the SSRF guard refuses private and loopback destinations even when allow-listed, so injection needs a public echo. + // The SSRF guard refuses private and loopback destinations even when allow-listed, so injection needs a public echo. seen, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -s -x %s http://%s/get", proxy, echo)) if err != nil { return fmt.Errorf("proxied exec: %w", err) diff --git a/e2e/cmd/meshsmoke/main.go b/e2e/cmd/meshsmoke/main.go index be00c5afb..0c8fb2ff3 100644 --- a/e2e/cmd/meshsmoke/main.go +++ b/e2e/cmd/meshsmoke/main.go @@ -58,8 +58,7 @@ func run(addr, peer, token, template string) error { } fmt.Printf(" promote: mesh-tpl published on %s\n", sb.Owner()) - // Gossip carries the new template to the entry node within about a tick; - // poll rather than guess the delay (a premature claim cold-fails at A). + // Poll for gossip to reach the entry node; a premature claim cold-fails at A. var clone *sandbox.Sandbox deadline := time.Now().Add(15 * time.Second) for { diff --git a/e2e/cmd/portsmoke/main.go b/e2e/cmd/portsmoke/main.go index e171a03a1..70aa9b2fc 100644 --- a/e2e/cmd/portsmoke/main.go +++ b/e2e/cmd/portsmoke/main.go @@ -93,7 +93,7 @@ func run(addr, token, template, listener string, hold time.Duration) error { fmt.Printf(" ok %-24s %5.1fs\n", step.name, time.Since(t0).Seconds()) } if hold > 0 { - // the listener is live now, for the operator's edge smoke to drive + // The listener is live now, for the operator's edge smoke to drive fmt.Printf("SANDBOX %s %s %s %d\n", sb.ID, sb.Token(), sb.Owner(), guestPort) select { case <-time.After(hold): diff --git a/e2e/cmd/qaab/main.go b/e2e/cmd/qaab/main.go index 2e0ea159d..d001f4598 100644 --- a/e2e/cmd/qaab/main.go +++ b/e2e/cmd/qaab/main.go @@ -19,7 +19,7 @@ import ( sandbox "github.com/cocoonstack/sandbox/sdk/go" ) -// one dd keeps exactly one virtio queue busy, so the writers are the queue fan-out +// One dd keeps exactly one virtio queue busy, so the writers are the queue fan-out const ddScript = `pids=; for i in $(seq 1 %d); do dd if=/dev/zero of=/root/qaab.$i bs=%s count=%d oflag=direct conv=fsync 2>/dev/null & pids="$pids $!"; done; status=0; for pid in $pids; do wait "$pid" || status=1; done; exit "$status"` type stats struct { diff --git a/e2e/cmd/rpcbench/main.go b/e2e/cmd/rpcbench/main.go index feca2b429..4e6aa1a04 100644 --- a/e2e/cmd/rpcbench/main.go +++ b/e2e/cmd/rpcbench/main.go @@ -67,7 +67,7 @@ func run(addr, token, template, caCert string, n int) error { dial := func() (net.Conn, error) { return dialAgent(ctx, sb.Owner(), sb.ID, sb.Token(), tlsCfg) } - // warm the path (wake resolution, page cache) before either mode. + // Warm the path (wake resolution, page cache) before either mode. for range 5 { conn, err := dial() if err != nil { diff --git a/e2e/cmd/smoke/main.go b/e2e/cmd/smoke/main.go index ea77258df..b87d720be 100644 --- a/e2e/cmd/smoke/main.go +++ b/e2e/cmd/smoke/main.go @@ -51,7 +51,7 @@ func run(addr, token, template, lspTemplate string, egress bool) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // explicitly the no-network lane: the git step asserts its typed error. + // Explicitly the no-network lane: the git step asserts its typed error. client, sb, err := harness.Claim(ctx, addr, token, template, sandbox.WithNetwork(sandbox.NetNone)) if err != nil { return err @@ -210,8 +210,7 @@ func smokeWatch(ctx context.Context, sb *sandbox.Sandbox) error { if !ok { return fmt.Errorf("watch ended early: %v", w.Err()) } - // the atomic write surfaces as temp-file events too; any event - // whose path carries the name proves the stream. + // The atomic write also emits temp-file events; any event on the name proves the stream. if strings.Contains(ev.Path, "w.txt") { return nil } @@ -283,18 +282,14 @@ func smokeGit(ctx context.Context, sb *sandbox.Sandbox) error { return fmt.Errorf("after checkout: current=%q, want feature", br.Current) } - // this sandbox is on the no-network lane: push must fail with the typed - // unimplemented error, not hang on an unreachable remote. + // The none lane answers push with the typed unimplemented error. if err := sb.GitPush(ctx, "/work", ""); !isSilkdKind(err, wire.KindUnimplemented) { return fmt.Errorf("push on none lane: %v, want unimplemented", err) } return nil } -// smokeEgress claims a second sandbox on the egress lane and pins the -// positive half of silkd's lane detection: git must actually run there, so a -// push with no remote fails inside git — never with the none-lane -// unimplemented guard. +// smokeEgress pins that git runs on the egress lane, unlike the none-lane unimplemented guard. func smokeEgress(ctx context.Context, client *sandbox.Client, template string) error { sb, err := client.New(ctx, template, sandbox.WithNetwork(sandbox.NetEgress)) if err != nil { @@ -310,9 +305,7 @@ func smokeEgress(ctx context.Context, client *sandbox.Client, template string) e return nil } -// smokeHibernate proves the full hibernate loop: a session (a live guest -// process) survives hibernate → transparent wake-on-exec, and the node's -// hibernated count tracks the transitions. +// smokeHibernate proves a session survives hibernate and wake-on-exec while the hibernated count tracks. func smokeHibernate(ctx context.Context, client *sandbox.Client, sb *sandbox.Sandbox) error { sess, err := sb.NewSession(ctx) if err != nil { @@ -338,9 +331,7 @@ func smokeHibernate(ctx context.Context, client *sandbox.Client, sb *sandbox.San return wantHibernated(ctx, client, 0) } -// smokeFork proves the fork loop: children inherit the parent's disk at the -// fork point, get fresh distinct machine identities (clone reseed), and are -// fully independent claims whose writes never reach a sibling or the parent. +// smokeFork proves children inherit the fork-point disk, get fresh machine identities, and stay independent. func smokeFork(ctx context.Context, sb *sandbox.Sandbox) error { if err := sb.WriteFile(ctx, "/work/fork-mark.txt", []byte("parent"), nil); err != nil { return err @@ -390,10 +381,7 @@ func smokeFork(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokePromote proves promote-to-template through the owner-bound handle: -// claiming from it must clone the parent's state — on a real node a cold -// boot of this never-registered image ref would fail, so success itself -// proves the golden path — and delete removes it. +// smokePromote proves a claim from a promoted template clones the parent's state and delete removes it. func smokePromote(ctx context.Context, client *sandbox.Client, sb *sandbox.Sandbox) error { tpl, err := sb.Promote(ctx, "smoke-tpl:v1") if err != nil { @@ -439,9 +427,7 @@ func smokePty(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokeCheckpoint proves the checkpoint tree: a branch carries the exact -// captured state, the source keeps running unaffected, two branches from one -// checkpoint are independent, and a deleted checkpoint stops branching. +// smokeCheckpoint proves branches carry the captured state independently and a deleted checkpoint stops branching. func smokeCheckpoint(ctx context.Context, sb *sandbox.Sandbox) error { if _, err := sb.Exec(ctx, "sh", "-c", "echo v1 > /root/ck.txt"); err != nil { return err @@ -499,8 +485,7 @@ func smokeCheckpoint(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokeTree pushes a tar into the guest, proves a mid-stream failure leaves -// the tree untouched (fs_push atomicity), and pulls it back. +// smokeTree proves a failed mid-stream push leaves the tree untouched and a pull returns it. func smokeTree(ctx context.Context, sb *sandbox.Sandbox) error { archive := func(content string) []byte { var buf bytes.Buffer @@ -525,8 +510,7 @@ func smokeTree(ctx context.Context, sb *sandbox.Sandbox) error { if out, err := sb.Exec(ctx, "cat", "/root/tree/t.txt"); err != nil || strings.TrimSpace(out) != "tree-marker" { return fmt.Errorf("tree changed by failed push: %q, %v", out, err) } - // silkd unlinks the staging file when it notices the dead stream — an - // asynchronous cleanup a fresh RPC can outrun, so settle with a budget. + // The staging unlink is asynchronous, so settle with a budget. var staging string var stagingErr error for range 20 { @@ -550,13 +534,9 @@ func smokeTree(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokePortForward proves the guest-port relay on the no-network lane: sshd -// (socket-activated in the base image) answers on 22, so its banner must -// arrive through DialPort; a dead port must fail with the typed not_found. +// smokePortForward proves sshd's banner arrives through DialPort and a dead port fails with not_found. func smokePortForward(ctx context.Context, sb *sandbox.Sandbox) error { - // the step proves the relay, not sshd's readiness SLA: the guest's - // socket-activated sshd can transiently refuse, so the positive probe - // retries briefly; the dead-port negative below stays strict. + // The step proves the relay, not sshd readiness, so the positive probe retries. pc, err := sb.DialPort(ctx, 22) for attempt := 0; err != nil && attempt < 100; attempt++ { time.Sleep(100 * time.Millisecond) @@ -579,9 +559,7 @@ func smokePortForward(ctx context.Context, sb *sandbox.Sandbox) error { return nil } -// smokeLsp proves the LSP broker on hardware: the base sandbox answers a -// typed not_found (no manifests), and a python-flavor sandbox serves a real -// pylsp session — initialize, didOpen, hover — over the relay. +// smokeLsp proves the base sandbox answers not_found and a python sandbox serves a pylsp session. func smokeLsp(ctx context.Context, client *sandbox.Client, base *sandbox.Sandbox, template string) error { if _, err := base.StartLsp(ctx, "python", ""); !isSilkdKind(err, wire.KindNotFound) { return fmt.Errorf("base StartLsp: %v, want typed not_found", err) @@ -665,7 +643,7 @@ func smokeProcs(ctx context.Context, sb *sandbox.Sandbox) error { return fmt.Errorf("logs replay %q missing mark", out.String()) } - // a long-runner dies to kill; its pid must be gone from the next attach. + // A long-runner dies to kill; its pid must be gone from the next attach. pid, err = sb.Spawn(ctx, sandbox.Cmd{Argv: []string{"sleep", "30"}}) if err != nil { return fmt.Errorf("spawn sleeper: %w", err) @@ -707,9 +685,7 @@ func lspWrite(w io.Writer, body string) error { return err } -// lspReadResponse reads framed server messages until the response carrying -// id arrives (server-initiated requests also carry ids but have a method; -// notifications have no id — both are skipped), returning its result. +// lspReadResponse skips server requests and notifications until the response for id arrives. func lspReadResponse(r *bufio.Reader, id int) (jsontext.Value, error) { tp := textproto.NewReader(r) for { diff --git a/e2e/cmd/volumesmoke/main.go b/e2e/cmd/volumesmoke/main.go index 7ee5c5344..6dc4b4c8d 100644 --- a/e2e/cmd/volumesmoke/main.go +++ b/e2e/cmd/volumesmoke/main.go @@ -217,7 +217,7 @@ func runWritable(ctx context.Context, client *sandbox.Client, template, volume s if _, err = writer.Exec(ctx, "sh", "-c", fmt.Sprintf("printf %%s %s > %s", stamp, path.Join(mount, file))); err != nil { return fmt.Errorf("write through the writable mount: %w", err) } - // a live writer owns the image: the second claim is refused before attach. + // A live writer owns the image: the second claim is refused before attach. busy, busyErr := client.New(ctx, template, sandbox.WithNetwork(sandbox.NetNone), sandbox.WithVolumes(sandbox.Volume{Name: volume, Mount: mount, Mode: "rw"})) if busyErr == nil { diff --git a/sdk/go/port.go b/sdk/go/port.go index c8fdedc00..74e993dd1 100644 --- a/sdk/go/port.go +++ b/sdk/go/port.go @@ -126,8 +126,7 @@ func (s *Sandbox) ProxyPort(ctx context.Context, localAddr string, port uint16) return l, nil } -// openStream drives the call → Ready → attach dance shared by every verb -// that turns the connection into a raw byte stream (DialPort, Lsp.Request). +// openStream turns a call into a raw byte stream, shared by DialPort and Lsp.Request. func (s *Sandbox) openStream(ctx context.Context, req wire.Request) (*PortConn, error) { conn, l, err := s.call(ctx, req) if err != nil { diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go index 4a180d8c0..17bd29e7d 100644 --- a/sdk/go/sandbox.go +++ b/sdk/go/sandbox.go @@ -296,8 +296,7 @@ func (s *Sandbox) dial(ctx context.Context) (*agentConn, error) { return newAgentConn(raw), nil } -// pumpStdin chunks the reader into stdin frames; Send's own locking keeps -// the pump safe against the caller's concurrent frames. +// pumpStdin chunks the reader into stdin frames. func pumpStdin(conn *silkd.Conn, r io.Reader) { buf := make([]byte, stdinChunk) for { diff --git a/sdk/go/watch.go b/sdk/go/watch.go index 33be6a1f9..3c5fac601 100644 --- a/sdk/go/watch.go +++ b/sdk/go/watch.go @@ -40,8 +40,7 @@ func (w *Watcher) Err() error { return w.err } -// drain relays event frames into the channel until a terminal condition; the -// closed channel keeps it from blocking on a consumer that stopped reading. +// drain relays event frames into the channel until the stream ends or w.closed fires. func (w *Watcher) drain(ctx context.Context, conn *silkd.Conn) { defer close(w.events) defer w.stop() From 59d3666f5489a9027a4718483a5bf359ad709840 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:26 +0800 Subject: [PATCH 16/70] review: guestserver keeps its report utility below the halfCloseSink block Layout only: /code File Organization puts utility funcs below the type's methods. --- e2e/cmd/guestserver/main.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/e2e/cmd/guestserver/main.go b/e2e/cmd/guestserver/main.go index 232405300..e7cd21382 100644 --- a/e2e/cmd/guestserver/main.go +++ b/e2e/cmd/guestserver/main.go @@ -47,17 +47,6 @@ func main() { } } -// report answers with what the guest received, so a caller can assert the relay changed nothing. -func report(w http.ResponseWriter, r *http.Request) { - body := new(strings.Builder) - fmt.Fprintf(body, "proto=%d\nmethod=%s\npath=%s\nhost=%s\n", r.ProtoMajor, r.Method, r.URL.RequestURI(), r.Host) - for _, name := range []string{"X-Access-Token", "X-API-KEY", "X-Probe"} { - fmt.Fprintf(body, "header[%s]=%s\n", name, r.Header.Get(name)) - } - w.Header().Set("Content-Type", "text/plain") - _, _ = io.WriteString(w, body.String()) -} - // halfCloseSink greets, shuts its write side, and keeps reading what the client sends after. type halfCloseSink struct { mu sync.Mutex @@ -106,3 +95,14 @@ func (h *halfCloseSink) read() string { } return h.got } + +// report answers with what the guest received, so a caller can assert the relay changed nothing. +func report(w http.ResponseWriter, r *http.Request) { + body := new(strings.Builder) + fmt.Fprintf(body, "proto=%d\nmethod=%s\npath=%s\nhost=%s\n", r.ProtoMajor, r.Method, r.URL.RequestURI(), r.Host) + for _, name := range []string{"X-Access-Token", "X-API-KEY", "X-Probe"} { + fmt.Fprintf(body, "header[%s]=%s\n", name, r.Header.Get(name)) + } + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, body.String()) +} From a37e9580af33dee41f04a8cc1e33f492d4472a62 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:32 +0800 Subject: [PATCH 17/70] review: volumesmoke rejects non-local probes with filepath.IsLocal Same verdict as the hand-rolled path test on 15 inputs. --- e2e/cmd/volumesmoke/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/cmd/volumesmoke/main.go b/e2e/cmd/volumesmoke/main.go index 6dc4b4c8d..2f968c8bf 100644 --- a/e2e/cmd/volumesmoke/main.go +++ b/e2e/cmd/volumesmoke/main.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path" + "path/filepath" "slices" "strings" "sync" @@ -38,7 +39,7 @@ func run(addr, token, template, volume, rwVolume, probe string, live bool) error if volume == "" { return errors.New("volume is required") } - if probe == "" || probe == "." || path.IsAbs(probe) || path.Clean(probe) != probe || probe == ".." || strings.HasPrefix(probe, "../") { + if !filepath.IsLocal(probe) || probe == "." || path.Clean(probe) != probe { return fmt.Errorf("probe %q must be a clean relative file path", probe) } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) From 329fd96e9210dce1a4864c3673b7294d7c9280a6 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:32 +0800 Subject: [PATCH 18/70] review: harness owns the guest proxy address, RelayConn and a package-level Request The proxy address was declared in four drivers. Dial returns RelayConn so portsmoke calls CloseWrite directly. PortRelay.Request never read its receiver. credsmoke's expectPlaceholder locals stop shadowing the entry and header consts. --- e2e/cmd/credsmoke/main.go | 19 +++++++++---------- e2e/cmd/egresssmoke/main.go | 6 ++---- e2e/cmd/interceptsmoke/main.go | 4 +--- e2e/cmd/portsmoke/main.go | 26 +++++++++----------------- e2e/cmd/sockssmoke/main.go | 9 ++++----- e2e/internal/harness/harness.go | 13 ++++++++++--- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/e2e/cmd/credsmoke/main.go b/e2e/cmd/credsmoke/main.go index 2a8382bc7..c1e1b94af 100644 --- a/e2e/cmd/credsmoke/main.go +++ b/e2e/cmd/credsmoke/main.go @@ -20,7 +20,6 @@ import ( ) const ( - proxy = "http://127.0.0.1:3128" header = "X-Api-Key" entry = "API_KEY" tsPH = "@TS@" @@ -99,7 +98,7 @@ func run(ctx context.Context, addr, token, template, echo, secret, sse string) e } fmt.Println(" each claim's origin saw only its own credential, the plain claim the guest's placeholder, all the pool secret") if sse != "" { - out, _ := plain.Exec(ctx, "sh", "-c", fmt.Sprintf("timeout 4 curl -sSN --noproxy '' -x %s %s 2>&1; true", proxy, sse)) + out, _ := plain.Exec(ctx, "sh", "-c", fmt.Sprintf("timeout 4 curl -sSN --noproxy '' -x %s %s 2>&1; true", harness.GuestProxy, sse)) if !strings.Contains(out, "data: one") { return fmt.Errorf("the first event did not reach the guest while the stream stayed open: %q", out) } @@ -257,7 +256,7 @@ func conditional(ctx context.Context, addr, token, template string, size sandbox {"json body", base + `/post -H 'Content-Type: application/json' -d '{"access_token":"` + fbPH + `"}'`, fmt.Sprintf(`"content-length":"%d"`, len(doc)), fb}, {"response header echo", "-D - -o /dev/null '" + base + "/response-headers?api_key=" + url.QueryEscape(serpPH) + "'", serpPH, stamp}, } { - out, execErr := vault.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s %s", proxy, tt.args)) + out, execErr := vault.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s %s", harness.GuestProxy, tt.args)) if execErr != nil { return fmt.Errorf("%s exec: %w (%s)", tt.name, execErr, out) } @@ -288,16 +287,16 @@ func credential(echo, value string) map[string]sandbox.EnvVar { } func expectPlaceholder(ctx context.Context, sb *sandbox.Sandbox, echo, value string) error { - entry := &sandbox.EnvVar{Value: value, Guest: new(false), Inject: &sandbox.EnvInject{Hosts: []string{echo}, Header: "X-Ph-Key", Placeholder: "@PH_KEY@"}} - if err := sb.PatchEnv(ctx, map[string]*sandbox.EnvVar{"PH_KEY": entry}); err != nil { + env := &sandbox.EnvVar{Value: value, Guest: new(false), Inject: &sandbox.EnvInject{Hosts: []string{echo}, Header: "X-Ph-Key", Placeholder: "@PH_KEY@"}} + if err := sb.PatchEnv(ctx, map[string]*sandbox.EnvVar{"PH_KEY": env}); err != nil { return fmt.Errorf("placeholder inject: %w", err) } for sent, want := range map[string]bool{"": false, "@PH_KEY@": true, "@OTHER@": false} { - header := "" + hdr := "" if sent != "" { - header = fmt.Sprintf("-H 'X-Ph-Key: %s' ", sent) + hdr = fmt.Sprintf("-H 'X-Ph-Key: %s' ", sent) } - out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s %shttps://%s/headers", proxy, header, echo)) + out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s %shttps://%s/headers", harness.GuestProxy, hdr, echo)) if err != nil { return fmt.Errorf("placeholder exec: %w (%s)", err, out) } @@ -310,7 +309,7 @@ func expectPlaceholder(ctx context.Context, sb *sandbox.Sandbox, echo, value str // expectTunnel reads the issuer and HTTP version the guest gets from host; an intercepted tunnel shows the e2e CA and HTTP/1.1. func expectTunnel(ctx context.Context, sb *sandbox.Sandbox, host string, intercepted bool) error { - out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sSv -o /dev/null -w 'version=%%{http_version}\\n' -x %s https://%s/ 2>&1", proxy, host)) + out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sSv -o /dev/null -w 'version=%%{http_version}\\n' -x %s https://%s/ 2>&1", harness.GuestProxy, host)) if err != nil { return fmt.Errorf("%s exec: %w (%s)", host, err, out) } @@ -325,7 +324,7 @@ func expectTunnel(ctx context.Context, sb *sandbox.Sandbox, host string, interce } func expectHeader(ctx context.Context, sb *sandbox.Sandbox, echo, want, not, secret string) error { - out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s -H '%s: placeholder' https://%s/headers", proxy, header, echo)) + out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s -H '%s: placeholder' https://%s/headers", harness.GuestProxy, header, echo)) if err != nil { return fmt.Errorf("proxied HTTPS exec: %w (%s)", err, out) } diff --git a/e2e/cmd/egresssmoke/main.go b/e2e/cmd/egresssmoke/main.go index aee5d61ea..3214a84d7 100644 --- a/e2e/cmd/egresssmoke/main.go +++ b/e2e/cmd/egresssmoke/main.go @@ -28,8 +28,6 @@ import ( sandbox "github.com/cocoonstack/sandbox/sdk/go" ) -const proxy = "http://127.0.0.1:3128" - func main() { addr := flag.String("addr", "127.0.0.1:7777", "sandboxd address") token := flag.String("token", "", "node api token") @@ -94,7 +92,7 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string fmt.Println(" direct egress blocked") // The SSRF guard refuses private and loopback destinations even when allow-listed, so injection needs a public echo. - seen, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -s -x %s http://%s/get", proxy, echo)) + seen, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -s -x %s http://%s/get", harness.GuestProxy, echo)) if err != nil { return fmt.Errorf("proxied exec: %w", err) } @@ -114,7 +112,7 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string } fmt.Println(" secret absent from guest env") - deny, _ := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -s -o /dev/null -w '%%{http_code}' -x %s http://10.255.255.1/", proxy)) + deny, _ := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -s -o /dev/null -w '%%{http_code}' -x %s http://10.255.255.1/", harness.GuestProxy)) if strings.TrimSpace(deny) != "403" { return fmt.Errorf("denied host returned %q, want 403", strings.TrimSpace(deny)) } diff --git a/e2e/cmd/interceptsmoke/main.go b/e2e/cmd/interceptsmoke/main.go index dac09fa15..6ee7f5817 100644 --- a/e2e/cmd/interceptsmoke/main.go +++ b/e2e/cmd/interceptsmoke/main.go @@ -18,8 +18,6 @@ import ( sandbox "github.com/cocoonstack/sandbox/sdk/go" ) -const proxy = "http://127.0.0.1:3128" - func main() { addr := flag.String("addr", "127.0.0.1:7779", "sandboxd address") token := flag.String("token", "", "node api token") @@ -54,7 +52,7 @@ func run(addr, token, template, echo, secret, issuer string) error { } fmt.Println(" cluster root baked into guest trust store") - verbose, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -v -x %s https://%s/get 2>&1", proxy, echo)) + verbose, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -v -x %s https://%s/get 2>&1", harness.GuestProxy, echo)) if err != nil { return fmt.Errorf("proxied HTTPS exec: %w (%s)", err, tail(verbose)) } diff --git a/e2e/cmd/portsmoke/main.go b/e2e/cmd/portsmoke/main.go index 70aa9b2fc..b655f85ac 100644 --- a/e2e/cmd/portsmoke/main.go +++ b/e2e/cmd/portsmoke/main.go @@ -182,11 +182,7 @@ func stepHalfClose(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbo if _, writeErr := io.WriteString(conn, "GET /half HTTP/1.1\r\nHost: guest\r\n\r\n"); writeErr != nil { return writeErr } - cw, ok := conn.(interface{ CloseWrite() error }) - if !ok { - return errors.New("relay conn does not support CloseWrite") - } - if closeErr := cw.CloseWrite(); closeErr != nil { + if closeErr := conn.CloseWrite(); closeErr != nil { return fmt.Errorf("close write: %w", closeErr) } out, err := io.ReadAll(conn) @@ -213,11 +209,7 @@ func stepGuestHalfClose(ctx context.Context, rt *harness.PortRelay, _ *sandbox.S if _, err := io.WriteString(conn, "after-done"); err != nil { return fmt.Errorf("write after the guest half close: %w", err) } - cw, ok := conn.(interface{ CloseWrite() error }) - if !ok { - return errors.New("relay conn does not support CloseWrite") - } - if err := cw.CloseWrite(); err != nil { + if err := conn.CloseWrite(); err != nil { return err } deadline := time.Now().Add(listenerReadyWait) @@ -235,17 +227,17 @@ func stepGuestHalfClose(ctx context.Context, rt *harness.PortRelay, _ *sandbox.S } func stepWrongToken(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) error { - return relayWantStatus(ctx, rt, rt.URL(guestPort), "wrong-token", true, http.StatusNotFound) + return relayWantStatus(ctx, rt.URL(guestPort), "wrong-token", true, http.StatusNotFound) } func stepNoToken(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) error { - return relayWantStatus(ctx, rt, rt.URL(guestPort), "", true, http.StatusUnauthorized) + return relayWantStatus(ctx, rt.URL(guestPort), "", true, http.StatusUnauthorized) } func stepBadPort(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) error { for _, port := range []string{"0", "65536", "+22", "http"} { url := fmt.Sprintf("http://%s/v1/sandboxes/%s/ports/%s", rt.Owner, rt.ID, port) - if err := relayWantStatus(ctx, rt, url, rt.Token, true, http.StatusBadRequest); err != nil { + if err := relayWantStatus(ctx, url, rt.Token, true, http.StatusBadRequest); err != nil { return fmt.Errorf("port %q: %w", port, err) } } @@ -253,11 +245,11 @@ func stepBadPort(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) } func stepNoUpgrade(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) error { - return relayWantStatus(ctx, rt, rt.URL(guestPort), rt.Token, false, http.StatusUpgradeRequired) + return relayWantStatus(ctx, rt.URL(guestPort), rt.Token, false, http.StatusUpgradeRequired) } func stepDeadPort(ctx context.Context, rt *harness.PortRelay, _ *sandbox.Sandbox) error { - return relayWantStatus(ctx, rt, rt.URL(deadPort), rt.Token, true, http.StatusBadGateway) + return relayWantStatus(ctx, rt.URL(deadPort), rt.Token, true, http.StatusBadGateway) } // stepHibernate proves the relay wakes a hibernated sandbox; the listener survives in the snapshot. @@ -294,8 +286,8 @@ func relayGet(ctx context.Context, r *harness.PortRelay, port uint16, path strin } // relayWantStatus asserts a refusal is answered as HTTP, never by opening a relay. -func relayWantStatus(ctx context.Context, r *harness.PortRelay, url, token string, upgrade bool, want int) error { - req, err := r.Request(ctx, url, token, upgrade) +func relayWantStatus(ctx context.Context, url, token string, upgrade bool, want int) error { + req, err := harness.Request(ctx, url, token, upgrade) if err != nil { return err } diff --git a/e2e/cmd/sockssmoke/main.go b/e2e/cmd/sockssmoke/main.go index 3a1c5d636..a2c5c8e01 100644 --- a/e2e/cmd/sockssmoke/main.go +++ b/e2e/cmd/sockssmoke/main.go @@ -21,7 +21,6 @@ import ( const ( socksProxy = "127.0.0.1:1080" - httpProxy = "http://127.0.0.1:3128" curlLoginDenied = "exit=67" curlProxyRefused = "exit=97" @@ -59,7 +58,7 @@ func run(addr, token, template, echo, getOnly, imap string) error { } fmt.Println(" allowed host tunnels over SOCKS5") - if code := curl(ctx, sb, "-x", httpProxy, "http://"+getOnly+"/"); code != "200" { + if code := curl(ctx, sb, "-x", harness.GuestProxy, "http://"+getOnly+"/"); code != "200" { return fmt.Errorf("GET-only host over the HTTP proxy returned %q, want 200", code) } if code := curl(ctx, sb, "--socks5-hostname", socksProxy, "http://"+getOnly+"/"); code != "000" { @@ -87,10 +86,10 @@ func run(addr, token, template, echo, getOnly, imap string) error { fmt.Println(" IMAP on the unlisted port refused on 1080") } - if code := curl(ctx, sb, "-x", httpProxy, "https://"+echo+"/get"); code != "000" { + if code := curl(ctx, sb, "-x", harness.GuestProxy, "https://"+echo+"/get"); code != "000" { return fmt.Errorf("CONNECT to the unlisted port 443 returned %q, want a refused tunnel", code) } - if code := curl(ctx, sb, "-x", httpProxy, "http://"+echo+":8080/get"); code != "403" { + if code := curl(ctx, sb, "-x", harness.GuestProxy, "http://"+echo+":8080/get"); code != "403" { return fmt.Errorf("forward request to the unlisted port 8080 returned %q, want 403", code) } fmt.Println(" unlisted ports refused on 3128 for CONNECT and forward requests") @@ -113,7 +112,7 @@ func run(addr, token, template, echo, getOnly, imap string) error { if code := curl(ctx, silent, "--socks5-hostname", socksProxy, "http://"+echo+"/get"); code != "000" { return fmt.Errorf("pool that did not opt in opened a SOCKS5 tunnel with %q, want none", code) } - if code := curl(ctx, silent, "-x", httpProxy, "http://"+echo+"/get"); code != "200" { + if code := curl(ctx, silent, "-x", harness.GuestProxy, "http://"+echo+"/get"); code != "200" { return fmt.Errorf("pool that did not opt in returned %q on the HTTP proxy, want 200", code) } fmt.Println(" pool that did not opt in: HTTP door open, no SOCKS5 tunnel") diff --git a/e2e/internal/harness/harness.go b/e2e/internal/harness/harness.go index 8e0d6b7d1..5a6cf0078 100644 --- a/e2e/internal/harness/harness.go +++ b/e2e/internal/harness/harness.go @@ -14,6 +14,8 @@ import ( sandbox "github.com/cocoonstack/sandbox/sdk/go" ) +const GuestProxy = "http://127.0.0.1:3128" + // Claim connects to a node and claims one sandbox; the caller owns Close. func Claim(ctx context.Context, addr, token, template string, opts ...sandbox.Option) (*sandbox.Client, *sandbox.Sandbox, error) { client, err := sandbox.Connect(addr, sandbox.WithAPIToken(token)) @@ -56,6 +58,11 @@ func HTTPOverPort(ctx context.Context, sb *sandbox.Sandbox, port uint16, method, return out, nil } +type RelayConn interface { + net.Conn + CloseWrite() error +} + // PortRelay drives the guest-port endpoint the way an edge proxy does, not via the SDK's DialPort. type PortRelay struct { Owner string @@ -69,13 +76,13 @@ func (p PortRelay) URL(port uint16) string { } // Dial upgrades to the raw relay and hands back the connection. -func (p PortRelay) Dial(ctx context.Context, port uint16) (net.Conn, error) { +func (p PortRelay) Dial(ctx context.Context, port uint16) (RelayConn, error) { var d net.Dialer conn, err := d.DialContext(ctx, "tcp", p.Owner) if err != nil { return nil, err } - req, err := p.Request(ctx, p.URL(port), p.Token, true) + req, err := Request(ctx, p.URL(port), p.Token, true) if err != nil { _ = conn.Close() return nil, err @@ -103,7 +110,7 @@ func (p PortRelay) Dial(ctx context.Context, port uint16) (net.Conn, error) { } // Request builds one call to the endpoint, optionally asking for the upgrade. -func (p PortRelay) Request(ctx context.Context, url, token string, upgrade bool) (*http.Request, error) { +func Request(ctx context.Context, url, token string, upgrade bool) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err From abfc8a5fe73bfa95c44a00e9192a56334015a1ee Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:04:53 +0800 Subject: [PATCH 19/70] docs: deploy's upgrade notes name the release each starts from At v0.1.21 "this release" read as every upgrade needing a drain, a fresh data dir and an emptied bucket. Releases come from the first tag holding each commit: #81 v0.1.6 (the pool key's engine axis went), #364 v0.1.18, #59 v0.1.5, #69 v0.1.5 and #72 v0.1.6, #171 and #169 v0.1.11. --- docs/deploy.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 76ff3f2b8..47ae4f738 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,30 +34,34 @@ flavors. ## Upgrading -This release does not convert VM or snapshot state from older releases. Drain old +Each note names the release it starts from; a note for a release you already +run does not apply. + +Upgrading from before v0.1.6 does not convert VM or snapshot state. Drain old claims and use fresh `data_dir` and `checkpoint_dir` locations when upgrading; older checkpoints and promoted templates must not be reused. -The S3 `checkpoint_store` changed its object layout (chunk manifests instead of -whole export files). Records written by an earlier release are not readable and +An S3 `checkpoint_store` written before v0.1.18 used another object layout +(whole export files, now chunk manifests). Those records are not readable and there is no fallback reader: before starting the new binary, wake or release every archived claim and delete the checkpoints, then re-promote templates, or point `prefix` at an empty location. Objects of the old layout under a reused prefix stay in the bucket until you delete them. -The scalar egress-attachment keys are retired: rename `"bridge": "br0"` to +Since v0.1.5 the scalar egress-attachment keys are retired: rename `"bridge": "br0"` to `"bridges": ["br0"]` and `"network": "cni"` to `"networks": ["cni"]` before starting the new binary — config loading rejects the old spellings loudly rather than silently dropping the egress lane. -Dataset volumes require a lockstep rollout. Upgrade every sandboxd +Dataset volumes, read-only since v0.1.5 and writable since v0.1.6, require a +lockstep rollout. Upgrade every sandboxd node and cocoon to the required version before enabling the catalog or shipping an SDK that requests volumes. Mixed-version serving is unsupported. Once a volume claim has finalized, do not roll a node back to an older sandboxd until all volume claims are gone; the older daemon cannot preserve the volume claims' admission holds and marker bookkeeping recorded in `claims.json`. -The guest images and sandboxd move together on the egress lane: sandboxd tells +Since v0.1.11 the guest images and sandboxd move together on the egress lane: sandboxd tells a NIC-bearing guest how its traffic leaves through `/etc/silkd-lane`, and a guest image from another release reads a different file or none, which on a locked bridge lane leaves its execs routing into the lock. Roll the images From b273f0e3fe99fee685b5f485f44e1f071e62015e Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:04:53 +0800 Subject: [PATCH 20/70] docs: deploy and egress samples load and name a pulled image by its full ref A pulled image is keyed by its whole ref, so template base:24.04 fails the golden build with image not found, as the README already fixed in #425. The deploy sample's cluster_key and the egress sample's missing egress_ca failed config.Load. --- docs/deploy.md | 17 +++++++++++------ docs/egress.md | 5 +++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 47ae4f738..775ec3989 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -15,10 +15,13 @@ the cocoon CLI and needs a template image with silkd baked in. reads. sandboxd logs a warning at startup when the detected cocoon is below v0.6.9 (a dev/`master-` build is assumed current); changing volumes on a running claim needs v0.7.2, and a pool's `reader` needs v0.7.3 -- A silkd-baked template image, e.g. `ghcr.io/cocoonstack/sandbox/base:24.04` +- A silkd-baked template image, e.g. `ghcr.io/cocoonstack/sandbox/rt:24.04` (pull via cocoon, or `cocoon image import` a tar). The image carries the boot artifact (`/boot/vmlinuz-sandbox`, `/boot/initrd.img-sandbox`), so the host - needs neither file + needs neither file. A `template` is cocoon's image ref verbatim: after + `cocoon image pull ghcr.io/cocoonstack/sandbox/rt:24.04` it is that whole + ref. The short `rt:24.04` in later examples names an image imported with + `cocoon image import rt:24.04` Prebuilt static linux/amd64 and linux/arm64 binaries (`sandboxd`, `sandboxd.dbg`, `sandbox-mcp`, `silkd`, with `checksums.txt`) ship with every @@ -97,11 +100,11 @@ sandboxd reads one JSON file (`-config`, default "node_id": "node-a", "bind": "10.0.0.5:7946", "join": ["10.0.0.6:7946"], - "cluster_key": "base64…" + "cluster_key": "MDEyMzQ1Njc4OWFiY2RlZg==" }, "pools": [ - {"template": "base:24.04", "net": "none", "size": "small", "warm": 4}, - {"template": "base:24.04", "net": "egress", "size": "small", "warm": 2} + {"template": "ghcr.io/cocoonstack/sandbox/rt:24.04", "net": "none", "size": "small", "warm": 4}, + {"template": "ghcr.io/cocoonstack/sandbox/rt:24.04", "net": "egress", "size": "small", "warm": 2} ] } ``` @@ -453,7 +456,9 @@ took 10,752 MiB as whole files stored about 912 MiB as chunks. ### A fuller config -The block above is the minimum. A production node with guarded +The block above shows the common fields; the +[quick start](https://github.com/cocoonstack/sandbox#quick-start) config is the +minimum. A production node with guarded egress + HTTPS interception, previews, an object-store checkpoint backend, the idle→hibernate→archive tiers, and a mesh looks like this — every field here validates on load: diff --git a/docs/egress.md b/docs/egress.md index ea46e63f7..9c66891b8 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -201,6 +201,11 @@ file: a claim's own `guest: false` env first, the node's second "secrets": [ { "name": "gh", "header": "Authorization", "value_env": "GH_TOKEN" } ], + "egress_ca": { // provisioned below + "root_cert": "/etc/sandboxd/egress-ca/root.crt", + "intermediate_cert": "/etc/sandboxd/egress-ca/node-a.crt", + "intermediate_key": "/etc/sandboxd/egress-ca/node-a.key" + }, "pools": [ { "template": "rt:24.04", "net": "none", "size": "small", "warm": 2, "egress": { "socks5": true, "allow": [ From b4be3f73b23f25efeb201da0558c4203ec6fffff Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:11 +0800 Subject: [PATCH 21/70] docs: deploy states what the code requires of a failed claim, a golden and a dump A wake of a failed egress-lane or volume claim answers 409, tenants get 403 on two more root routes, golden dumps queue, an idle default acts on running claims at the next sweep, and an adopted replica always wakes on a read; none of these had a line. --- docs/deploy.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 775ec3989..b2a2b014c 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -167,7 +167,7 @@ the intersection of the two. | `archive_delete_after_seconds` | 0 (keep) | purge an archived claim's store checkpoint this long after it was archived; the claim is then gone for good. On S3, chunks and manifests remain until [offline reclamation](#reclaiming-s3-storage). On archive this retention window replaces the live claim deadline; 0 clears the deadline so the archive is kept forever. An [evacuation](cluster.md#evacuating-a-node) keeps a longer live deadline, and a claim of a pool without an archive tier, whose `on_expire` is not `archive`, keeps its deadline. Same node-wide/per-pool split | | `archive_failover` | false | let another node adopt this node's archived claims once its node lease expires, and the running claims of pools with `replicate_seconds`: each archive and replication round also writes a row (token SHA-256, owner, epoch, checkpoint id, retention deadline, and the claim record with its env, `guest: false` values included, in plaintext) to the shared PostgreSQL, and a `Lookup` that reaches any node takes the claim over there (see [archived claims across node loss](cluster.md#archived-claims-across-node-loss)). Requires `meta_store` with a `cell` (rows are scoped by it, so meshes sharing one database never adopt from each other), a `checkpoint_store` of kind `s3` and a `mesh`, all checked at config load. The node also renews a lease row every 5 s (see [node lease](cluster.md#node-lease)). With the meta store down, archives, wakes, releases and env writes of archived claims, and env writes and renews of hibernated replicated claims, fail and retry, and replicated claims stop being served once the lease is overdue (see [running claims](cluster.md#running-claims-across-node-loss)); other running claims never touch it. Restart only | | `mesh` | unset | join a cluster ([Clusters](cluster.md)); unset = single node | -| `pools[]` | — | warm pools, keyed by `(template, net, size)`. `warm` defaults to 4 only when both `warm` and `warm_max` are omitted; an explicit `warm: 0` is kept, so `warm: 0` under a `warm_max` starts the pool empty and grows it on demand, and `warm: 0` alone registers the key with no warm VMs (`PUT /v1/pools` never defaults `warm`); `net` is `none` or `egress`; `size` is a tier, below. Retune online without a restart via [`PUT /v1/pools`](sandboxd-api.md#put-v1pools) — omitted pools drain, and their goldens are deleted once they are idle (`warm: 0` keeps the golden). This is the **first-boot seed**: once a node takes a `PUT /v1/pools`, the applied set persists to `/pools.json` and overrides this section on every later boot (a startup log notes it); delete `pools.json` to return to config-owned pools. With a `meta_store` `cell`, the cell's set overrides it instead (see [shared pool set](#shared-pool-set)). Egress stays config-owned either way. A template re-pulled or re-imported under the same ref is picked up within a minute: the pool rebuilds its golden and replaces its warm VMs. See [state ownership](cluster.md#state-ownership) | +| `pools[]` | — | warm pools, keyed by `(template, net, size)`. `warm` defaults to 4 only when both `warm` and `warm_max` are omitted; an explicit `warm: 0` is kept, so `warm: 0` under a `warm_max` starts the pool empty and grows it on demand, and `warm: 0` alone registers the key with no warm VMs (`PUT /v1/pools` never defaults `warm`); `net` is `none` (the default) or `egress`; `size` is a tier, below (default `small`). Retune online without a restart via [`PUT /v1/pools`](sandboxd-api.md#put-v1pools) — omitted pools drain, and their goldens are deleted once they are idle (`warm: 0` keeps the golden). This is the **first-boot seed**: once a node takes a `PUT /v1/pools`, the applied set persists to `/pools.json` and overrides this section on every later boot (a startup log notes it); delete `pools.json` to return to config-owned pools. With a `meta_store` `cell`, the cell's set overrides it instead (see [shared pool set](#shared-pool-set)). Egress stays config-owned either way. A template re-pulled or re-imported under the same ref is picked up within a minute: the pool rebuilds its golden and replaces its warm VMs. See [state ownership](cluster.md#state-ownership) | Size tiers (free-form CPU/memory is deliberately not accepted — it would fragment the warm pools): @@ -257,7 +257,7 @@ hibernate of its claims runs a guest `sync` right before the pause, and a reader shows the disk as of that `sync`: a write the guest makes in the instant between the `sync` and the pause is visible only after a wake. A hibernate whose `sync` fails or times out is marked not reader-safe, as is any sleep that began before -the pool had `reader`, and its reads wake the claim as without the feature. +the pool had `reader`, as is a claim adopted from a replica, and its reads wake the claim as without the feature. ### Dataset volumes @@ -596,6 +596,7 @@ promoted templates, and preview URLs are stamped with the tenant name; sandbox and checkpoint listings filter to the caller's tenant, and a tenant can delete only its own checkpoints and templates (root sees everything). Operator surfaces stay root-only — per-id sandbox reads and stats, +`PUT/DELETE /v1/volumes/{name}`, `PUT /v1/sandboxes/{id}/instance-metadata`, `GET /v1/checkpoints/{id}/blob`, `GET /v1/info`, `PUT /v1/pools`, `/v1/tenants`, `/v1/config/reload`, `POST/DELETE /v1/drain`, `/metrics`, `GET /v1/usage`; a tenant token there is authenticated but not authorized, so it answers 403 (a @@ -625,7 +626,8 @@ it; older cocoons fall back to forced removal. Any golden no pool uses is deleted. Then the refill loop builds one golden snapshot per pool (a one-time cold boot + snapshot export, seconds on bare metal, plus the pool's warmup and the wait for the guest's systemd to finish starting) and keeps each pool topped -up with claim-ready clones. +up with claim-ready clones. Each golden's save and export run one at a time, so +a node with many or large pools takes the sum of their memory writes to come up. `GET /v1/info` shows `"golden": true` and `warm` at target when the node is ready to serve warm claims. A golden left on disk is adopted on start when its stamp matches the pool's @@ -674,6 +676,11 @@ VMM that cocoon could not move into its own scope, and any `cocoon` call still in flight, outlive the stop instead of dying with the service's cgroup. Claimed sandboxes are reaped when their TTL expires (default 5m, capped at 24h). +A snapshot save, hibernate or export has 15 minutes, plus 3 to exit once +canceled, and at most 8 saves and hibernates run at once on a node. A call that +dials a guest gives up after 10 s, since a paused or wedged guest never answers, +and gets `502`. + ### Dead VMMs and host restarts With `cocoond_socket` set, the node follows `cocoon daemon`'s event stream @@ -703,8 +710,10 @@ synced survive, memory and processes do not. The claim's guest env entries are written again and its egress proxy is re-armed; the instance-metadata document is not kept, so re-send it when `restarts` moves. A failed claim keeps its VM and disk until release or lease end (it is destroyed at expiry even with -`on_expire: archive`); `POST /v1/sandboxes/{id}/wake` cold-boots it and resets -the restart budget. After a host restart the same rule applies to every +`on_expire: archive`). `POST /v1/sandboxes/{id}/wake` cold-boots it and resets +the restart budget, except an egress-lane claim, a claim holding volumes or one +whose VM record is gone: those answer 409 and stay failed until release or lease +end. After a host restart the same rule applies to every running claim whose VM record survived, so hibernated, archived and running claims all come back; with `sync_claims` off a power loss can still lose the journal itself. Cocoon keeps each VM's copy-on-write disk under its `run_dir`, @@ -964,6 +973,9 @@ Effects on what already runs: exit and `sync_claims` to the next commit. A new `archive_delete_after_seconds` sets the deadline of claims archived after the reload; a deadline already set stays. +- **Idle defaults**: a new `idle_hibernate_seconds` or `archive_after_seconds` + applies to the node's unpooled claims at the next 5 s sweep, those already + running included, so a lower value can hibernate many at once. - **Checkpoint TTL**: `checkpoint_ttl_hours` applies at the next hourly sweep, so a lower value deletes what is now past it. It is part of the cluster digest: nodes report a config mismatch until every node has reloaded the same value. From 20f56ff8227b7c32a66d2f27ca3ffdb6a27c662c Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:12 +0800 Subject: [PATCH 22/70] docs: deploy gives the archive registry's DDL A role without CREATE could not enable archive_failover from the docs: the other three shared tables have a pre-create DDL and this one had none. --- docs/cluster.md | 2 +- docs/deploy.md | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index 3215577b6..fd698abb4 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -312,7 +312,7 @@ With `archive_failover` every node renews one row of `sandboxd_nodes (cell, node, lease_until)` every 5 seconds, with a single upsert on the database clock that covers the whole node: `lease_until = now() + 15 s`. A renewal that fails once does not lose the lease or trip the fence, as long as the next one -answers within 2 seconds of its start. The table is created at start when it is missing, and a node +answers within 2 seconds of its start. The table is [created at start](deploy.md#shared-archive-registry) when it is missing, and a node with no row counts as expired. Adoption and the departed-owner sweep consult the lease, never mesh membership, so a node the mesh lost but the database still hears keeps its claims, and a node the mesh still lists but the database no diff --git a/docs/deploy.md b/docs/deploy.md index b2a2b014c..831b242b1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -774,7 +774,7 @@ through `/v1/tenants` on any node. the DSN (4 suffices) so a fleet stays within the database's `max_connections`. - Every other shared table a node opens adds a query pool of the same size: - the pool set with a `cell`, the archive registry with `archive_failover`, + the pool set with a `cell`, the [archive registry](#shared-archive-registry) with `archive_failover`, and the usage table with `usage`. Count each in `max_connections`. - LISTEN needs a session-mode connection, so a connection pooler in front of the database must not run in transaction mode for that DSN. @@ -934,6 +934,41 @@ only; computing durations or prices stays with the consumer. - **Renaming** a node, or moving it to another cell, starts it with no rows: the node lands what its journal still holds again under the new name. +### Shared archive registry + +With `archive_failover`, each node keeps its archive and replica rows in +`sandboxd_archives` and its [node lease](cluster.md#node-lease) in +`sandboxd_nodes`, scoped by `meta_store.cell`. + +- **Schema:** sandboxd creates the tables at boot when they are missing; create + them ahead with the same DDL when the node's role has no `CREATE` right: + + ```sql + CREATE SEQUENCE IF NOT EXISTS sandboxd_archives_epoch; + CREATE TABLE IF NOT EXISTS sandboxd_archives ( + cell text NOT NULL, + id text NOT NULL, + tenant text NOT NULL DEFAULT '', + token_sha256 text NOT NULL, + owner text NOT NULL, + epoch bigint NOT NULL CHECK (epoch > 0), + consumed boolean NOT NULL DEFAULT false, + checkpoint text NOT NULL, + deadline timestamptz, + claim jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (cell, id) + ); + CREATE INDEX IF NOT EXISTS sandboxd_archives_deadline ON sandboxd_archives (cell, deadline) WHERE deadline IS NOT NULL; + CREATE INDEX IF NOT EXISTS sandboxd_archives_consumed ON sandboxd_archives (cell) WHERE consumed; + CREATE TABLE IF NOT EXISTS sandboxd_nodes ( + cell text NOT NULL, + node text NOT NULL, + lease_until timestamptz NOT NULL, + PRIMARY KEY (cell, node) + ); + ``` + ### Reloading the config `kill -HUP ` or a root `POST /v1/config/reload` From 94c8f6405a9256273d506d37f1cb51b4dc93ff7e Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:12 +0800 Subject: [PATCH 23/70] docs: deploy keeps the S3 layout and the replica bound in one place each The checkpoint_store row repeated the Reclaiming S3 storage section, which takes the facts only the row carried (lineage scope, marker order, re-stamped manifests); the TTL, replica-loss, tenant-outage and cluster-invariant passages become links to the pages that own them. --- docs/deploy.md | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index 831b242b1..fe0b87ad7 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -147,16 +147,16 @@ the intersection of the two. | `preview_secret` | — | cluster-shared HMAC secret signing preview tokens (all nodes share one) | | `preview_advertise` | = `preview_listen` | the browser-facing preview base URL, minted into every preview URL, so it must name a routable host (a wildcard `preview_listen` needs it set explicitly); nodes behind one TLS proxy may share it, while signed tokens route internally through each owner's `advertise_addr` | | `checkpoint_dir` | `/checkpoints` | where checkpoints and promoted templates live. Point it at a shared FUSE mount (JuiceFS over object storage, NFS) and every node sharing the mount can branch every checkpoint — records are generation-addressed with `meta.json` as the atomic commit pointer, so no cross-node locking is required of the filesystem. A re-publish retains its superseded export generation for at least ~1h and until a following hourly sweep so an in-flight clone that resolved the old metadata can finish; budget the current generation plus every generation retained across that grace-and-sweep window. An explicit delete can make a concurrent clone fail visibly. One contract on any shared root (mount or bucket): a template key has a single writer — promotes go to the sandbox's owner node, and operators must not race promotes of one name from different nodes (checkpoint ids are node-generated and never collide). Abandoned staging (`*.tmp`) is removed only once it is older than an hour, at startup and on the hourly generation sweep, so a publish in flight on another node of a shared root survives; the `meta.json` commit pointer is fsynced with its directory | -| `checkpoint_store` | dir | checkpoint AND promoted-template backend (both live in one store root, id-namespaced ck_/tp_): `{"kind": "s3", "s3": {"bucket": "…", "prefix": "ck/", "endpoint": "…", "region": "…", "force_path_style": true}}` stores checkpoints in object storage (any node claims any checkpoint, no shared mount needed). Credentials come from the standard AWS chain (env/IAM role), never this file. An export file is stored as 16 MiB chunks keyed by SHA-256 under `chunks//`, with one `manifests//.json` per published generation naming each file's size and present chunks, and `/meta.json` uploaded last as the commit marker (the record prefix exists only while the record is live). Holes and all-zero chunks are never uploaded, and a fetch recreates the file at its full size and writes only the present chunks, so a mostly-empty disk costs its data, not its size, on the wire and on the fetching node. A lineage is the dedup scope: it defaults to the record id, and every checkpoint and archive of one claim shares the claim's sandbox id, so consecutive captures upload only the chunks that changed (the first capture after a wake uploads everything again when no other live record of that claim remains, because the wake deletes the record it restored from); a publish into a shared lineage writes a zero-byte marker `lineages//` after its manifest and before `meta.json`, and reads only the records those markers list; a crash between manifest and marker only costs a missed dedup, and Delete removes the record's marker right away. Re-publish retains the prior generation's manifest so an in-flight fetch that selected old metadata can finish. sandboxd never deletes a chunk or a manifest from the bucket: Delete removes `meta.json`, the labels and the lineage markers and re-stamps the manifests, so a fetch that already selected the record still completes, and reclaiming storage is the operator's job, see [Reclaiming S3 storage](#reclaiming-s3-storage). A publish or fetch is aborted after 30 minutes. Do not put an expiry lifecycle rule on the prefix: it would delete chunks a live record still names. Budget storage for every generation and chunk until your reclaim runs. When caching a new generation, each node prunes other cached generations of that record unused for at least an hour. The periodic generation sweep drops a node's cache of a record once the record is gone from the bucket and the cache is unused for at least an hour. Absent = the dir backend at `checkpoint_dir` | -| `checkpoint_ttl_hours` | 0 (keep forever) | ages out checkpoints older than this; the sweep runs hourly and at startup. Explicit deletes never wait for it. Must be nonzero and match fleet-wide when `checkpoint_peer_heal` is on — it is the expiry eligibility point for a healed replica a delete broadcast missed, after which its next successful hourly sweep removes it; persistent sweep failure extends retention until one succeeds, so it is not a hard ceiling | -| `checkpoint_peer_heal` | false | on a cluster, lets a node pull a checkpoint it lacks from a peer — found via a live probe, not gossip — rather than failing the branch; see [placement lifecycle](cluster.md#checkpoints-on-a-cluster). Three requirements, all enforced at config load: a nonempty `api_token` (the blob transfer between peers authenticates with it; without one the raw record stream would be open), `mesh.cluster_key` set (the pull presents the fleet `api_token` to an address learned from the peer probe, so the gossip layer carrying that address must itself be authenticated), and `checkpoint_ttl_hours` nonzero (a replica a delete broadcast missed becomes eligible for expiry after it, and its next successful hourly sweep removes it — so it is the finite eligibility point, not an exact ceiling). A shared checkpoint store (`checkpoint_store` kind `s3`) ignores this setting — every node already resolves every checkpoint directly, so there is nothing to heal | +| `checkpoint_store` | dir | checkpoint AND promoted-template backend (both live in one store root, id-namespaced ck_/tp_): `{"kind": "s3", "s3": {"bucket": "…", "prefix": "ck/", "endpoint": "…", "region": "…", "force_path_style": true}}` stores checkpoints in object storage (any node claims any checkpoint, no shared mount needed). Credentials come from the standard AWS chain (env/IAM role), never this file. Holes and all-zero chunks are never uploaded, and a fetch recreates the file at its full size and writes only the present chunks, so a mostly-empty disk costs its data, not its size, on the wire and on the fetching node. Consecutive captures of one claim upload only the chunks that changed (the first capture after a wake uploads everything again when no other live record of that claim remains, because the wake deletes the record it restored from). sandboxd never deletes a chunk or a manifest from the bucket; [Reclaiming S3 storage](#reclaiming-s3-storage) has the key layout and the operator's job. A publish or fetch is aborted after 30 minutes. Do not put an expiry lifecycle rule on the prefix: it would delete chunks a live record still names. Budget storage for every generation and chunk until your reclaim runs. When caching a new generation, each node prunes other cached generations of that record unused for at least an hour. The periodic generation sweep drops a node's cache of a record once the record is gone from the bucket and the cache is unused for at least an hour. Absent = the dir backend at `checkpoint_dir` | +| `checkpoint_ttl_hours` | 0 (keep forever) | ages out checkpoints older than this; the sweep runs hourly and at startup. Explicit deletes never wait for it. Must be nonzero and match fleet-wide when `checkpoint_peer_heal` is on ([why](cluster.md#delete-is-eventual-not-a-fleet-wide-revocation)) | +| `checkpoint_peer_heal` | false | on a cluster, lets a node pull a checkpoint it lacks from a peer — found via a live probe, not gossip — rather than failing the branch; see [placement lifecycle](cluster.md#checkpoints-on-a-cluster). Three requirements, all enforced at config load: a nonempty `api_token` (the blob transfer between peers authenticates with it; without one the raw record stream would be open), `mesh.cluster_key` set (the pull presents the fleet `api_token` to an address learned from the peer probe, so the gossip layer carrying that address must itself be authenticated), and `checkpoint_ttl_hours` nonzero. A shared checkpoint store (`checkpoint_store` kind `s3`) ignores this setting — every node already resolves every checkpoint directly, so there is nothing to heal | | `warm_max` (pool entry) | 0 (static) | turns on the demand-adaptive watermark for that pool: the warm target rises from `warm` toward `warm_max` while claims arrive faster than the measured provision lead covers, and decays back over ~a minute of silence. The pool follows the target down: warm VMs above it are destroyed once the count has stayed above it for a full decay period, so a burst does not leave the pool parked at its high-water mark | | `warmup` (pool entry) | unset | argv run in the golden VM after readiness and before its snapshot, so the files it touches are page-cache-resident in every clone, and again in every clone before it joins the warm pool, so those pages are already faulted into the restored VM when the first command runs — e.g. `["node", "-e", "0"]` on a Node flavor. It runs under the engine's 2-minute command timeout in silkd's base environment (`PATH`, `TERM`, and the guest image's proxy variables wherever nothing routes directly — the none lane and the locked bridge egress lane — with the proxy not yet serving, since a door pre-bound at refill only starts serving at claim); a non-zero exit or a timeout fails the golden build, so the pool stays unfilled until the config is fixed. Config-owned like `egress`: `PUT /v1/pools` rejects it, and a golden built with a different warmup is rebuilt. The build then waits up to 30 s for the guest's systemd to finish starting, because a snapshot taken during boot makes every clone run the queued jobs before silkd answers. | | `capture_trim` (pool entry) | `false` | Trim the guest's copy-on-write disk before a promote or checkpoint of this pool's sandboxes (and of the clones of a template promoted from it), so the record carries only live data: blocks a build deleted — a `COPY` step's archive, a `RUN` step's caches — are no longer captured. sandboxd mounts the disk init resolved from `cocoon.cow` a second time at `/run/sandboxd-trim` through silkd as root, runs `fstrim` and unmounts, under the capture's transition lock (about 15 ms on a warm clone). A failed trim is logged and the capture proceeds, larger. A hibernated or archived sandbox is captured untrimmed. Config-owned like `egress`: `PUT /v1/pools` rejects it | | `reader` (pool entry) | `false` | Serve read-only file calls on this pool's hibernated or archived claims from a warm VM of the same pool instead of waking them; needs `warm` of at least 1 and a cocoon whose `vm disk attach` takes `--from-snapshot` and `--from-dir`. See [Reading sleeping claims](#reading-sleeping-claims-without-waking-them). Config-owned like `capture_trim`: `PUT /v1/pools` rejects it | | `egress_classes` | unset | named tenant egress layers: `[{"name": "desk", "egress": {…}, "egress_upstream_env": "DESK_UPSTREAM"}]`. A tenant names one with `egress_class` through the [tenant API](sandboxd-api.md#tenants-v1tenants) and its claims take that policy intersected with the pool's. `egress` is required; `intercept` stays a pool-rule flag. Reloadable: a class's policy reaches live claims on their next request. A class removed while tenants still name it leaves them with no egress and is logged at load and at reload | | `egress_upstream_env` (pool or egress class entry) | unset | names a node environment variable holding the default upstream proxy URL for the pool's or class's claims (class before pool, a claim's own entry before both); needs `egress_upstream`, and the URL must be on its `allow`. Read and checked at startup. Config-owned like `egress`: `PUT /v1/pools` rejects it | -| `replicate_seconds` (pool entry) | 0 (off) | snapshot every running claim of this pool to the checkpoint store this often and point the claim's registry row at the snapshot, so another node restores the claim, memory and processes included, when this node is lost (see [running claims across node loss](cluster.md#running-claims-across-node-loss)). Each round pauses the guest for the length of a memory and disk capture. A restore resumes from the last committed replica and loses everything after it: up to the longer of the interval and one replication pass, plus the 5-second tick, plus one more pass, since a claim can run first in one pass and last in the next. A pass covers all the node's due claims of the replicating pools with bounded concurrency, so it grows with claim count and guest memory; the first round of a claim after a wake or adoption uploads the whole claim, so that pass is the long pass. The claim's row carries no retention deadline while it runs. Requires `archive_failover` and a `none`-lane pool, both checked at config load; a claim with volumes is skipped. Config-owned like `egress`: `PUT /v1/pools` rejects it. Restart only: a claim is replicated by its pool's setting from the moment it is claimed, so a reload that changes it is refused (a pool restarted with 0 starts retiring its replicas while the registry remains enabled; see [Disabling archive failover](#disabling-archive-failover)) | +| `replicate_seconds` (pool entry) | 0 (off) | snapshot every running claim of this pool to the checkpoint store this often and point the claim's registry row at the snapshot, so another node restores the claim, memory and processes included, when this node is lost (see [running claims across node loss](cluster.md#running-claims-across-node-loss)). Each round pauses the guest for the length of a memory and disk capture. A restore loses what ran after the last committed replica: [the bound](cluster.md#running-claims-across-node-loss). The claim's row carries no retention deadline while it runs. Requires `archive_failover` and a `none`-lane pool, both checked at config load; a claim with volumes is skipped. Config-owned like `egress`: `PUT /v1/pools` rejects it. Restart only: a claim is replicated by its pool's setting from the moment it is claimed, so a reload that changes it is refused (a pool restarted with 0 starts retiring its replicas while the registry remains enabled; see [Disabling archive failover](#disabling-archive-failover)) | | `storage` (pool entry) | unset (cocoon's default, 10G) | size of the copy-on-write disk the pool's golden is cold-booted with, in cocoon's own `--storage` spelling (`"40G"`, `"40GiB"`, `"40Gi"`: Docker/Kubernetes units, all binary) and passed to it verbatim; every clone, fork, checkpoint branch and promoted template of the pool inherits it. At least cocoon's default of 10G. Equal sizes spelled differently build the same golden; a changed size rebuilds the golden on the next build, and claims already out keep their disk. Config-owned like `egress`: `PUT /v1/pools` rejects it | | `max_claims` | 0 (unlimited) | node-wide cap on live claims; claim/fork/branch requests beyond it answer 429 with the pool state unharmed (on a cluster a non-volume claim first tries a redirect to a warm peer, else to a peer that serves the pool; a volume claim answers the 429 with no redirect) | | `max_running` | 0 (off) | node-wide cap on claims that hold a VM: not hibernated, not archived, not failed, with a wake in flight counted; claim, fork, branch and wake beyond it answer 429 and leave the claim as it was; a claim takes the same redirect as `max_claims`. Hibernated and archived claims hold no slot, so `max_claims` can follow disk while this follows RAM and CPU. Warm and reader VMs are not counted: keep `max_running` × the largest size plus every pool's warm target × its size within the node's RAM. A wake cannot move to another node, so its 429 stays until a running claim sleeps or goes. Lowering the cap stops nothing that runs. A [reload](#reloading-the-config) applies a change | @@ -409,9 +409,12 @@ service account as `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. GCS rewrites sandboxd never garbage-collects the S3 `checkpoint_store`. It only adds objects and, on a delete, removes the record's `meta.json`, labels and lineage -markers; chunks, manifests and everything a superseded record wrote stay in the -bucket until an operator job removes them. The `dir` backend is different: it -keeps its own local generation sweep. +markers and re-stamps its manifests, so a fetch that already selected the record +still completes; chunks, manifests, stale markers and everything a superseded +record wrote stay in the bucket until an operator job removes them. A re-publish +retains the prior generation's manifest so an in-flight fetch that selected the +old metadata can finish. The `dir` backend is different: it keeps its own local +generation sweep. **Key layout** (`` is the configured prefix, ending in `/`): @@ -420,11 +423,8 @@ keeps its own local generation sweep. | `/meta.json` | The live record, uploaded last as the commit marker. Absent once the record is deleted. Its current generation is `export-` plus the first 16 hex characters of the SHA-256 of the object body. | | `/labels.json` | Optional labels, removed with the record. | | `manifests//.json` | One JSON manifest per published generation: `lineage` and, per file, `path`, `size` and the present `chunks` (`index`, `sha256`). `` is `export-<16 hex>`. | -| `chunks//` | A 16 MiB chunk. A chunk is shared by every record of its lineage that names the same digest, and only by those. | -| `lineages//` | Zero-byte marker: record `` published into ``. It only drives dedup, never chunk liveness. | - -**What sandboxd never deletes:** chunks, manifests of deleted records, -superseded generations of a live record, and stale markers. +| `chunks//` | A 16 MiB chunk. A lineage is the dedup scope: it defaults to the record id, and every checkpoint and archive of one claim shares the claim's sandbox id. A chunk is shared by every record of its lineage that names the same digest, and only by those. | +| `lineages//` | Zero-byte marker: record `` published into ``, written after the manifest and before `meta.json`. A publish into a shared lineage reads only the records these list, so a crash between manifest and marker only costs a missed dedup. It only drives dedup, never chunk liveness. | **Offline reclamation only.** Stop every sandboxd node using the prefix and wait for all in-flight publishes, deletes and fetches to finish. Keep the @@ -521,8 +521,8 @@ here validates on load: Secret values come from the environment named by `value_env` (here `GH_TOKEN`), never the file. The `egress_ca` files are provisioned with `sandboxd ca` — see [Guarded egress](egress.md#https-interception). On a -cluster, `api_token`, `preview_secret`, and `cluster_key` must match on -every node, and so must the tenant set. +cluster, some of these must match on every node: see +[cluster-invariant config](cluster.md#cluster-invariant-config). ### High-density no-network pools @@ -779,11 +779,8 @@ through `/v1/tenants` on any node. - LISTEN needs a session-mode connection, so a connection pooler in front of the database must not run in transaction mode for that DSN. - A write runs in one transaction with a `pg_notify`. -- **Outage:** - - running claims, exec and egress keep working; - - cached tenants authenticate; - - uncached ones answer 503, and tenant writes fail; - - recovery needs no restart. +- **Outage:** running claims, exec and egress keep working, and recovery needs + no restart; [Tenants](cluster.md#tenants) has the rest. - **Quota:** `max_claims` stays a per-node cap, read from the shared row. ### Shared pool set From cde9191b084c6b9bb3e571f54997d690d0be45e8 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:12 +0800 Subject: [PATCH 24/70] docs: cluster lists registered volumes and the TTL, and says when the digest covers secrets An operator rebuilding data_dir from config.json lost runtime-volumes.json silently, and gauge 0 read as a check of two secrets a keyless digest never covers. --- docs/cluster.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index fd698abb4..43357ca43 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -451,8 +451,8 @@ stay in the store until the operator's [offline reclamation](deploy.md#reclaiming-s3-storage) removes them. With replication this is the fastest-growing part of the store: roughly the per-round churn times the rounds since the last reclamation, per replicated claim, on top of the live replica. -The first round after a wake or adoption uploads the whole claim again, because -the wake deletes the record it restored from; later rounds dedup. Resolving a +Later rounds dedup; the first after a wake or adoption does not, because the +wake deletes the record it restored from. Resolving a claim for a stream of a pool that does not replicate reads one plain field and no atomic; a replicated claim adds one atomic load of the fence state and its stream one watcher goroutine. Adding a claim to a failover node gains one map @@ -543,6 +543,7 @@ fully from it: | operator config (`volumes`, `secrets`, egress policies, `bridges`/`networks`, `mesh`, `preview_secret`, `egress_ca`) | `config.json` (human/deploy-tool owned) | re-read at boot; its reloadable part on a per-node [reload](deploy.md#reloading-the-config) | | API-applied pool targets (`PUT /v1/pools`) | `/pools.json` (machine owned), or the cell's row in the shared `meta_store` when the node sets `meta_store.cell` | yes | | API-applied tenants (`/v1/tenants`) | `/tenants.json` (machine owned), or the shared `meta_store` table | yes | +| API-registered volumes (`PUT /v1/volumes/{name}`) | `/runtime-volumes.json` (machine owned, per node) | yes | | claims | the claims journal + `Reconcile` | yes | | placement hints (warm counts, template and volume sets) | gossip | rebuilt | | checkpoint ownership | a live per-request probe (no gossip); a healed replica is this node's own persisted copy, aged out by `checkpoint_ttl_hours` | yes | @@ -636,6 +637,7 @@ Some config must match on every node or the cluster fails in confusing ways: | `mesh.cluster_key` | nodes cannot join / decrypt gossip at all | | `egress_ca` cluster root | a guest checkpointed/redirected across nodes trusts the root; a divergent root fails interception | | egress: `egress_classes`, each pool's `egress` and `egress_upstream_env`, `secrets` names, headers and `value_env`, `egress_internal_allow`, `egress_upstream` | a claim or tenant redirected to a divergent node reaches different hosts, and its [claim credentials](egress.md#claim-credentials) are refused or not injected there | +| `checkpoint_ttl_hours` | a healed replica a delete broadcast missed ages out at a different time on each node, and nodes report a digest mismatch until every node has reloaded the same value | | the engine root path (cocoon's `root_dir`) | a checkpoint or template export pins the base image blobs' absolute path under the root that captured it; a node whose root sits at another path refuses to clone a record it healed or pulled (`untrusted storage path in snapshot metadata`) — the heal succeeds, the branch fails. Not part of the gossiped digest below | Each node gossips a digest of these over its live tenant set and quotas (HMAC-keyed by @@ -687,7 +689,9 @@ with every claim. - the same engine root path (cocoon's `root_dir`) on every node; it is not in the digest, so a mismatch shows only at the first cross-node branch - same `api_token`, tenant set, `preview_secret`, and `egress_ca` root everywhere - (a mismatch warns and shows in `sandboxd_config_digest_mismatch`) + (a mismatch warns and shows in `sandboxd_config_digest_mismatch`; only with + `cluster_key` set does that cover `api_token`, `preview_secret` and tenant + tokens) - with `meta_store`, every node's DSN points at the same database: the tenant set is not in the gossiped digest, so nodes on different databases serve diverging tenants without a warning From 6ef720be4f100ccd48c8c123153e9e03c7271db3 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:12 +0800 Subject: [PATCH 25/70] docs: security names the TLS edge, the re-admitted prefix and the shared usage table Stated as hard constraints these ruled out what deploy ships: a TLS edge for SDK clients, egress_internal_allow, and meta_store.usage. --- docs/security.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/security.md b/docs/security.md index 588ee190d..900d039c8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -34,7 +34,9 @@ exposing any part of a deployment beyond a single trusted host. loopback, private, link-local (cloud metadata), CGN, the IPv4-embedding IPv6 ranges and the node's own interface addresses, so an allow-listed name that resolves or rebinds to an internal address cannot - reach the host or a sibling. + reach the host or a sibling. A prefix the operator re-admits with + `egress_internal_allow` is the exception: scope it with ports + ([egress](egress.md)). - **What never enters the guest.** Egress credentials: the proxy injects the secret host-side, so prompt injection can exfiltrate at most the proxy's answers, and every credentialed call is journaled. Git auth @@ -55,12 +57,12 @@ all of them. serves plain HTTP; `api_token`, tenant tokens, and per-sandbox tokens are cleartext bearers on the wire, and on a cluster the SDK dials every node it is redirected to. Keep nodes and clients inside a VPC, WireGuard - mesh, or equivalent. The only surface designed to face a browser is the - preview listener, and it belongs behind a TLS-terminating proxy - ([deploy](deploy.md#preview-urls)). Never expose `listen` publicly. -2. **One sandboxd per host.** The restart sweep owns the whole - `sandbox_egress_*` nftables namespace; a second daemon would clear the - first's locks. + mesh, or equivalent, or put a TLS edge in front of every node + ([TLS for SDK clients](deploy.md#tls-for-sdk-clients)). The only surface + designed to face a browser is the preview listener, and it belongs behind a + TLS-terminating proxy ([deploy](deploy.md#preview-urls)). Never expose + `listen` itself publicly. +2. **One sandboxd per host** ([why](egress.md#deployment-constraints)). 3. **Set `mesh.cluster_key` unless the gossip network is itself trusted**, and open the memberlist port node-to-node only. 4. **Treat the checkpoint store like backups.** A checkpoint, an archive and @@ -156,9 +158,12 @@ Facts to plan around, stated so the boundary is honest: ([egress](egress.md#https-interception)). - **The audit and usage journals are local JSONL** with size rotation — no tamper-evidence. Ship them off-node if integrity against a host - compromise matters (at which point the journals are the least of it). -- **Rate limiting is capacity-based only** (`max_claims`, per-tenant - caps answer 429). The API assumes callers inside the trust boundary; + compromise matters (at which point the journals are the least of it). With + `meta_store.usage` the usage journal also lands in the + [shared usage table](deploy.md#shared-usage-table); the audit journal stays + local. +- **Rate limiting is capacity-based only** (`max_claims`, `max_running`, + `disk_reserve` and per-tenant caps answer 429). The API assumes callers inside the trust boundary; front it with your own limiter if semi-trusted automation can reach it. - **A dead node's running sandboxes die with it** unless their pool replicates them (`replicate_seconds`): another node then restores the last From 8a4aeba355347d16c2a324cfd0cac347e21d832f Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:12 +0800 Subject: [PATCH 26/70] docs: benchmarks and performance keep each caveat once --- docs/benchmarks.md | 5 ++--- docs/performance.md | 13 +++++-------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 4ab46c329..09b04377b 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -72,14 +72,13 @@ prints a markdown table stamped with the host evidence: virtualization (`systemd-detect-virt`), CPU, kernel, cocoon version, image digest. On exit it removes every `sbx-*` VM on the cocoon root, a serving sandboxd's claims included, so run it only on a node that serves no claims. +The daemon also reclaims every `sbx-` VM at start. Knobs (environment variables): `WARM`/`WARM_N` (warm-pool depth and burst size — the burst must stay within the depth, or refill loses the race and the tail measures clones), `CLONE_N`, `COLD_N`, `RPC_N`, `PULL_MB`, `PULL_N`, `BURST_N` (concurrent clone-claim burst; 0 skips the stage), -`ADDR`/`TOKEN` (the throwaway daemon's listen address and token). Run the -harness on a node with no live sandboxd: the daemon reclaims every `sbx-` VM at -start, and the harness removes every `sbx-` VM at exit. +`ADDR`/`TOKEN` (the throwaway daemon's listen address and token). Boot anatomy (where inside the cold tier the milliseconds go — kernel, initramfs phases, rootfs handoff) has its own harness: diff --git a/docs/performance.md b/docs/performance.md index d52a30044..bb3cd9f35 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -7,10 +7,8 @@ Reproduce with `make bench` (tier definitions and the dated results log: latencies print per run), and `scripts/boot-bench.sh` (boot phases). **Bare metal** = a 16-core AMD (SVM) node, Ubuntu 24.04, kernel 6.x, local -NVMe, KVM. **Nested** = a cloud VM with nested virtualization; nested -inflates VM-lifecycle latencies ~2.4× and punishes restore harder than cold -boot — do not compare nested numbers against other systems' bare-metal -claims. +NVMe, KVM. **Nested** = a cloud VM with nested virtualization, never comparable +with bare-metal claims ([why](benchmarks.md#the-three-claim-tiers)). ## Claim latency (what the SDK sees) @@ -190,10 +188,9 @@ Reconcile uses) stays as a regression sentinel. RPCs): dial-per-RPC measured p50 1.23ms / p90 4.69ms / p99 10.51ms; one connection pre-dialed ahead measured p50 1.05ms / p90 4.47ms / p99 15.49ms. That experiment rejected the background pre-dialer because its ~0.2ms p50 win -did not survive the tail. Current main instead implements protocol-level -sequential reuse: silkd proto 2 serves RPCs back to back and each SDK handle -parks completed relay connections for its next call. This is neither the -pre-dialer nor concurrent multiplexing. `e2e/cmd/rpcbench` reports A (dial +did not survive the tail. Current main instead implements +[sequential reuse](#relay-connection-reuse), neither the pre-dialer nor +concurrent multiplexing. `e2e/cmd/rpcbench` reports A (dial per RPC) and C (SDK keep-alive); the numbers in Relay connection reuse above, the pre-dialed spare row included, keep the pre-dialer declined — behind a TLS edge it tracks the dial arm, because it hides a handshake only while RPCs From e008046a1de518fd0bb1a0a25d06647042c79c13 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:54 +0800 Subject: [PATCH 27/70] docs: api states redirects, operator tokens, fork env and the checkpoint list as the server does The mint endpoint never answers the 503 and 429 its paragraph listed: the browser request does. A template another node deleted stays in tplSet, so the cluster page's never-fires claim was false for that entry. --- docs/cluster.md | 3 ++- docs/sandboxd-api.md | 38 ++++++++++++++++++++++++++------------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index 43357ca43..0b3f80629 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -465,7 +465,8 @@ lives in its node's checkpoint store. On the default local-disk backend that means **only on its owner node**, and the mesh gossips each node's template set alongside its warm counts; on a shared store (a FUSE-mounted `checkpoint_dir` or the s3 backend) every node resolves every template -directly, so the gossip routing simply never fires. An export carries only +directly, so the gossip routing fires only for a template another node deleted +after this one started, which stays listed until this node restarts. An export carries only the sandbox's writable state — the base image blobs resolve from the claiming node's local store, pulled by digest when missing (`vm clone --pull`). Name-based calls route cluster-wide: diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index cadaa0d16..046d6a85c 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -20,7 +20,7 @@ All bodies are JSON. Three token kinds: Endpoints below that say "node API token" accept root or tenant unless marked root-only. Errors are `{"error": "message"}` with the status codes listed per endpoint. Request bodies are capped at 1 MiB; a larger one is -rejected with `400 {"error": "invalid request body"}`. +rejected with `400` and an error beginning `invalid request body`. ## POST /v1/claim @@ -58,7 +58,9 @@ Auth: `Authorization: Bearer ` (when configured). of the claim's length, so its next expiry archives it again; a call that wakes it between the hibernate and the export grants that fresh lease too. A failed hibernate or export at expiry keeps the claim and retries on every - reap tick (5 s); it never falls back to destroy. A volume claim or an egress-lane claim + reap tick (5 s); it never falls back to destroy. A failed claim (`failed` set, see + [dead VMMs](deploy.md#dead-vmms-and-host-restarts)) is destroyed at expiry even + under `archive`. A volume claim or an egress-lane claim cannot hibernate, so `archive` on one answers 409 with the same error as a hibernate - `no_redirect` is set by the SDK when retrying at a redirect target @@ -203,8 +205,10 @@ VM closes the devices. Redirects (mutually exclusive with the fields above) name peers to retry at — sent on a warm miss with warm peers, when the node lacks a golden for -the key but gossip names a template owner, and when the node is at -`max_claims` or `max_running` and a peer reports warm capacity or, failing that, serves the pool: +the key but gossip names a template owner, and when a claim without `volumes`, +`no_redirect` or `require_promoted` meets a 429 (the node at `max_claims` or +`max_running`, the calling tenant at its cap, the node draining, or the disk under +`disk_reserve`) and a peer reports warm capacity or, failing that, serves the pool: ```json {"redirect": ["10.0.0.6:7777", "10.0.0.7:7777"], @@ -554,7 +558,8 @@ already holds the new lease, and a resend replaces the row. ## POST /v1/sandboxes/{id}/fork Auth: the node `api_token` (Bearer) — forking creates node resources, like a -claim. The sandbox's own token rides in the body as the ownership proof: +claim. The sandbox's own token rides in the body as the ownership proof; the +root `api_token` with an empty body `token` acts by id as the operator: ```json {"token": "…", "count": 2, "ttl_seconds": 300, "claim_ref_prefix": "team-a/"} @@ -566,6 +571,9 @@ addresses the children by name; without it children carry no `claim_ref`. The prefix is at most 512 bytes of printable UTF-8, as `claim_ref` is. Children inherit the parent's `metadata`, `on_expire` and `"egress": false`; an `on_expire` in the body overrides the inherited action. +Children do not inherit the claim env: a guest env file captured from the parent +is rewritten empty in each child, and the parent's host-only entries are not +copied; set one with `PUT /v1/sandboxes/{id}/env`. Clones the sandbox into `count` fresh claims (1 up to the node's `max_fork_count`, default 16). Memory, disk, and guest state (sessions, processes, tmpfs) duplicate at the fork point; cocoon's @@ -591,7 +599,8 @@ under `disk_reserve`. ## POST /v1/sandboxes/{id}/promote Auth: like fork — node `api_token` in the header, the sandbox's own token in -the body: +the body; the root `api_token` with an empty body `token` acts by id as the +operator: ```json {"token": "…", "template": "myproj:v1"} @@ -822,7 +831,8 @@ Unlike fork, promote and checkpoint — where the root token with an empty body token acts by id as the operator — preview has no operator path: the body token is required. Mints a signed URL serving a guest HTTP port from a browser: body `{"token": "...", "port": 8080, -"ttl_seconds": 0}` → `{"url": "http:///p//"}`. +"ttl_seconds": 0}` → `{"url": "/p//"}` (`http://` is +prefixed when `preview_advertise` names no scheme). The URL's life is clamped to the claim's remaining lease; an archived claim kept forever (`archive_delete_after_seconds: 0`) has no lease, so there the requested `ttl_seconds` stands unclamped and `ttl_seconds: 0` mints a one-hour @@ -831,13 +841,14 @@ URL; releasing the sandbox ends it early either way. has no `preview_listen`. The signed token embeds the sandbox id, port, and owner `advertise_addr`, so any node's preview listener can serve it (forwarding to the owner's main listener) and a released sandbox's URL simply stops -resolving — no revocation list. A request for a replicated claim answers 503 -`lease lost; retry` while its node's lease is overdue, and a request that has to wake a sandbox answers 429 while the disk reserve is low or the node is at `max_running`. See [deploy](deploy.md#preview-urls). +resolving — no revocation list. A browser request to the minted URL answers 503 +`lease lost; retry` for a replicated claim while its node's lease is overdue, and 429 when it has to wake a sandbox while the disk reserve is low or the node is at `max_running`. See [deploy](deploy.md#preview-urls). ## POST /v1/sandboxes/{id}/checkpoint Auth: node API token; body `{"token": "", "name": "..."}` -(name optional). Captures the sandbox's full state without stopping it and +(name optional); the root `api_token` with an empty body `token` acts by id as +the operator. Captures the sandbox's full state without stopping it and answers `200 {"checkpoint": {id, name, sandbox_id, key, tenant?, created_at}}` — `tenant` records the calling tenant, absent for root. 400 bad body or name, 401 bad api token, 404 unknown id or wrong sandbox @@ -897,7 +908,9 @@ part of the public API; an SDK caller has no reason to call it directly. Auth: node API token. Lists this node's checkpoints, newest first. A tenant sees only its own records; root sees everything. A checkpoint backing an -archived claim is that claim's wake image, not a listable record. +archived claim is that claim's wake image, not a listable record. Answers +`200 {"checkpoints": [{id, name, sandbox_id, key, tenant?, created_at}]}`, the +record shape `POST /v1/sandboxes/{id}/checkpoint` returns. ## DELETE /v1/checkpoints/{id} @@ -1311,7 +1324,8 @@ size tier, its [`labels`](#put-v1templateslabelstemplatenetsize) when any are set, and `tenant` when a tenant promoted it (omitted for the operator). A key a configured pool now owns is left out, since claims of it clone the pool's golden. On a shared checkpoint store a node lists what it promoted plus what -was in the store when it started. A template published by an older sandboxd is +was in the store when it started, and a template another node deletes later +stays listed, and may redirect a claim to a 404, until this node restarts. A template published by an older sandboxd is listed once it is re-promoted. ## GET /v1/peers From a0c91b916a367818407eee1a94d358f1668019ba Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:54 +0800 Subject: [PATCH 28/70] docs: api lists the errors of a call that reaches a guest once Wake, agent, ports and exec carried the same four tails. Restore failures, 409s, release, hibernate and the preview listener differ, so they keep their own lines. --- docs/sandboxd-api.md | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index 046d6a85c..ffb325959 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -22,6 +22,21 @@ marked root-only. Errors are `{"error": "message"}` with the status codes listed per endpoint. Request bodies are capped at 1 MiB; a larger one is rejected with `400` and an error beginning `invalid request body`. +## Guest errors + +Wake, the agent relay, the port relay and exec restore a sleeping sandbox before +they reach its guest, so they share these answers: + +- 403 a removed tenant's archive +- 429 while the disk reserve is low or the node is at + [`max_running`](deploy.md#configuration) +- 503 `lease lost; retry` for a replicated claim while its node's lease is + overdue ([running claims](cluster.md#running-claims-across-node-loss)) +- 503 `sandbox moving to another node; retry` while an + [evacuation](#post-v1drain) has yet to move it +- 503 `archive registry down; retry` for an archived claim under + `archive_failover` while the meta store is down + ## POST /v1/claim Auth: `Authorization: Bearer ` (when configured). @@ -519,11 +534,8 @@ while stored, and waking it starts a fresh lease of the length the claim was gra [failed](deploy.md#dead-vmms-and-host-restarts) sandbox is cold-booted from its own disk (memory is lost) and its restart budget resets. 204 on success, 404 unknown id or wrong token, 409 a failed egress-lane or volume -sandbox, or one whose VM record is gone, 403 a removed tenant's archive, -429 while the disk reserve is low or the node is at [`max_running`](deploy.md#configuration), 500 when the cold boot or a restore fails, -503 `lease lost; retry` for a replicated claim while its node's lease is overdue, -503 `sandbox moving to another node; retry` while an [evacuation](#post-v1drain) has yet to move it, -503 `archive registry down; retry` for an archived claim under `archive_failover` while the meta store is down. +sandbox, or one whose VM record is gone, 500 when the cold boot or a restore +fails, and the [guest errors](#guest-errors). ## POST /v1/sandboxes/{id}/renew @@ -1161,11 +1173,9 @@ ends after about two minutes of failed TCP keepalives. Frames sent right behind before the 101, reach silkd in order; the Go and Python SDKs send their first frames that way over plain HTTP, so a proxy in front of a plain-HTTP endpoint must forward them too (Go's `httputil.ReverseProxy` drops them; Caddy forwards -them). 426 without the upgrade header, 403 a removed tenant's archive, 404 -unknown sandbox, 409 a failed sandbox, 429 while the disk reserve is low or the node is at `max_running`, 500 a -failed restore, 502 guest unreachable, 503 `lease lost; retry` for a replicated claim -while its node's lease is overdue (see -[running claims](cluster.md#running-claims-across-node-loss)), 503 `archive registry down; retry` for an archived claim under `archive_failover` while the meta store is down. A relay of a +them). 426 without the upgrade header, 404 unknown sandbox, 409 a failed sandbox, +500 a failed restore, 502 guest unreachable, and the +[guest errors](#guest-errors). A relay of a claim of a replicating pool that is already open when the lease turns overdue is closed, whether or not it has a replica yet. @@ -1226,9 +1236,8 @@ fork in flight, answers `409` at once; an open passive relay still holds the san 400 a port outside 1-65535; 401 missing bearer token; 404 unknown sandbox or wrong token; 409 a failed sandbox or a passive relay to a paused one; 426 without the upgrade header; 502 when the guest cannot be reached on that port — no listener, or a -hibernated sandbox whose restore failed. The node log carries which; 403 a -removed tenant's archive; 429 while the disk reserve is low or the node is at `max_running`; 503 -`lease lost; retry` for a replicated claim while its node's lease is overdue; 503 `archive registry down; retry` for an archived claim under `archive_failover` while the meta store is down. +hibernated sandbox whose restore failed. The node log carries which; and the +[guest errors](#guest-errors). ## POST /v1/sandboxes/{id}/exec @@ -1253,9 +1262,8 @@ does not exist are the caller's, not 502s; 401 missing bearer token; 404 unknown 413 when stdout+stderr exceed 8 MiB; 503 with `Retry-After: 1` when the node's buffered-exec memory is exhausted (64 MiB of output across every buffered exec in flight; the command is killed); 502 guest unreachable or any other -silkd error; 403 a removed tenant's archive; 409 a failed sandbox; 429 while the -disk reserve is low or the node is at `max_running`; 500 a failed restore; 503 `lease lost; retry` for a replicated claim -while its node's lease is overdue; 503 `archive registry down; retry` for an archived claim under `archive_failover` while the meta store is down; 504 when the command outlives `timeout_seconds`. A client that hangs up first gets no reply and the command is killed. A hibernated sandbox wakes transparently like on the relay. +silkd error; 409 a failed sandbox; 500 a failed restore; the +[guest errors](#guest-errors); 504 when the command outlives `timeout_seconds`. A client that hangs up first gets no reply and the command is killed. A hibernated sandbox wakes transparently like on the relay. ## GET /v1/sandboxes/{id}/owner From 2b3013bede1748a6b1a5020ec66324f0c7e45674 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:54 +0800 Subject: [PATCH 29/70] docs: sdk, silkd, langchain and android match what the code does Go Exec runs the argv given, with no implicit shell; every control-plane verb returns APIError; the checkpoint option refusals and a fork child's env were incomplete. --- docs/android.md | 5 +++-- docs/langchain.md | 3 +++ docs/sdk.md | 8 +++++++- docs/silkd.md | 3 ++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/android.md b/docs/android.md index 922bf2cae..39ca1fc79 100644 --- a/docs/android.md +++ b/docs/android.md @@ -40,8 +40,9 @@ ln, err := sb.ProxyPort(ctx, "127.0.0.1:6555", 5555) ## What works, what differs -- `sb.Exec` runs `/system/bin/sh` — there is no bash and no glibc, so the - session verbs (persistent bash) are unavailable; prefix commands with +- There is no bash and no glibc, so pass `/system/bin/sh` as argv[0] + (`sb.Exec(ctx, "/system/bin/sh", "-c", ...)`), and the session verbs + (persistent bash) are unavailable; prefix commands with `PATH=/system/bin:/system/xbin:$PATH` when calling tools directly. - Files, port relay, and watch verbs work as on the Ubuntu flavors. - Checkpoint/branch of a booted Android works: a branch answers `ps` and diff --git a/docs/langchain.md b/docs/langchain.md index fa59dd3ad..56aeb3c24 100644 --- a/docs/langchain.md +++ b/docs/langchain.md @@ -14,6 +14,9 @@ with CocoonToolkit("10.0.0.5:7777", api_token="...", agent.invoke({"messages": [("user", "clone the repo and run the tests")]}) ``` +Parameters: `template` (default `rt:24.04`), `net` (`none` or `egress`; empty +takes the node default `none`), `ttl_seconds` (default 3600), `from_checkpoint`. + The toolkit claims its sandbox lazily on the first tool call and releases it when the context manager exits. Tools (all `StructuredTool`s with typed schemas, sync-native with `asyncio.to_thread` async bridges): diff --git a/docs/sdk.md b/docs/sdk.md index 462972c0a..80b848a8a 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -234,8 +234,10 @@ call on a handle answers 404 once its sandbox has moved; run `Lookup` to tell that from a released sandbox. Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane sandboxes cannot either, nor take `WithArchiveOnExpire`; each answers 409. Passing -`WithVolumes` or `WithClaimRef` to `Checkpoint.New` returns a local error: +`WithVolumes`, `WithVolumesAttachOnly` or `WithClaimRef` to `Checkpoint.New` returns a local error: checkpoint branches support neither volumes nor a claim reference in this version. +`WithNetwork` or `WithSize` to `Checkpoint.New` or `Template.New` does too: the +snapshot pins the lane and size. `WithMetadata`, `WithArchiveOnExpire` and `WithEnv` apply to `New`, `Template.New`, and `Checkpoint.New`: the labels follow the [claim bounds](sandboxd-api.md#post-v1claim), and a branch never inherits its source's labels or expiry action. @@ -761,6 +763,10 @@ tenant. ## Error handling - `*sandbox.ExitError` — non-zero exit from `Exec` (`Code`, `Stderr`) +- `*sandbox.APIError` — a non-2xx control-plane reply (`Verb`, `Status`, + `Message`); claim, fork, checkpoint, renew, env and tenant verbs return it, and + a 404/429/409 is matched on `Status`. Volume calls wrap it in + `*sandbox.VolumeError` - `*wire.ErrorResp` — a typed guest-side failure; `Kind` is one of `wire.KindBadRequest` (including a spawn the guest cannot start: missing binary, a cwd that is not a directory, no exec bit, a `User` the guest diff --git a/docs/silkd.md b/docs/silkd.md index 65ad6466c..5948f8f00 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -110,7 +110,8 @@ and when the same env is sent again; a change to host-only entries never touches it. A unit that must follow a change restarts on it, for example from a `.path` unit. The file is in the guest's memory, so it survives hibernate and archive; a branch, fork child or promoted-template clone whose source had -guest entries gets its own file rewritten at claim. An +guest entries gets its file rewritten at claim with its own claim's entries +(none for a fork child). An older silkd ignores the file; units that read it still see it. ## Limits From a7737b4b5ed0a63a5b9690137342684ea03000a2 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:54 +0800 Subject: [PATCH 30/70] docs: the sdk pages link the volume and deadline rules the api page owns Go-specific and Python-specific names stay. The settled-entry sentence stays on the Go page, its only source. --- docs/sdk-python.md | 38 +++++++---------------------- docs/sdk.md | 60 +++++++++++++--------------------------------- 2 files changed, 25 insertions(+), 73 deletions(-) diff --git a/docs/sdk-python.md b/docs/sdk-python.md index 93f0a6d80..1e6835234 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -127,25 +127,14 @@ Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane sandboxes cannot either; each answers 409. Checkpoint branches do not accept volumes of either mode in this version. -`mount=False` on `client.new` or `template.new` claims the same volumes -without mounting them: the dictionaries in `sb.volumes` carry no `mount` key, -and the workload finds each device by polling `/sys/block/*/serial` for the -catalog name — not guaranteed present when the claim returns, typically -within ~100ms — then confirms the `/dev/` node itself exists before -mounting. Everything above describes the default (`mount=True`) and is -unchanged by it. What changes is that the mount and its consistency are -entirely yours: sandboxd writes and clears no dirty marker for an attach-only -`rw` claim, because it cannot verify your unmount, so releasing without -unmounting cleanly leaves the image as a crash would — see -[sandboxd-api](sandboxd-api.md#attach-only-volumes) for the full contract. +`mount=False` on `client.new` or `template.new` leaves the mount to you: the +dictionaries in `sb.volumes` carry no `mount` key; see +[attach-only volumes](sandboxd-api.md#attach-only-volumes) for finding the device +and the consistency contract. For a root client managing a tenant claim, see the [external volume controller example](volume-controller.md). It keeps the controller reservation and access gate across detach, hibernate, and wake. -The caller-visible constraints are deliberate: volume claims may consume a -warm VM, remain non-capturable, mount read-only by default, and require Cloud -Hypervisor. - `client.volumes()` returns the fleet entries this token may use: ```python @@ -158,12 +147,8 @@ for volume in client.volumes(): `writable` is present (and `true`) only for a writable entry; a read-only entry omits the key, so read it with `.get`, not `[...]`. -Discovery returns the gossiped union and holder count; availability and size -describe the connected node. Warm candidates retain normal ranking, filtered to -nodes advertising every requested name. A promoted-template claim prefers a -peer advertising both the template and every volume; when that intersection is -empty, one volume holder may self-verify access to a shared template store -before provisioning. +Entry semantics and placement: [GET /v1/volumes](sandboxd-api.md#get-v1volumes), +[cluster](cluster.md#volumes-and-placement). ## Changing live volumes @@ -253,14 +238,9 @@ Streams (`watch`, `open_pty`, `dial_port`, an LSP session) take a connection of their own, and a guest whose silkd predates the back-to-back protocol gets one connection per call as before. -If that deployment also enables `archive_after_seconds`, archiving replaces -the original claim deadline with the archive-retention deadline (or no -deadline when archives are kept forever). Waking an archive starts a fresh -lease of the length the claim was granted (the server default when it asked -for none). The existing handle's `sb.deadline` remains the value -returned when that handle was created or last renewed; call -`client.sandboxes()` to read the current server deadline after an -archive/wake transition. +The handle's `sb.deadline` remains the value returned when it was created or last +renewed, so after an archive/wake transition read the current one from +`client.sandboxes()` ([wake](sandboxd-api.md#post-v1sandboxesidwake)). ## Forking diff --git a/docs/sdk.md b/docs/sdk.md index 80b848a8a..850ed2af6 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -290,36 +290,17 @@ the handle retains its last known sets. Retry an interrupted operation with the same full PUT target, or set a new target to recover. `Client.Sandboxes` exposes `VolumesWant` and `Volumes` for inspection with an API token. -PUT and PATCH can add or remove mounted and attach-only entries in either mode. -Changing a held entry's mount, mode, or `attach_only` requires successful -removal first. See the [live volume contract](sandboxd-api.md#put-v1sandboxesidvolumes) -for admission policy and dirty-marker handling. Paused sandboxes are not woken, -and images must exist on the owner node. Admission holds cover the union of -desired and applied sets. A settled retained entry receives no new attach or -mount calls. `converging` is true while a volume-change request is running. -Pending or detaching entries with `converging: false` require an explicit retry. -Daemon restart replaces unfinished diagnostics with -an interrupted message. Capture is refused while either set is nonempty. -Release cancels convergence and hands held volumes to teardown. See the -[API recovery contract](sandboxd-api.md#partial-failure-and-recovery) and -[same-sandbox replacement tutorial](live-volumes.md). A failed sandbox with -volume holds is release-only after VMM exit; an empty target cannot recover it. - -`WithVolumesAttachOnly()` claims the same volumes without mounting them: the -entries in `Sandbox.Volumes` carry an empty `Mount`, and the workload finds -each device by polling `/sys/block/*/serial` for the catalog name — not -guaranteed present when the claim returns, typically within ~100ms — then -confirms the `/dev/` node itself exists before mounting. Everything -above describes the default and is unchanged by this option. What changes is -that the mount and its consistency are entirely yours: sandboxd writes and -clears no dirty marker for an attach-only `rw` claim, because it cannot verify -your unmount, so releasing without unmounting cleanly leaves the image as a -crash would — see -[sandboxd-api](sandboxd-api.md#attach-only-volumes) for the full contract. - -The caller-visible constraints are deliberate: volume claims may consume a -warm VM, remain non-capturable, mount read-only by default, and require Cloud -Hypervisor. +What can change live, admission holds, retries, restart diagnostics, capture +refusal, the release hand-off and a failed sandbox's release-only rule are in the +[live volume contract](sandboxd-api.md#put-v1sandboxesidvolumes) and the +[API recovery contract](sandboxd-api.md#partial-failure-and-recovery), with a +[same-sandbox replacement tutorial](live-volumes.md). A settled retained entry +receives no new attach or mount calls. + +`WithVolumesAttachOnly()` leaves the mount to you: the entries in +`Sandbox.Volumes` carry an empty `Mount`; see +[attach-only volumes](sandboxd-api.md#attach-only-volumes) for finding the device +and the consistency contract. Discover the fleet entries this token may use before planning a claim: @@ -330,12 +311,8 @@ for _, volume := range catalog { } ``` -Discovery returns the gossiped union and holder count; availability and size -describe the connected node. Warm candidates retain normal ranking, filtered to -nodes advertising every requested name. A promoted-template claim prefers a -peer advertising both the template and every volume; when that intersection is -empty, one volume holder may self-verify access to a shared template store -before provisioning. +Entry semantics and placement: [GET /v1/volumes](sandboxd-api.md#get-v1volumes), +[cluster](cluster.md#volumes-and-placement). ## Claim env @@ -407,14 +384,9 @@ destroying it, whatever the deployment's archive settings: the node hibernates it if it is running and moves it to the checkpoint store, and the next call that reaches the guest restores it. `Fork` children inherit the setting. -If that deployment also enables `archive_after_seconds`, archiving replaces -the original claim deadline with the archive-retention deadline (or no -deadline when archives are kept forever). Waking an archive starts a fresh -lease of the length the claim was granted (the server default when it asked -for none). The existing handle's `Sandbox.Deadline` is the value -returned when that handle was created or last renewed; call -`Client.Sandboxes` to read the current server deadline after an -archive/wake transition. +The handle's `Sandbox.Deadline` is the value returned when it was created or last +renewed, so after an archive/wake transition read the current one from +`Client.Sandboxes` ([wake](sandboxd-api.md#post-v1sandboxesidwake)). ## Forking From 992a289339bd0a4bb5507d3a7dd85796a636a2eb Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:02:01 +0800 Subject: [PATCH 31/70] sandboxd: a preview listener that cannot bind is fatal A taken preview_listen was logged and ignored: the node stayed healthy while the preview endpoint kept minting URLs for a dead port. The main listener already exits on the same failure, and systemd Restart= is the retry loop. http.ErrServerClosed stays clean. --- sandboxd/main.go | 12 ++++++----- sandboxd/main_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 sandboxd/main_test.go diff --git a/sandboxd/main.go b/sandboxd/main.go index d8c1dd6b7..19c3dad85 100644 --- a/sandboxd/main.go +++ b/sandboxd/main.go @@ -178,11 +178,7 @@ func main() { } if preview != nil { previewSrv := &http.Server{Addr: cfg.PreviewListen, Handler: preview.Handler(), ReadHeaderTimeout: readHeaderTimeout} - go func() { - if err := previewSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { - logger.Error(ctx, err, "preview server") - } - }() + go servePreview(ctx, previewSrv) context.AfterFunc(ctx, func() { sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownGrace) defer cancel() @@ -211,6 +207,12 @@ func main() { logger.Info(ctx, "sandboxd stopped; VMs stay alive for the next reconcile") } +func servePreview(ctx context.Context, srv *http.Server) { + if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.WithFunc("main.servePreview").Fatalf(ctx, err, "preview server") + } +} + func startMesh(ctx context.Context, cfg *config.Config, mgr *pool.Manager) (*mesh.Mesh, error) { mc := cfg.Mesh mlCfg := memberlist.DefaultLANConfig() diff --git a/sandboxd/main_test.go b/sandboxd/main_test.go new file mode 100644 index 000000000..633aa5611 --- /dev/null +++ b/sandboxd/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "errors" + "net" + "net/http" + "os" + "os/exec" + "testing" + "time" +) + +const previewChildAddr = "SANDBOXD_TEST_PREVIEW_ADDR" + +func TestServePreviewExitsWhenTheListenerCannotBind(t *testing.T) { + if addr := os.Getenv(previewChildAddr); addr != "" { + servePreview(t.Context(), &http.Server{Addr: addr}) + return + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestServePreviewExitsWhenTheListenerCannotBind$") + cmd.Env = append(os.Environ(), previewChildAddr+"="+ln.Addr().String()) + err = cmd.Run() + if exit, ok := errors.AsType[*exec.ExitError](err); !ok || exit.ExitCode() == 0 { + t.Fatalf("child err = %v, want a non-zero exit when the preview port is taken", err) + } +} + +func TestServePreviewReturnsAfterShutdown(t *testing.T) { + srv := &http.Server{Addr: "127.0.0.1:0"} + done := make(chan struct{}) + go func() { + defer close(done) + servePreview(t.Context(), srv) + }() + if err := srv.Shutdown(t.Context()); err != nil { + t.Fatalf("shutdown: %v", err) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("servePreview did not return after Shutdown") + } +} From 35f6d4cef9458f217a6e73bb5bc61b8af6d1e4c6 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:02 +0800 Subject: [PATCH 32/70] tenants: the contract replaces the set with no tenants An empty Replace must delete every tenant on both backends: pgx sends a nil slice as NULL, so a Collect-built name list would delete nothing on pg. --- sandboxd/tenants/tenantstest/tenantstest.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sandboxd/tenants/tenantstest/tenantstest.go b/sandboxd/tenants/tenantstest/tenantstest.go index 1de7d784c..756d81c4d 100644 --- a/sandboxd/tenants/tenantstest/tenantstest.go +++ b/sandboxd/tenants/tenantstest/tenantstest.go @@ -181,6 +181,13 @@ func testDeleteReplace(t *testing.T, s tenants.Source) { if _, err = s.Resolve(ctx, Sum("tc")); !errors.Is(err, tenants.ErrUnknown) { t.Errorf("a tenant the replace dropped: %v, want ErrUnknown", err) } + c, err = s.Replace(ctx, nil) + if err != nil || !slices.Equal(c.Removed, []string{"a", "d"}) { + t.Fatalf("replace with no tenants: %+v %v, want a and d removed", c, err) + } + if _, err = s.Resolve(ctx, Sum("td")); !errors.Is(err, tenants.ErrUnknown) { + t.Errorf("a tenant the empty replace dropped: %v, want ErrUnknown", err) + } } func testReplaceSwap(t *testing.T, s tenants.Source) { From 0edfb807c5f39c093c7749abbd87e9c06ad55472 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:05:32 +0800 Subject: [PATCH 33/70] review: one owner for the max fork default, the routable-host test, the s3 store test and the tenant claim mapping config.DefaultMaxForkCount replaces the second literal in pool, config.RoutableHost replaces the copy in server.New, Config.S3Checkpoints replaces three spellings of the kind test, and forTenant replaces the tenant-to-options mapping written in both claim paths. Behaviour-preserving, net zero lines. --- sandboxd/backends.go | 6 +++--- sandboxd/config/config.go | 26 +++++++++++++++----------- sandboxd/pool/pool.go | 3 +-- sandboxd/pool/view.go | 2 +- sandboxd/server/server.go | 19 ++++++++----------- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/sandboxd/backends.go b/sandboxd/backends.go index 307a438d0..235c2efb5 100644 --- a/sandboxd/backends.go +++ b/sandboxd/backends.go @@ -27,7 +27,7 @@ func openBackends(ctx context.Context, cfg *config.Config) (pool.Backends, error if b.Checkpoints, err = newStoreView(ctx, cfg, "checkpoint-staging", store.CheckpointIDRe); err != nil { return pool.Backends{}, err } - b.SharedCheckpoints = cfg.CheckpointStore != nil && cfg.CheckpointStore.Kind == "s3" + b.SharedCheckpoints = cfg.S3Checkpoints() if b.Templates, err = newStoreView(ctx, cfg, "template-staging", store.TemplateIDRe); err != nil { return pool.Backends{}, err } @@ -64,8 +64,8 @@ func usageNode(cfg *config.Config) (string, error) { } func newStoreView(ctx context.Context, cfg *config.Config, staging string, idRe *regexp.Regexp) (store.Store, error) { - if cs := cfg.CheckpointStore; cs != nil && cs.Kind == "s3" { - return s3.New(ctx, *cs.S3, filepath.Join(cfg.DataDir, staging), idRe) + if cfg.S3Checkpoints() { + return s3.New(ctx, *cfg.CheckpointStore.S3, filepath.Join(cfg.DataDir, staging), idRe) } return dir.New(cmp.Or(cfg.CheckpointDir, filepath.Join(cfg.DataDir, "checkpoints")), idRe) } diff --git a/sandboxd/config/config.go b/sandboxd/config/config.go index 091a7d453..d4808be88 100644 --- a/sandboxd/config/config.go +++ b/sandboxd/config/config.go @@ -30,7 +30,7 @@ const ( defaultDataDir = "/var/lib/sandboxd" defaultCocoonBin = "cocoon" defaultWarm = 4 - defaultMaxForkCount = 16 + DefaultMaxForkCount = 16 refillFloor = 4 refillCeiling = 256 // cocoon builds a VM with a 10G disk by default, so a pool never asks for less @@ -290,6 +290,10 @@ func (c *Config) HasEgress() bool { return len(c.Bridges) > 0 || len(c.Networks) > 0 } +func (c *Config) S3Checkpoints() bool { + return c.CheckpointStore != nil && c.CheckpointStore.Kind == "s3" +} + func (c *Config) guardsEgressLane() bool { return len(c.EgressClasses) > 0 || slices.ContainsFunc(c.Pools, func(p PoolSpec) bool { return p.Net == types.NetEgress && p.Egress != nil }) } @@ -300,7 +304,7 @@ func (c *Config) applyDefaults() { c.CocoonBin = cmp.Or(c.CocoonBin, defaultCocoonBin) c.AdvertiseAddr = cmp.Or(c.AdvertiseAddr, c.Listen) c.PreviewAdvertise = cmp.Or(c.PreviewAdvertise, c.PreviewListen) - c.MaxForkCount = cmp.Or(c.MaxForkCount, defaultMaxForkCount) + c.MaxForkCount = cmp.Or(c.MaxForkCount, DefaultMaxForkCount) if c.RefillConcurrency == 0 { c.RefillConcurrency = autoRefillConcurrency(runtime.NumCPU()) } @@ -450,7 +454,7 @@ func (c *Config) validateClientAdvertise() error { (u.Path != "" && u.Path != "/") || strings.ContainsAny(c.ClientAdvertise, "?#") { return fmt.Errorf("client_advertise must be an http or https origin") } - if !routableHost(u.Hostname()) { + if !RoutableHost(u.Hostname()) { return fmt.Errorf("client_advertise must name a routable host") } if port := u.Port(); port != "" { @@ -477,7 +481,7 @@ func (c *Config) validateMesh() error { if err != nil { return fmt.Errorf("advertise_addr: %w", err) } - if !routableHost(host) { + if !RoutableHost(host) { return fmt.Errorf("advertise_addr %q is gossiped to peers and must name a routable host", c.AdvertiseAddr) } return nil @@ -613,7 +617,7 @@ func (c *Config) validateDiskReserve() error { func (c *Config) validateMetaStore() error { ms := c.MetaStore switch { - case c.ArchiveFailover && (ms == nil || ms.Cell == "" || c.CheckpointStore == nil || c.CheckpointStore.Kind != "s3" || c.Mesh == nil): + case c.ArchiveFailover && (ms == nil || ms.Cell == "" || !c.S3Checkpoints() || c.Mesh == nil): return fmt.Errorf("archive_failover requires meta_store with a cell, an s3 checkpoint_store and a mesh") case ms == nil: return nil @@ -681,6 +685,11 @@ func StorageBytes(s string) (int64, error) { return units.RAMInBytes(s) } +func RoutableHost(host string) bool { + ip, _ := netip.ParseAddr(host) + return host != "" && !ip.IsUnspecified() +} + func validatePolicy(p *egress.Policy, secrets map[string]struct{}) error { if p == nil { return nil @@ -733,10 +742,5 @@ func namesHost(addr string) bool { addr = "http://" + addr } u, err := url.Parse(addr) - return err == nil && routableHost(u.Hostname()) -} - -func routableHost(host string) bool { - ip, _ := netip.ParseAddr(host) - return host != "" && !ip.IsUnspecified() + return err == nil && RoutableHost(u.Hostname()) } diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index cbd225191..0aee29431 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -64,8 +64,7 @@ const ( refillBackoffBase = 250 * time.Millisecond refillBackoffMax = buildRetryDelay // Fallbacks when a Manager is built from a Config that skipped config.Load's defaulting - defaultMaxFork = 16 - defaultRefill = 4 + defaultRefill = 4 // A heal pulls a whole guest memory image, so a few saturate disk and NIC. maxConcurrentHeals = 4 diff --git a/sandboxd/pool/view.go b/sandboxd/pool/view.go index 6a84c10c0..525f9c532 100644 --- a/sandboxd/pool/view.go +++ b/sandboxd/pool/view.go @@ -45,7 +45,7 @@ func newConfigView(cfg *config.Config, secrets *egress.SecretStore) *configView maxClaims: cfg.MaxClaims, maxRunning: cfg.MaxRunning, - maxFork: cmp.Or(cfg.MaxForkCount, defaultMaxFork), + maxFork: cmp.Or(cfg.MaxForkCount, config.DefaultMaxForkCount), releaseDelay: time.Duration(cfg.ReleaseDelaySeconds) * time.Second, vmmRestart: cfg.VMMRestart, ckptTTL: time.Duration(cfg.CheckpointTTLHours) * time.Hour, diff --git a/sandboxd/server/server.go b/sandboxd/server/server.go index ebb3e0358..5b2baa23a 100644 --- a/sandboxd/server/server.go +++ b/sandboxd/server/server.go @@ -11,7 +11,6 @@ import ( "io" "net" "net/http" - "net/netip" "net/url" "slices" "sync" @@ -265,10 +264,8 @@ type Server struct { // New returns a Server; an empty apiToken leaves node-level endpoints open, and tenants resolve through mgr. func New(apiToken, advertise string, mgr Manager, dialer Dialer, placer Placer, prober CheckpointProber, probeKey []byte, preview *PreviewServer) *Server { - if host, _, err := net.SplitHostPort(advertise); err == nil { - if ip, _ := netip.ParseAddr(host); host == "" || ip.IsUnspecified() { - advertise = "" - } + if host, _, err := net.SplitHostPort(advertise); err == nil && !config.RoutableHost(host) { + advertise = "" } return &Server{ mgr: mgr, @@ -663,10 +660,7 @@ func (s *Server) handleClaimCheckpoint(w http.ResponseWriter, r *http.Request) { return } ckptID := r.PathValue("id") - o := pool.ClaimOptions{TTL: req.TTL(), OnExpire: req.OnExpire, Metadata: req.Metadata, Env: req.Env} - if tenant := tenantRecordFrom(r.Context()); tenant != nil { - o.Tenant, o.EgressClass = tenant.Name, tenant.EgressClass - } + o := forTenant(pool.ClaimOptions{TTL: req.TTL(), OnExpire: req.OnExpire, Metadata: req.Metadata, Env: req.Env}, tenantRecordFrom(r.Context())) sb, err := s.mgr.ClaimCheckpoint(r.Context(), ckptID, o) if errors.Is(err, pool.ErrUnknownCheckpoint) { if !req.NoRedirect && s.prober != nil && s.writeRedirect(w, s.prober.Owners(r.Context(), ckptID)) { @@ -930,10 +924,13 @@ func (s *Server) claimResponse(sb *types.Sandbox) ClaimResponse { } func claimOptions(req ClaimRequest, tenant *tenants.TenantRecord) pool.ClaimOptions { - o := pool.ClaimOptions{ + return forTenant(pool.ClaimOptions{ TTL: req.TTL(), OnExpire: req.OnExpire, ClaimRef: req.ClaimRef, Metadata: req.Metadata, Volumes: req.Volumes, NoEgress: req.Egress != nil && !*req.Egress, RequirePromoted: req.RequirePromoted, Env: req.Env, - } + }, tenant) +} + +func forTenant(o pool.ClaimOptions, tenant *tenants.TenantRecord) pool.ClaimOptions { if tenant != nil { o.Tenant, o.EgressClass = tenant.Name, tenant.EgressClass } From 9e339c68c6beb49ba0ead44a8e20323a54e185c9 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:07:16 +0800 Subject: [PATCH 34/70] review: store package docs fit the comment budget, constant errors use errors.New Package docs of store, s3 and peer shrink to one line (docs/deploy.md owns the key layout); the SweepStaging godoc and the redirectCacheEntry comment restate their names; three message-only fmt.Errorf become errors.New; VolumePatch is decode-only, so its slices follow the omitempty rule. --- sandboxd/store/digest.go | 3 ++- sandboxd/store/peer/probe.go | 1 - sandboxd/store/peer/transport.go | 6 ++---- sandboxd/store/s3/s3.go | 10 ++-------- sandboxd/store/store.go | 6 +----- sandboxd/types/api.go | 4 ++-- 6 files changed, 9 insertions(+), 21 deletions(-) diff --git a/sandboxd/store/digest.go b/sandboxd/store/digest.go index 7b79e0423..0bf7c5bb1 100644 --- a/sandboxd/store/digest.go +++ b/sandboxd/store/digest.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "errors" "fmt" "slices" ) @@ -40,7 +41,7 @@ func AssembleDigest(files []DigestFile) (string, error) { _, _ = h.Write([]byte(digestDomain)) for i, file := range files { if file.Path == "" { - return "", fmt.Errorf("digest file path is empty") + return "", errors.New("digest file path is empty") } if i > 0 && files[i-1].Path == file.Path { return "", fmt.Errorf("duplicate digest path %q", file.Path) diff --git a/sandboxd/store/peer/probe.go b/sandboxd/store/peer/probe.go index 64e450820..c77efa29a 100644 --- a/sandboxd/store/peer/probe.go +++ b/sandboxd/store/peer/probe.go @@ -33,7 +33,6 @@ const ( probeBucketSeconds = 30 ) -// redirectCacheEntry is Owners' positive-cache record. type redirectCacheEntry struct { owners []string expires time.Time diff --git a/sandboxd/store/peer/transport.go b/sandboxd/store/peer/transport.go index b2e911d73..35a812ec7 100644 --- a/sandboxd/store/peer/transport.go +++ b/sandboxd/store/peer/transport.go @@ -1,6 +1,4 @@ -// Package peer adds cross-node reach to a node-local record store: a record -// missing locally is pulled from a node that gossiped it, published locally, -// and served from there. +// Package peer pulls a record from the node that holds it and finds, heals and deletes records across peers. package peer import ( @@ -122,7 +120,7 @@ func Untar(r io.Reader, dst string) error { hdr, err := tr.Next() if errors.Is(err, io.EOF) { if !complete { - return fmt.Errorf("untar: record incomplete, no completion marker (transfer cut short)") + return errors.New("untar: record incomplete, no completion marker (transfer cut short)") } return nil } diff --git a/sandboxd/store/s3/s3.go b/sandboxd/store/s3/s3.go index e6da7be08..9ac202cde 100644 --- a/sandboxd/store/s3/s3.go +++ b/sandboxd/store/s3/s3.go @@ -1,9 +1,4 @@ -// Package s3 is the object-store record backend for nodes without a -// shared POSIX namespace: /meta.json (live records only) plus -// manifests//.json and the content-addressed chunks the -// manifests name, meta.json uploaded last as the commit marker (S3 has no -// atomic multi-object rename). Exports are 16 MiB chunks keyed by SHA-256; -// holes and all-zero chunks are absent. The aws dependency stays in this package. +// Package s3 keeps records in an S3 bucket and keeps the aws dependency in this package. package s3 import ( @@ -95,7 +90,7 @@ type Store struct { // New builds the backend; ctx bounds the credential-chain resolution. func New(ctx context.Context, cfg store.S3Config, stagingRoot string, idRe *regexp.Regexp) (*Store, error) { if cfg.Bucket == "" { - return nil, fmt.Errorf("s3 checkpoint store needs a bucket") + return nil, errors.New("s3 checkpoint store needs a bucket") } // A prefix without its trailing slash hides every record from the delimiter listing. if cfg.Prefix != "" && !strings.HasSuffix(cfg.Prefix, "/") { @@ -295,7 +290,6 @@ func (s *Store) SweepGenerations(ctx context.Context) error { return nil } -// SweepStaging clears local staging residue at startup. func (s *Store) SweepStaging() error { return utils.RemoveDirEntries(s.staging, nil) } diff --git a/sandboxd/store/store.go b/sandboxd/store/store.go index fec7e0555..00f53f428 100644 --- a/sandboxd/store/store.go +++ b/sandboxd/store/store.go @@ -1,8 +1,4 @@ -// Package store persists captured sandbox states — checkpoints and promoted -// templates — behind one interface: staging, an atomic publish, a local -// directory cocoon can `clone --from-dir`, and listing/removal. The dir -// backend covers local disk and any shared FUSE mount (every node sharing it -// resolves every record); the s3 backend talks to object storage natively. +// Package store defines the record backend interface and the digest, chunk and id helpers its dir and s3 backends share. package store import ( diff --git a/sandboxd/types/api.go b/sandboxd/types/api.go index 0d81b5407..218e0f546 100644 --- a/sandboxd/types/api.go +++ b/sandboxd/types/api.go @@ -32,8 +32,8 @@ type VolumeRegistration struct { // VolumePatch is the wire body of PATCH /v1/sandboxes/{id}/volumes. type VolumePatch struct { - Add []Volume `json:"add,omitzero"` - Remove []string `json:"remove,omitzero"` + Add []Volume `json:"add,omitempty"` + Remove []string `json:"remove,omitempty"` } // VolumePhase is how far one held volume has converged. From 1e7a6e0d52d7d7709c554d546bdecc99ce869024 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:01:52 +0800 Subject: [PATCH 35/70] pool: a fenced node captures no replicated claim Fork, Checkpoint and Promote reached captureAllowed with no fence gate, so a node past its lease window could hand out children, a checkpoint or a template from a claim a peer may already own. ErrLeaseLost maps to 503 through the error table and is benign to the archive and replicate sweeps. --- sandboxd/pool/capture.go | 3 +++ sandboxd/pool/replica_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sandboxd/pool/capture.go b/sandboxd/pool/capture.go index dbf0713ce..66681887f 100644 --- a/sandboxd/pool/capture.go +++ b/sandboxd/pool/capture.go @@ -34,6 +34,9 @@ func (m *Manager) captureAllowed(sb *types.Sandbox) error { if m.claimed[sb.ID] != sb { return ErrUnknownSandbox } + if _, err := m.fenceWatch(sb); err != nil { + return err + } if m.hasVolumeHolds(sb) { return ErrVolumeCapture } diff --git a/sandboxd/pool/replica_test.go b/sandboxd/pool/replica_test.go index debce3cea..d6a14f3c2 100644 --- a/sandboxd/pool/replica_test.go +++ b/sandboxd/pool/replica_test.go @@ -583,6 +583,34 @@ func TestAFencedNodeNeitherHibernatesNorWakesAReplicatedClaim(t *testing.T) { } } +func TestAFencedNodeCapturesNoReplicatedClaim(t *testing.T) { + c := newReplicaCluster(t) + a := c.node(t, "a", t.TempDir(), keepAnHour) + sb, plain := mustClaim(t, a, testKey), mustClaim(t, a, plainKey) + replicateNow(t, a, sb) + a.tripFence() + cred := Cred{Token: sb.Token} + if _, err := a.Fork(t.Context(), sb.ID, cred, 1, time.Hour, "", ""); !errors.Is(err, ErrLeaseLost) { + t.Errorf("fork while fenced: %v, want ErrLeaseLost", err) + } + if _, err := a.Checkpoint(t.Context(), sb.ID, cred, "", ""); !errors.Is(err, ErrLeaseLost) { + t.Errorf("checkpoint while fenced: %v, want ErrLeaseLost", err) + } + if _, _, err := a.Promote(t.Context(), sb.ID, cred, "tpl", ""); !errors.Is(err, ErrLeaseLost) { + t.Errorf("promote while fenced: %v, want ErrLeaseLost", err) + } + if err := a.replicate(t.Context(), sb); !errors.Is(err, ErrLeaseLost) || !benignSweepErr(err) { + t.Errorf("replicate while fenced: %v, want a benign ErrLeaseLost", err) + } + if _, err := a.Checkpoint(t.Context(), plain.ID, Cred{Token: plain.Token}, "", ""); err != nil { + t.Errorf("checkpoint of an unreplicated claim while fenced: %v", err) + } + a.liftFence() + if _, err := a.Fork(t.Context(), sb.ID, cred, 1, time.Hour, "", ""); err != nil { + t.Errorf("fork after the fence lifted: %v", err) + } +} + func TestTheSweepsLeaveAReplicatedClaimAloneWhileFenced(t *testing.T) { c := newReplicaCluster(t) a := c.node(t, "a", t.TempDir(), keepAnHour) From 833d7205dbf9761202d5c6cd1eebb987b38d7546 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:03:51 +0800 Subject: [PATCH 36/70] pool: consumeArchive reads the row epoch under the manager lock An unfence rewrites ArchiveEpoch holding only m.mu, and a release or replica retirement read it holding only Transition, so the two raced. The stale read cost at most a refused Consume and a settle round trip. --- sandboxd/pool/failover.go | 7 +++++-- sandboxd/pool/lease_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index 104c76d2c..769244d40 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -317,11 +317,14 @@ func (m *Manager) releaseRowed(ctx context.Context, id string, sb *types.Sandbox } func (m *Manager) consumeArchive(ctx context.Context, sb *types.Sandbox) (int64, error) { - if !m.archiveRowed(sb) && !m.replicaRowed(sb) { + m.mu.Lock() + rowed, epoch := m.archiveRowed(sb) || m.replicaRowed(sb), sb.ArchiveEpoch + m.mu.Unlock() + if !rowed { return 0, nil } ctx = context.WithoutCancel(ctx) - consumed, err := m.consumeRow(ctx, sb, sb.ArchiveEpoch, "consume archive row") + consumed, err := m.consumeRow(ctx, sb, epoch, "consume archive row") if err != nil { return 0, err } diff --git a/sandboxd/pool/lease_test.go b/sandboxd/pool/lease_test.go index 81145a46e..49b27fe5c 100644 --- a/sandboxd/pool/lease_test.go +++ b/sandboxd/pool/lease_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "os" + "sync" "sync/atomic" "testing" "testing/synctest" @@ -496,6 +497,31 @@ func TestLeaseUnfenceOfManyClaimsLiftsInsideTheWindowOverASlowRegistry(t *testin }) } +func TestLeaseConsumeDoesNotRaceAnUnfenceRewritingTheEpoch(t *testing.T) { + c := newReplicaCluster(t) + a := c.node(t, "a", t.TempDir(), keepAnHour) + sb := mustClaim(t, a, testKey) + replicateNow(t, a, sb) + rows, err := c.reg.Rows(t.Context(), []string{sb.ID}) + if err != nil { + t.Fatalf("rows: %v", err) + } + var unfence sync.WaitGroup + unfence.Go(func() { + if _, takeErr := a.takeOwnRows(t.Context(), map[string]*types.Sandbox{sb.ID: sb}, rows); takeErr != nil { + t.Errorf("take own rows: %v", takeErr) + } + }) + time.Sleep(20 * time.Millisecond) + sb.Transition.Lock() + consumed, err := a.consumeArchive(t.Context(), sb) + sb.Transition.Unlock() + unfence.Wait() + if err != nil || consumed == 0 { + t.Errorf("consume after the unfence took the row: epoch %d, %v", consumed, err) + } +} + type lostTakeRegistry struct { archives.Registry } From 199d228602c255f7b5888c697ae4198096dfacca Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:09:29 +0800 Subject: [PATCH 37/70] review: pool comments name what the code does now Two field comments named fields that moved to configView, checkTemplateOwner never deletes, HasPromotedTemplate's cache can go stale, and poolSeedHash's field list drifted against the lines below it. --- sandboxd/pool/pool.go | 2 -- sandboxd/pool/poolstore.go | 2 +- sandboxd/pool/template.go | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 0aee29431..8444dd1ec 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -368,7 +368,6 @@ type Manager struct { poolVersion int64 configSeedHash string // config pools' hash, to warn when a file edit is overridden - // idleDefault is the idle-hibernate threshold for unpooled keys; zero disables. idleSweep atomic.Bool livenessSweep atomic.Bool @@ -377,7 +376,6 @@ type Manager struct { vmmEvents atomic.Bool lastVMPoll atomic.Int64 - // archive*Default are the archive thresholds for unpooled keys. archiveSweep atomic.Bool archiveDeleteSweep atomic.Bool // archiving holds ids with an export or a registry drop in flight; pendingCks pins ids mid-commit; both under m.mu. diff --git a/sandboxd/pool/poolstore.go b/sandboxd/pool/poolstore.go index c58c900c8..0a7e35972 100644 --- a/sandboxd/pool/poolstore.go +++ b/sandboxd/pool/poolstore.go @@ -117,7 +117,7 @@ func (m *Manager) adoptPools(ctx context.Context) error { return nil } -// poolSeedHash digests a pool set's warm-target shape, order-independent, without the config-owned egress, warmup, capture_trim, storage, replicate_seconds, reader and egress_upstream_env. +// poolSeedHash digests a pool set's warm-target shape, order-independent, without its config-owned fields. func poolSeedHash(specs []config.PoolSpec) string { shaped := slices.Clone(specs) for i := range shaped { diff --git a/sandboxd/pool/template.go b/sandboxd/pool/template.go index 0bda045b8..75da451cf 100644 --- a/sandboxd/pool/template.go +++ b/sandboxd/pool/template.go @@ -191,7 +191,7 @@ func (m *Manager) HasPoolGolden(key types.PoolKey) bool { return ok && p.goldenDir != "" } -// HasPromotedTemplate is resolveGolden's test exactly, so routing never promises a refused golden. +// HasPromotedTemplate applies resolveGolden's owner test, so routing does not promise another tenant's template. func (m *Manager) HasPromotedTemplate(ctx context.Context, key types.PoolKey, tenant string) bool { if m.pooled(key) { return false @@ -279,7 +279,7 @@ func (m *Manager) pooled(key types.PoolKey) bool { return ok } -// checkTemplateOwner rejects publishing or deleting over another tenant's record. +// checkTemplateOwner rejects publishing over another tenant's record. func (m *Manager) checkTemplateOwner(ctx context.Context, id, tenant string) error { if tenant == "" { return nil From a33459b24bbf9c6d4e018bd77ff1da0bd31032cb Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:09:55 +0800 Subject: [PATCH 38/70] review: checkpoint.go keeps const and var above its types --- sandboxd/pool/checkpoint.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 439dab3a9..9f03ef135 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -18,9 +18,6 @@ import ( "github.com/cocoonstack/sandbox/sandboxd/usage" ) -// DeleteScope selects how far a checkpoint delete reaches; fleet-wide is the zero value. -type DeleteScope int - const ( DeleteFleet DeleteScope = iota DeleteLocal @@ -32,6 +29,9 @@ var ( ErrHealBusy = errors.New("too many checkpoint heals in flight") ) +// DeleteScope selects how far a checkpoint delete reaches; fleet-wide is the zero value. +type DeleteScope int + // Checkpoint captures a claimed sandbox's state under a fresh id; the source keeps running. func (m *Manager) Checkpoint(ctx context.Context, id string, cred Cred, name, tenant string) (types.Checkpoint, error) { sb, ok := m.resolve(id, cred) From 2b0404aa54f4521daf910a4d82a9f83d09a7fe41 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:11:24 +0800 Subject: [PATCH 39/70] review: fold pool helpers with one caller archiveDeleteFor, unpooledTemplates, archiveFetchErr, dropStaleRow, hasRegularFile, deliverGuestEnv and TenantRecords each had one caller. reputArchive's held parameter was dead: its only caller guards a non-zero epoch. --- sandboxd/pool/archive.go | 15 ++++++--------- sandboxd/pool/checkpoint.go | 6 +----- sandboxd/pool/env.go | 18 +++++++----------- sandboxd/pool/failover.go | 17 ++++------------- sandboxd/pool/pool.go | 2 +- sandboxd/pool/reader.go | 15 ++++++--------- sandboxd/pool/template.go | 10 ++-------- sandboxd/pool/tenants.go | 5 ----- sandboxd/pool/tenants_test.go | 4 ++-- 9 files changed, 29 insertions(+), 63 deletions(-) diff --git a/sandboxd/pool/archive.go b/sandboxd/pool/archive.go index 6289afd94..d97ec9ce9 100644 --- a/sandboxd/pool/archive.go +++ b/sandboxd/pool/archive.go @@ -20,7 +20,7 @@ import ( const archiveDeleteDir = "archive-deletes" -// archive*For resolve a key's thresholds (pool's, else node default); callers hold m.mu. +// archiveAfterFor resolves a key's threshold (pool's, else node default); callers hold m.mu. func (m *Manager) archiveAfterFor(key types.PoolKey) time.Duration { if p, ok := m.activePool(key); ok { return p.archiveAfter @@ -28,16 +28,13 @@ func (m *Manager) archiveAfterFor(key types.PoolKey) time.Duration { return m.view.Load().archiveAfterDefault } -func (m *Manager) archiveDeleteFor(key types.PoolKey) time.Duration { - if p, ok := m.activePool(key); ok { - return p.archiveDelete - } - return m.view.Load().archiveDeleteDefault -} - // retentionDeadline is zero for an archive kept forever; callers hold m.mu. func (m *Manager) retentionDeadline(key types.PoolKey) time.Time { - if d := m.archiveDeleteFor(key); d > 0 { + d := m.view.Load().archiveDeleteDefault + if p, ok := m.activePool(key); ok { + d = p.archiveDelete + } + if d > 0 { return time.Now().Add(d) } return time.Time{} diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 9f03ef135..64b26c506 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -394,7 +394,7 @@ func validateHealedCheckpoint(staging, wantID string) error { return fmt.Errorf("healed record %s missing export dir: %w", wantID, err) } // An export with no regular file clones to nothing; deeper content is cocoon's own format - if !hasRegularFile(export) { + if !slices.ContainsFunc(export, func(e os.DirEntry) bool { return e.Type().IsRegular() }) { return fmt.Errorf("healed record %s has no export content", wantID) } entries, err := os.ReadDir(staging) @@ -409,10 +409,6 @@ func validateHealedCheckpoint(staging, wantID string) error { return nil } -func hasRegularFile(entries []os.DirEntry) bool { - return slices.ContainsFunc(entries, func(e os.DirEntry) bool { return e.Type().IsRegular() }) -} - func parseCheckpoint(raw []byte) (types.Checkpoint, error) { var ckpt types.Checkpoint if err := json.Unmarshal(raw, &ckpt); err != nil { diff --git a/sandboxd/pool/env.go b/sandboxd/pool/env.go index 3ab5dc8af..c2d3af6e4 100644 --- a/sandboxd/pool/env.go +++ b/sandboxd/pool/env.go @@ -106,7 +106,13 @@ func (m *Manager) writeEnv(ctx context.Context, id, tenant, verb string, next fu return fmt.Errorf("%s env %s: %w", verb, id, err) } } - return m.deliverGuestEnv(ctx, verb, id, sock, env, deliver) + if !deliver { + return nil + } + if err := m.eng.WriteGuestEnv(ctx, sock, env.GuestFile()); err != nil { + return fmt.Errorf("%s env %s: write to the guest: %w", verb, id, err) + } + return nil } func (m *Manager) rewriteArchiveRow(ctx context.Context, sb *types.Sandbox, locked bool) error { @@ -139,16 +145,6 @@ func (m *Manager) rewriteArchiveRow(ctx context.Context, sb *types.Sandbox, lock return nil } -func (m *Manager) deliverGuestEnv(ctx context.Context, verb, id, sock string, env types.Env, deliver bool) error { - if !deliver { - return nil - } - if err := m.eng.WriteGuestEnv(ctx, sock, env.GuestFile()); err != nil { - return fmt.Errorf("%s env %s: write to the guest: %w", verb, id, err) - } - return nil -} - // deliverEnv writes each claim's guest env file before it is handed out; an inherited guest holds its source's file, so it is rewritten even when empty. func (m *Manager) deliverEnv(ctx context.Context, sbs []*types.Sandbox, inherited bool) error { if !inherited && !slices.ContainsFunc(sbs, func(sb *types.Sandbox) bool { return sb.Env.HasGuest() }) { diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index 769244d40..af02fa7c4 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -274,10 +274,7 @@ func (m *Manager) dropTombstone(ctx context.Context, id string, epoch int64) { } } -func (m *Manager) reputArchive(ctx context.Context, sb *types.Sandbox, held int64) { - if held == 0 { - return - } +func (m *Manager) reputArchive(ctx context.Context, sb *types.Sandbox) { var epoch int64 var err error if sb.ArchiveCk != "" { @@ -459,12 +456,12 @@ func (m *Manager) reconcileOwnRow(ctx context.Context, rec archives.Record, now switch { case !held: case refs && rec.Consumed && epoch != 0: - m.reputArchive(ctx, sb, epoch) + m.reputArchive(ctx, sb) case refs: case rec.Consumed: m.purgeArchiveRow(ctx, rec) - default: - m.dropStaleRow(ctx, sb, rec) + case !m.archiveCkPinned(rec.Checkpoint): + m.abandonArchive(ctx, sb, rec.Epoch, rec.Checkpoint) } } @@ -487,12 +484,6 @@ func (m *Manager) startRowRetention(ctx context.Context, id string, epoch int64) return err } -func (m *Manager) dropStaleRow(ctx context.Context, sb *types.Sandbox, rec archives.Record) { - if !m.archiveCkPinned(rec.Checkpoint) { - m.abandonArchive(ctx, sb, rec.Epoch, rec.Checkpoint) - } -} - func (m *Manager) purgeArchiveRow(ctx context.Context, rec archives.Record) { logger := log.WithFunc("pool.purgeArchiveRow") deleted, err := m.deleteArchiveCk(ctx, rec.Checkpoint) diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 8444dd1ec..4748a693f 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -575,7 +575,7 @@ func (m *Manager) ClusterDigest() string { m.reloadMu.Lock() cfg := m.cfg m.reloadMu.Unlock() - return clusterDigest(cfg, m.out.CAFingerprint(), m.TenantRecords()) + return clusterDigest(cfg, m.out.CAFingerprint(), m.tenants.Records()) } // Run drives the refill and reap loops until ctx is canceled. diff --git a/sandboxd/pool/reader.go b/sandboxd/pool/reader.go index 355090928..d5adcbd61 100644 --- a/sandboxd/pool/reader.go +++ b/sandboxd/pool/reader.go @@ -228,8 +228,12 @@ func (m *Manager) fetchReaderArchive(ctx context.Context, r *reader) error { } func (m *Manager) fetchReaderFile(ctx context.Context, r *reader, name string) error { - if err := m.ckpts.FetchFile(ctx, r.source, name, filepath.Join(r.dir, name)); err != nil { - return archiveFetchErr(err) + err := m.ckpts.FetchFile(ctx, r.source, name, filepath.Join(r.dir, name)) + if errors.Is(err, store.ErrNotFound) { + return errNotSleeping + } + if err != nil { + return fmt.Errorf("fetch archive: %w", err) } return nil } @@ -284,13 +288,6 @@ func sleepSource(sb *types.Sandbox) string { return cmp.Or(sb.HibernateSnap, sb.ArchiveCk) } -func archiveFetchErr(err error) error { - if errors.Is(err, store.ErrNotFound) { - return errNotSleeping - } - return fmt.Errorf("fetch archive: %w", err) -} - func diskAttachFiles(envelope string) ([]string, error) { raw, err := os.ReadFile(envelope) //nolint:gosec // path from our own fetch if err != nil { diff --git a/sandboxd/pool/template.go b/sandboxd/pool/template.go index 75da451cf..b468da9e4 100644 --- a/sandboxd/pool/template.go +++ b/sandboxd/pool/template.go @@ -120,7 +120,8 @@ func (m *Manager) DeleteTemplate(ctx context.Context, key types.PoolKey, tenant, // Templates lists the promoted templates this node holds and no pool owns, in key-hash order like Info's pools; one published before keys were recorded is listed once re-promoted. func (m *Manager) Templates() []TemplateInfo { - held := m.unpooledTemplates() + held := map[string]TemplateInfo{} + m.eachUnpooledTemplate(func(id string, t TemplateInfo) { held[id] = t }) out := make([]TemplateInfo, 0, len(held)) for _, id := range slices.Sorted(maps.Keys(held)) { if t := held[id]; t.Key.Template != "" { @@ -248,13 +249,6 @@ func (m *Manager) checkTemplateDigest(ctx context.Context, id, digest string) er return nil } -// unpooledTemplates copies the held templates by id, less any key a pool now owns. -func (m *Manager) unpooledTemplates() map[string]TemplateInfo { - held := map[string]TemplateInfo{} - m.eachUnpooledTemplate(func(id string, t TemplateInfo) { held[id] = t }) - return held -} - // eachUnpooledTemplate visits the held templates a pool does not own, under the template lock. func (m *Manager) eachUnpooledTemplate(visit func(id string, t TemplateInfo)) { m.mu.Lock() diff --git a/sandboxd/pool/tenants.go b/sandboxd/pool/tenants.go index dd4109c6c..4fcbc6d26 100644 --- a/sandboxd/pool/tenants.go +++ b/sandboxd/pool/tenants.go @@ -34,11 +34,6 @@ func (m *Manager) TenantByToken(ctx context.Context, token string) (*tenants.Ten return m.tenants.Resolve(ctx, sha256.Sum256([]byte(token))) } -// TenantRecords returns a node-local tenant set for the cluster config digest; a shared set returns nil. -func (m *Manager) TenantRecords() []tenants.TenantRecord { - return m.tenants.Records() -} - // Tenants lists up to limit tenants after the cursor with each one's live claims here; the first page also names the removed tenants still holding claims. func (m *Manager) Tenants(ctx context.Context, after string, limit int) (TenantPage, error) { records, err := m.tenants.List(ctx, after, limit+1) diff --git a/sandboxd/pool/tenants_test.go b/sandboxd/pool/tenants_test.go index 6339fbe9c..619348569 100644 --- a/sandboxd/pool/tenants_test.go +++ b/sandboxd/pool/tenants_test.go @@ -179,7 +179,7 @@ func TestConcurrentTenantPutsKeepEveryTenant(t *testing.T) { }) } wg.Wait() - if n := len(m.TenantRecords()); n != 32 { + if n := len(m.tenants.Records()); n != 32 { t.Errorf("%d tenants after 32 concurrent puts, want 32", n) } } @@ -389,7 +389,7 @@ func TestTwoNodesOnOneMetaStoreShareTheTenantSet(t *testing.T) { if _, err := a.Renew(t.Context(), sb.ID, Cred{Token: sb.Token}, time.Hour, ""); !errors.Is(err, ErrTenantRemoved) { t.Errorf("renew on a after b removed the tenant: %v, want ErrTenantRemoved even though a's cache lost the name", err) } - if a.TenantRecords() != nil { + if a.tenants.Records() != nil { t.Error("a shared tenant set entered the cluster digest") } if _, err := os.Stat(filepath.Join(a.dataDir, "tenants.json")); !errors.Is(err, os.ErrNotExist) { From 4ead9f1ec128aa89170da1bc0fe238bea4a49de0 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:13:20 +0800 Subject: [PATCH 40/70] review: one owner for pool facts stated twice replicaPools repeated view.poolReplicate, whose key set a reload cannot change and PUT /v1/pools cannot set. The view's map guards bought nothing, since every reader takes the zero value. env and replicable spelled out runningShaped, AgentSocket spelled out isFenced, and the two reader teardowns stripped a cancel that removeVM strips itself. addClaimLocked gains one atomic load on nodes with archives and nothing elsewhere. --- sandboxd/pool/claim.go | 5 ++--- sandboxd/pool/env.go | 2 +- sandboxd/pool/pool.go | 17 +++++------------ sandboxd/pool/reader.go | 4 ++-- sandboxd/pool/replica.go | 2 +- sandboxd/pool/view.go | 17 ++--------------- 6 files changed, 13 insertions(+), 34 deletions(-) diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 887422dad..a8432169a 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -220,7 +220,7 @@ func (m *Manager) AgentSocket(id, token string) (string, error) { if !ok { return "", ErrUnknownSandbox } - if sb.Replicated() && m.fence.Load().fenced { + if sb.Replicated() && m.isFenced() { return "", ErrLeaseLost } // No activity stamp: a control-plane poll must not keep an idle sandbox awake @@ -471,8 +471,7 @@ func (m *Manager) tenantDelta(tenant string, delta int) { func (m *Manager) addClaimLocked(sb *types.Sandbox) { if m.archives != nil { - _, replicating := m.replicaPools[sb.PolicyKey()] - sb.SetReplicated(replicating || sb.HasReplica()) + sb.SetReplicated(m.view.Load().poolReplicate[sb.PolicyKey()] > 0 || sb.HasReplica()) } m.claimed[sb.ID] = sb m.tenantDelta(sb.Tenant, 1) diff --git a/sandboxd/pool/env.go b/sandboxd/pool/env.go index c2d3af6e4..7e2c7a565 100644 --- a/sandboxd/pool/env.go +++ b/sandboxd/pool/env.go @@ -73,7 +73,7 @@ func (m *Manager) writeEnv(ctx context.Context, id, tenant, verb string, next fu } } changed := !env.SameGuest(prev) - paused := !locked || sb.HibernateSnap != "" || sb.PendingSnap != "" || sb.ArchiveCk != "" || failed + paused := !locked || !runningShaped(sb) if changed && paused { m.mu.Unlock() if failed { diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 4748a693f..bcc4d18d3 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -386,12 +386,11 @@ type Manager struct { nodeID string orphanSweep atomic.Bool adoptFlights singleflight.Group - // fence gates replicated claims while the lease is overdue; replicaPools is fixed at start; replicas belongs to the pass holding replicating. - fence atomic.Pointer[fenceState] - unfencing atomic.Bool - replicaPools map[types.PoolKey]struct{} - replicating atomic.Bool - replicas map[string]time.Time + // fence gates replicated claims while the lease is overdue; replicas belongs to the pass holding replicating. + fence atomic.Pointer[fenceState] + unfencing atomic.Bool + replicating atomic.Bool + replicas map[string]time.Time view atomic.Pointer[configView] // cfg is the config the view was built from; reloadMu orders reloads, the only writers of cfg and view. @@ -554,12 +553,6 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg if m.archives != nil { m.nodeID = cfg.Mesh.ID() close(start.ch) - m.replicaPools = map[types.PoolKey]struct{}{} - for _, spec := range cfg.Pools { - if spec.ReplicateSeconds > 0 { - m.replicaPools[spec.PoolKey] = struct{}{} - } - } } m.fence.Store(start) if err := m.adoptPools(ctx); err != nil { diff --git a/sandboxd/pool/reader.go b/sandboxd/pool/reader.go index d5adcbd61..c2d3df582 100644 --- a/sandboxd/pool/reader.go +++ b/sandboxd/pool/reader.go @@ -133,7 +133,7 @@ func (m *Manager) dropReader(ctx context.Context, r *reader, force bool) { } delete(rs.live, r.claim) rs.mu.Unlock() - m.destroyReader(context.WithoutCancel(ctx), r) + m.destroyReader(ctx, r) } func (m *Manager) ensureReader(ctx context.Context, sb *types.Sandbox) error { @@ -175,7 +175,7 @@ func (m *Manager) openReader(ctx context.Context, sb *types.Sandbox) error { r.idle = time.AfterFunc(readerIdle, func() { m.dropReader(context.Background(), r, false) }) rs.mu.Unlock() if old != nil { - m.destroyReader(context.WithoutCancel(ctx), old) + m.destroyReader(ctx, old) } log.WithFunc("pool.openReader").Infof(ctx, "reader %s for sleeping claim %s ready in %s", r.vm, sb.ID, time.Since(start).Round(time.Millisecond)) return nil diff --git a/sandboxd/pool/replica.go b/sandboxd/pool/replica.go index 793fd40d5..20427d9a8 100644 --- a/sandboxd/pool/replica.go +++ b/sandboxd/pool/replica.go @@ -67,7 +67,7 @@ func (m *Manager) replicateOnce(ctx context.Context) { // replicable excludes a claim that is paused, archived or archiving, failed, or whose tenant was removed; callers hold m.mu. func (m *Manager) replicable(sb *types.Sandbox) bool { _, archiving := m.archiving[sb.ID] - return !archiving && sb.HibernateSnap == "" && sb.PendingSnap == "" && sb.ArchiveCk == "" && sb.ArchivePending == "" && sb.Failed == "" && !m.tenantGone(sb.Tenant) + return !archiving && runningShaped(sb) && sb.ArchivePending == "" && !m.tenantGone(sb.Tenant) } func (m *Manager) replicate(ctx context.Context, sb *types.Sandbox) error { diff --git a/sandboxd/pool/view.go b/sandboxd/pool/view.go index 525f9c532..b27d202f5 100644 --- a/sandboxd/pool/view.go +++ b/sandboxd/pool/view.go @@ -62,21 +62,8 @@ func newConfigView(cfg *config.Config, secrets *egress.SecretStore) *configView } for _, spec := range cfg.Pools { key := spec.PoolKey - if len(spec.Warmup) > 0 { - v.poolWarmups[key] = spec.Warmup - } - if spec.CaptureTrim { - v.poolTrims[key] = true - } - if spec.Storage != "" { - v.poolStorage[key] = spec.Storage - } - if spec.ReplicateSeconds > 0 { - v.poolReplicate[key] = time.Duration(spec.ReplicateSeconds) * time.Second - } - if spec.Reader { - v.poolReaders[key] = true - } + v.poolWarmups[key], v.poolTrims[key], v.poolStorage[key] = spec.Warmup, spec.CaptureTrim, spec.Storage + v.poolReplicate[key], v.poolReaders[key] = time.Duration(spec.ReplicateSeconds)*time.Second, spec.Reader } return v } From bafaa4d021081b189fc68cb0586af293244e7398 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:16:16 +0800 Subject: [PATCH 41/70] review: pool sweeps and wakes carry what they have commitWake logged its persist failure at Warn and its caller rebuilt the error with the cause dropped; it now returns the error. idleOnce copied id and token so idleHibernate could look the claim up again, and hibernateLocked's setIfLive already answers ErrUnknownSandbox for a released claim. Fork and Checkpoint no longer wait on Transition for a usage field, since every write of VMName after publication holds m.mu; lockedVMName stays for tests, which read under Transition. --- sandboxd/pool/archive.go | 20 +++++++------------- sandboxd/pool/checkpoint.go | 5 ++++- sandboxd/pool/fork.go | 4 ++-- sandboxd/pool/hibernate.go | 15 +++++---------- sandboxd/pool/pool.go | 7 ------- sandboxd/pool/pool_test.go | 6 ++++++ 6 files changed, 24 insertions(+), 33 deletions(-) diff --git a/sandboxd/pool/archive.go b/sandboxd/pool/archive.go index d97ec9ce9..b9255cde8 100644 --- a/sandboxd/pool/archive.go +++ b/sandboxd/pool/archive.go @@ -232,13 +232,9 @@ func (m *Manager) wakeArchived(ctx context.Context, sb *types.Sandbox) (string, m.destroy(ctx, built.VMName) return "", fmt.Errorf("wake %s: track archive checkpoint: %w", sb.ID, markErr) } - live, saved := m.commitWake(ctx, sb, built.VMName, built.VsockSocket) - if !live || !saved { + if err = m.commitWake(ctx, sb, built.VMName, built.VsockSocket); err != nil { m.destroy(ctx, built.VMName) - if !live { - return "", ErrUnknownSandbox - } - return "", fmt.Errorf("wake %s: persist claims", sb.ID) + return "", err } logger := log.WithFunc("pool.wakeArchived") // The pre-commit marker keeps a failed delete retryable after the journal update. @@ -262,12 +258,11 @@ func (m *Manager) wakeArchived(ctx context.Context, sb *types.Sandbox) (string, return built.VsockSocket, nil } -func (m *Manager) commitWake(ctx context.Context, sb *types.Sandbox, vmName, sock string) (live, saved bool) { +func (m *Manager) commitWake(ctx context.Context, sb *types.Sandbox, vmName, sock string) error { m.mu.Lock() - live = m.claimed[sb.ID] == sb - if !live { + if m.claimed[sb.ID] != sb { m.mu.Unlock() - return false, false + return ErrUnknownSandbox } ck, deadline, epoch, safe := sb.ArchiveCk, sb.Deadline, sb.ArchiveEpoch, sb.ReaderSafe sb.VMName, sb.VsockSocket, sb.ArchiveCk, sb.ArchiveEpoch, sb.ReaderSafe = vmName, sock, "", 0, false @@ -283,11 +278,10 @@ func (m *Manager) commitWake(ctx context.Context, sb *types.Sandbox, vmName, soc } m.mu.Unlock() m.store.recommit(ctx, rb) - log.WithFunc("pool.commitWake").Warnf(ctx, "persist claims: %v", err) - return true, false + return fmt.Errorf("wake %s: persist claims: %w", sb.ID, err) } sb.Touch() // restart the idle→hibernate→archive clock - return true, true + return nil } func (m *Manager) deleteOrphanArchiveCk(ctx context.Context, ckID string) { diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 64b26c506..d8f22b9c8 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -54,7 +54,10 @@ func (m *Manager) Checkpoint(ctx context.Context, id string, cred Cred, name, te return types.Checkpoint{}, err } m.counters.checkpoints.Add(1) - m.recordUsage(ctx, usage.Event{Event: "checkpoint", ID: sb.ID, VMName: lockedVMName(sb), Tenant: sb.Tenant, Reference: ckpt.ID}) + m.mu.Lock() + vmName := sb.VMName + m.mu.Unlock() + m.recordUsage(ctx, usage.Event{Event: "checkpoint", ID: sb.ID, VMName: vmName, Tenant: sb.Tenant, Reference: ckpt.ID}) return ckpt, nil } diff --git a/sandboxd/pool/fork.go b/sandboxd/pool/fork.go index c6d0deb6c..e8803ee28 100644 --- a/sandboxd/pool/fork.go +++ b/sandboxd/pool/fork.go @@ -40,7 +40,7 @@ func (m *Manager) Fork(ctx context.Context, id string, cred Cred, count int, ttl return nil, fmt.Errorf("fork %s: %w", sb.ID, err) } m.mu.Lock() - parentExpire := sb.OnExpire + parentExpire, parentVM := sb.OnExpire, sb.VMName m.mu.Unlock() for _, c := range children { c.Tenant, c.EgressClass = sb.Tenant, sb.EgressClass @@ -58,7 +58,7 @@ func (m *Manager) Fork(ctx context.Context, id string, cred Cred, count int, ttl for i, c := range children { ids[i] = c.ID } - m.recordUsage(ctx, usage.Event{Event: "fork", ID: sb.ID, VMName: lockedVMName(sb), Tenant: sb.Tenant, Children: ids}) + m.recordUsage(ctx, usage.Event{Event: "fork", ID: sb.ID, VMName: parentVM, Tenant: sb.Tenant, Children: ids}) return children, nil } diff --git a/sandboxd/pool/hibernate.go b/sandboxd/pool/hibernate.go index cbb762ca7..9bda609f4 100644 --- a/sandboxd/pool/hibernate.go +++ b/sandboxd/pool/hibernate.go @@ -242,8 +242,7 @@ func (m *Manager) idleOnce(ctx context.Context) { return // the previous sweep's hibernates are still draining } now := time.Now() - type victim struct{ id, token string } - var victims []victim + var victims []*types.Sandbox m.mu.Lock() idleDefault := m.view.Load().idleDefault idlePolicy := idleDefault > 0 @@ -266,7 +265,7 @@ func (m *Manager) idleOnce(ctx context.Context) { if idle <= 0 || now.Sub(sb.LastSeen()) < idle { continue } - victims = append(victims, victim{sb.ID, sb.Token}) + victims = append(victims, sb) } m.mu.Unlock() if len(victims) == 0 { @@ -277,17 +276,13 @@ func (m *Manager) idleOnce(ctx context.Context) { go func() { defer m.idleSweep.Store(false) m.runBounded(ctx, len(victims), func(ctx context.Context, i int) { - v := victims[i] - logSweepResult(ctx, log.WithFunc("pool.idleOnce"), m.idleHibernate(ctx, v.id, v.token, now), "idle-hibernated "+v.id, "idle-hibernate "+v.id) + sb := victims[i] + logSweepResult(ctx, log.WithFunc("pool.idleOnce"), m.idleHibernate(ctx, sb, now), "idle-hibernated "+sb.ID, "idle-hibernate "+sb.ID) }).Wait() }() } -func (m *Manager) idleHibernate(ctx context.Context, id, token string, sweepStart time.Time) error { - sb, ok := m.claim(id, token) - if !ok { - return ErrUnknownSandbox - } +func (m *Manager) idleHibernate(ctx context.Context, sb *types.Sandbox, sweepStart time.Time) error { sb.Transition.Lock() defer sb.Transition.Unlock() m.mu.Lock() diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index bcc4d18d3..f81ba9824 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -812,13 +812,6 @@ func vmName(key types.PoolKey) string { return vmPrefix + key.Hash() + "-" + randHex(6) } -// lockedVMName reads the VM name under the lock that archive() clears it under. -func lockedVMName(sb *types.Sandbox) string { - sb.Transition.Lock() - defer sb.Transition.Unlock() - return sb.VMName -} - func randHex(n int) string { b := make([]byte, n) _, _ = rand.Read(b) // never fails per crypto/rand contract diff --git a/sandboxd/pool/pool_test.go b/sandboxd/pool/pool_test.go index da9577e99..e3986cb2e 100644 --- a/sandboxd/pool/pool_test.go +++ b/sandboxd/pool/pool_test.go @@ -2011,6 +2011,12 @@ func (m *Manager) liftFence() { m.fence.Store(&fenceState{ch: make(chan struct{})}) } +func lockedVMName(sb *types.Sandbox) string { + sb.Transition.Lock() + defer sb.Transition.Unlock() + return sb.VMName +} + func newTestManager(t testing.TB, eng *fakeEngine, pools ...config.PoolSpec) *Manager { t.Helper() return newTestManagerAt(t, eng, t.TempDir(), pools...) From 8343183bca2d47a0e9130dbd61821cd86857006d Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:09:45 +0800 Subject: [PATCH 42/70] review: types.go splits into env.go and volume.go Pure move: the env types, limits and patterns go to env.go and the volume types, modes and mount validation to volume.go, matching the existing env_test.go and volume_test.go. envView and namedVar stay beside the Sandbox that owns them. No line changes beyond imports and gofmt alignment. --- sandboxd/types/env.go | 285 +++++++++++++++++++++++++++ sandboxd/types/types.go | 409 +-------------------------------------- sandboxd/types/volume.go | 151 +++++++++++++++ 3 files changed, 437 insertions(+), 408 deletions(-) create mode 100644 sandboxd/types/env.go create mode 100644 sandboxd/types/volume.go diff --git a/sandboxd/types/env.go b/sandboxd/types/env.go new file mode 100644 index 000000000..bbc7e8c50 --- /dev/null +++ b/sandboxd/types/env.go @@ -0,0 +1,285 @@ +package types + +import ( + "encoding/json/jsontext" + "encoding/json/v2" + "errors" + "fmt" + "maps" + "regexp" + "slices" + "strings" + + "golang.org/x/net/http/httpguts" +) + +const ( + maxEnvVars = 64 + maxEnvValueBytes = 8 << 10 + maxEnvBytes = 64 << 10 + maxInjectHosts = 8 + maxPlaceholder = 256 +) + +var ( + // EnvNameRe is the portable shell variable name. + EnvNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) + // injectHostRe is a lowercase DNS name or IPv4 literal, optionally under a "*." wildcard. + injectHostRe = regexp.MustCompile(`^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + + // guestFileEscaper covers the characters systemd unescapes inside a double-quoted EnvironmentFile value. + guestFileEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`) +) + +// EnvInject sends a host-only entry's value to Hosts, exact or "*." suffix, as Header or as the Query parameter, where the guest sent a set Placeholder. +type EnvInject struct { + Hosts []string `json:"hosts"` + Header string `json:"header,omitempty"` + Query string `json:"query,omitempty"` + Body bool `json:"body,omitzero"` + Placeholder string `json:"placeholder,omitempty"` +} + +// Matches reports whether the lowercased host is one of the inject hosts. +func (i *EnvInject) Matches(host string) bool { + return slices.ContainsFunc(i.Hosts, func(h string) bool { + if suffix, wild := strings.CutPrefix(h, "*"); wild { + return strings.HasSuffix(host, suffix) + } + return host == h + }) +} + +func (i *EnvInject) validate(name string) error { + if len(i.Hosts) == 0 || len(i.Hosts) > maxInjectHosts { + return fmt.Errorf("env %s: inject needs 1 to %d hosts, got %d", name, maxInjectHosts, len(i.Hosts)) + } + switch { + case (i.Header == "") == (i.Query == ""): + return fmt.Errorf("env %s: inject needs exactly one of header and query", name) + case i.Header != "" && !httpguts.ValidHeaderFieldName(i.Header): + return fmt.Errorf("env %s: inject header %q is not a valid header name", name, i.Header) + case !httpguts.ValidHeaderFieldValue(i.Query): + return fmt.Errorf("env %s: inject query must not hold control characters other than tab", name) + case i.Query != "" && i.Placeholder == "": + return fmt.Errorf("env %s: inject query needs a placeholder, so a value is never added to a request", name) + case i.Body && i.Query == "": + return fmt.Errorf("env %s: inject body needs query", name) + } + for _, h := range i.Hosts { + if len(h) > 253 || !injectHostRe.MatchString(h) { + return fmt.Errorf("env %s: inject host %q must be a lowercase host name or *.suffix", name, h) + } + } + if len(i.Placeholder) > maxPlaceholder || !httpguts.ValidHeaderFieldValue(i.Placeholder) || strings.TrimSpace(i.Placeholder) != i.Placeholder { + return fmt.Errorf("env %s: inject placeholder must be at most %d bytes of header value without control characters or surrounding space", name, maxPlaceholder) + } + return nil +} + +func (i *EnvInject) size() int { + n := len(i.Header) + len(i.Query) + len(i.Placeholder) + for _, h := range i.Hosts { + n += len(h) + } + return n +} + +func (i *EnvInject) equal(other *EnvInject) bool { + if i == nil || other == nil { + return i == other + } + return i.Header == other.Header && i.Query == other.Query && i.Body == other.Body && i.Placeholder == other.Placeholder && slices.Equal(i.Hosts, other.Hosts) +} + +// EnvVar is one entry of a claim's environment; a nil Guest means the entry reaches the guest. +type EnvVar struct { + Value string `json:"value"` + Guest *bool `json:"guest,omitempty"` + // Inject, on a host-only entry, makes the egress proxy send Value to the named hosts. + Inject *EnvInject `json:"inject,omitempty"` +} + +// InGuest reports whether the entry is delivered into the guest. +func (v EnvVar) InGuest() bool { return v.Guest == nil || *v.Guest } + +// UnmarshalJSON rejects unknown members and a null guest, so a mistyped host-only flag never falls back to the guest. +func (v *EnvVar) UnmarshalJSON(b []byte) error { + var raw struct { + Value string `json:"value"` + Guest jsontext.Value `json:"guest,omitzero"` + Inject *EnvInject `json:"inject,omitempty"` + } + if err := json.Unmarshal(b, &raw, json.RejectUnknownMembers(true)); err != nil { + return fmt.Errorf("env entry: %w", err) + } + *v = EnvVar{Value: raw.Value, Inject: raw.Inject} + if raw.Guest == nil { + return nil + } + var guest bool + if raw.Guest.Kind() == 'n' || json.Unmarshal(raw.Guest, &guest) != nil { + return errors.New("env entry: guest must be true or false") + } + v.Guest = &guest + return nil +} + +func (v EnvVar) same(other EnvVar) bool { + return v.Value == other.Value && v.InGuest() == other.InGuest() && v.Inject.equal(other.Inject) +} + +// Env is a claim's own environment: guest entries reach the guest, the others stay host-side for egress secrets. +type Env map[string]EnvVar + +// Validate enforces the entry count, the name grammar, a bounded value a header and an env file both carry, and a bounded total. +func (e Env) Validate() error { + if len(e) > maxEnvVars { + return fmt.Errorf("env must contain at most %d entries, got %d", maxEnvVars, len(e)) + } + total := 0 + for name, v := range e { + total += len(name) + len(v.Value) + if !EnvNameRe.MatchString(name) { + return fmt.Errorf("env name %q must match %s", name, EnvNameRe) + } + if len(v.Value) > maxEnvValueBytes { + return fmt.Errorf("env value of %s must be at most %d bytes, got %d", name, maxEnvValueBytes, len(v.Value)) + } + if !httpguts.ValidHeaderFieldValue(v.Value) { + return fmt.Errorf("env value of %s must not hold control characters other than tab", name) + } + if v.Inject == nil { + continue + } + if v.InGuest() { + return fmt.Errorf("env %s: inject needs guest: false, or the guest holds the value", name) + } + if err := v.Inject.validate(name); err != nil { + return err + } + total += v.Inject.size() + } + if total > maxEnvBytes { + return fmt.Errorf("env names, values and injects must total at most %d bytes, got %d", maxEnvBytes, total) + } + return nil +} + +// Hidden returns the value of a host-only entry. +func (e Env) Hidden(name string) (string, bool) { + v, ok := e[name] + if !ok || v.InGuest() { + return "", false + } + return v.Value, true +} + +// HasInject reports whether any entry injects into egress requests. +func (e Env) HasInject() bool { + for _, v := range e { + if v.Inject != nil { + return true + } + } + return false +} + +// InjectsChanged returns the entries of e that inject and that prev lacks or holds differently. +func (e Env) InjectsChanged(prev Env) Env { + var out Env + for name, v := range e { + if old, ok := prev[name]; v.Inject != nil && (!ok || !v.same(old)) { + if out == nil { + out = Env{} + } + out[name] = v + } + } + return out +} + +// HasGuest reports whether any entry reaches the guest. +func (e Env) HasGuest() bool { + for _, v := range e { + if v.InGuest() { + return true + } + } + return false +} + +// Equal reports whether e and other hold the same entries. +func (e Env) Equal(other Env) bool { + return maps.EqualFunc(e, other, EnvVar.same) +} + +// SameGuest reports whether e and other deliver the same guest entries. +func (e Env) SameGuest(other Env) bool { + return maps.EqualFunc(e.guest(), other.guest(), EnvVar.same) +} + +// Redacted copies e with every host-only value dropped, so a read names them without serving them. +func (e Env) Redacted() Env { + out := maps.Clone(e) + for name, v := range out { + if !v.InGuest() { + v.Value = "" + out[name] = v + } + } + return out +} + +// GuestFile renders the guest entries as a systemd EnvironmentFile, sorted by name. +func (e Env) GuestFile() []byte { + var b strings.Builder + for _, name := range slices.Sorted(maps.Keys(e.guest())) { + fmt.Fprintf(&b, "%s=\"%s\"\n", name, guestFileEscaper.Replace(e[name].Value)) + } + return []byte(b.String()) +} + +func (e Env) guest() Env { + out := Env{} + for name, v := range e { + if v.InGuest() { + out[name] = v + } + } + return out +} + +// EnvPatch names the entries of a claim's env to set; a null entry removes the name. +type EnvPatch map[string]*EnvVar + +// Validate enforces the name grammar on every entry and the Env rules on the entries it sets. +func (p EnvPatch) Validate() error { + set := Env{} + for name, v := range p { + if !EnvNameRe.MatchString(name) { + return fmt.Errorf("env name %q must match %s", name, EnvNameRe) + } + if v != nil { + set[name] = *v + } + } + return set.Validate() +} + +// Apply returns e with p's entries set or removed, nil when none remain; e is left unchanged. +func (p EnvPatch) Apply(e Env) Env { + out := Env{} + maps.Copy(out, e) + for name, v := range p { + if v == nil { + delete(out, name) + } else { + out[name] = *v + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/sandboxd/types/types.go b/sandboxd/types/types.go index 56027a41f..576a7101d 100644 --- a/sandboxd/types/types.go +++ b/sandboxd/types/types.go @@ -5,13 +5,9 @@ import ( "cmp" "crypto/sha256" "encoding/hex" - "encoding/json/jsontext" - "encoding/json/v2" "errors" "fmt" "iter" - "maps" - "path/filepath" "regexp" "slices" "strings" @@ -20,8 +16,6 @@ import ( "time" "unicode" "unicode/utf8" - - "golang.org/x/net/http/httpguts" ) const ( @@ -51,42 +45,17 @@ const ( VMMRestartCold VMMRestart = "cold" VMMRestartNone VMMRestart = "none" - MaxClaimVolumes = 8 TemplateRootScope = "*" - // Also the guest `mount -o` option literals: renaming them changes the mount flags. - VolumeModeRO = "ro" - VolumeModeRW = "rw" - - DirectIOOn = "on" - DirectIOOff = "off" - DirectIOAuto = "auto" - maxMetadataPairs = 16 maxMetadataKeyBytes = 128 maxMetadataValueBytes = 512 maxMetadataBytes = 4 << 10 - - maxEnvVars = 64 - maxEnvValueBytes = 8 << 10 - maxEnvBytes = 64 << 10 - maxInjectHosts = 8 - maxPlaceholder = 256 ) var ( // NameRe pins caller-chosen names to one conservative charset cocoon also accepts. - NameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,62}$`) - // VolumeNameRe is the virtio disk serial grammar shared with cocoon. - VolumeNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,19}$`) - // EnvNameRe is the portable shell variable name. - EnvNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]{0,127}$`) - // injectHostRe is a lowercase DNS name or IPv4 literal, optionally under a "*." wildcard. - injectHostRe = regexp.MustCompile(`^(\*\.)?([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) - - // guestFileEscaper covers the characters systemd unescapes inside a double-quoted EnvironmentFile value. - guestFileEscaper = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`) - + NameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,62}$`) sizeSpecs = map[Size]SizeSpec{ SizeSmall: {CPU: 1, Memory: "512M", MemoryBytes: 512 << 20}, SizeMedium: {CPU: 2, Memory: "1G", MemoryBytes: 1 << 30}, @@ -94,11 +63,6 @@ var ( SizeXLarge: {CPU: 4, Memory: "8G", MemoryBytes: 8 << 30}, Size2XLarge: {CPU: 8, Memory: "16G", MemoryBytes: 16 << 30}, } - - guestOSMountRoots = []string{ - "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", - "/run", "/sbin", "/sys", "/usr", "/var", - } ) // NetShape selects whether the Cloud Hypervisor guest has a NIC. @@ -220,259 +184,6 @@ func (md Metadata) encodedSize() int { return size } -// EnvInject sends a host-only entry's value to Hosts, exact or "*." suffix, as Header or as the Query parameter, where the guest sent a set Placeholder. -type EnvInject struct { - Hosts []string `json:"hosts"` - Header string `json:"header,omitempty"` - Query string `json:"query,omitempty"` - Body bool `json:"body,omitzero"` - Placeholder string `json:"placeholder,omitempty"` -} - -// Matches reports whether the lowercased host is one of the inject hosts. -func (i *EnvInject) Matches(host string) bool { - return slices.ContainsFunc(i.Hosts, func(h string) bool { - if suffix, wild := strings.CutPrefix(h, "*"); wild { - return strings.HasSuffix(host, suffix) - } - return host == h - }) -} - -func (i *EnvInject) validate(name string) error { - if len(i.Hosts) == 0 || len(i.Hosts) > maxInjectHosts { - return fmt.Errorf("env %s: inject needs 1 to %d hosts, got %d", name, maxInjectHosts, len(i.Hosts)) - } - switch { - case (i.Header == "") == (i.Query == ""): - return fmt.Errorf("env %s: inject needs exactly one of header and query", name) - case i.Header != "" && !httpguts.ValidHeaderFieldName(i.Header): - return fmt.Errorf("env %s: inject header %q is not a valid header name", name, i.Header) - case !httpguts.ValidHeaderFieldValue(i.Query): - return fmt.Errorf("env %s: inject query must not hold control characters other than tab", name) - case i.Query != "" && i.Placeholder == "": - return fmt.Errorf("env %s: inject query needs a placeholder, so a value is never added to a request", name) - case i.Body && i.Query == "": - return fmt.Errorf("env %s: inject body needs query", name) - } - for _, h := range i.Hosts { - if len(h) > 253 || !injectHostRe.MatchString(h) { - return fmt.Errorf("env %s: inject host %q must be a lowercase host name or *.suffix", name, h) - } - } - if len(i.Placeholder) > maxPlaceholder || !httpguts.ValidHeaderFieldValue(i.Placeholder) || strings.TrimSpace(i.Placeholder) != i.Placeholder { - return fmt.Errorf("env %s: inject placeholder must be at most %d bytes of header value without control characters or surrounding space", name, maxPlaceholder) - } - return nil -} - -func (i *EnvInject) size() int { - n := len(i.Header) + len(i.Query) + len(i.Placeholder) - for _, h := range i.Hosts { - n += len(h) - } - return n -} - -func (i *EnvInject) equal(other *EnvInject) bool { - if i == nil || other == nil { - return i == other - } - return i.Header == other.Header && i.Query == other.Query && i.Body == other.Body && i.Placeholder == other.Placeholder && slices.Equal(i.Hosts, other.Hosts) -} - -// EnvVar is one entry of a claim's environment; a nil Guest means the entry reaches the guest. -type EnvVar struct { - Value string `json:"value"` - Guest *bool `json:"guest,omitempty"` - // Inject, on a host-only entry, makes the egress proxy send Value to the named hosts. - Inject *EnvInject `json:"inject,omitempty"` -} - -// InGuest reports whether the entry is delivered into the guest. -func (v EnvVar) InGuest() bool { return v.Guest == nil || *v.Guest } - -// UnmarshalJSON rejects unknown members and a null guest, so a mistyped host-only flag never falls back to the guest. -func (v *EnvVar) UnmarshalJSON(b []byte) error { - var raw struct { - Value string `json:"value"` - Guest jsontext.Value `json:"guest,omitzero"` - Inject *EnvInject `json:"inject,omitempty"` - } - if err := json.Unmarshal(b, &raw, json.RejectUnknownMembers(true)); err != nil { - return fmt.Errorf("env entry: %w", err) - } - *v = EnvVar{Value: raw.Value, Inject: raw.Inject} - if raw.Guest == nil { - return nil - } - var guest bool - if raw.Guest.Kind() == 'n' || json.Unmarshal(raw.Guest, &guest) != nil { - return errors.New("env entry: guest must be true or false") - } - v.Guest = &guest - return nil -} - -func (v EnvVar) same(other EnvVar) bool { - return v.Value == other.Value && v.InGuest() == other.InGuest() && v.Inject.equal(other.Inject) -} - -// Env is a claim's own environment: guest entries reach the guest, the others stay host-side for egress secrets. -type Env map[string]EnvVar - -// Validate enforces the entry count, the name grammar, a bounded value a header and an env file both carry, and a bounded total. -func (e Env) Validate() error { - if len(e) > maxEnvVars { - return fmt.Errorf("env must contain at most %d entries, got %d", maxEnvVars, len(e)) - } - total := 0 - for name, v := range e { - total += len(name) + len(v.Value) - if !EnvNameRe.MatchString(name) { - return fmt.Errorf("env name %q must match %s", name, EnvNameRe) - } - if len(v.Value) > maxEnvValueBytes { - return fmt.Errorf("env value of %s must be at most %d bytes, got %d", name, maxEnvValueBytes, len(v.Value)) - } - if !httpguts.ValidHeaderFieldValue(v.Value) { - return fmt.Errorf("env value of %s must not hold control characters other than tab", name) - } - if v.Inject == nil { - continue - } - if v.InGuest() { - return fmt.Errorf("env %s: inject needs guest: false, or the guest holds the value", name) - } - if err := v.Inject.validate(name); err != nil { - return err - } - total += v.Inject.size() - } - if total > maxEnvBytes { - return fmt.Errorf("env names, values and injects must total at most %d bytes, got %d", maxEnvBytes, total) - } - return nil -} - -// Hidden returns the value of a host-only entry. -func (e Env) Hidden(name string) (string, bool) { - v, ok := e[name] - if !ok || v.InGuest() { - return "", false - } - return v.Value, true -} - -// HasInject reports whether any entry injects into egress requests. -func (e Env) HasInject() bool { - for _, v := range e { - if v.Inject != nil { - return true - } - } - return false -} - -// InjectsChanged returns the entries of e that inject and that prev lacks or holds differently. -func (e Env) InjectsChanged(prev Env) Env { - var out Env - for name, v := range e { - if old, ok := prev[name]; v.Inject != nil && (!ok || !v.same(old)) { - if out == nil { - out = Env{} - } - out[name] = v - } - } - return out -} - -// HasGuest reports whether any entry reaches the guest. -func (e Env) HasGuest() bool { - for _, v := range e { - if v.InGuest() { - return true - } - } - return false -} - -// Equal reports whether e and other hold the same entries. -func (e Env) Equal(other Env) bool { - return maps.EqualFunc(e, other, EnvVar.same) -} - -// SameGuest reports whether e and other deliver the same guest entries. -func (e Env) SameGuest(other Env) bool { - return maps.EqualFunc(e.guest(), other.guest(), EnvVar.same) -} - -// Redacted copies e with every host-only value dropped, so a read names them without serving them. -func (e Env) Redacted() Env { - out := maps.Clone(e) - for name, v := range out { - if !v.InGuest() { - v.Value = "" - out[name] = v - } - } - return out -} - -// GuestFile renders the guest entries as a systemd EnvironmentFile, sorted by name. -func (e Env) GuestFile() []byte { - var b strings.Builder - for _, name := range slices.Sorted(maps.Keys(e.guest())) { - fmt.Fprintf(&b, "%s=\"%s\"\n", name, guestFileEscaper.Replace(e[name].Value)) - } - return []byte(b.String()) -} - -func (e Env) guest() Env { - out := Env{} - for name, v := range e { - if v.InGuest() { - out[name] = v - } - } - return out -} - -// EnvPatch names the entries of a claim's env to set; a null entry removes the name. -type EnvPatch map[string]*EnvVar - -// Validate enforces the name grammar on every entry and the Env rules on the entries it sets. -func (p EnvPatch) Validate() error { - set := Env{} - for name, v := range p { - if !EnvNameRe.MatchString(name) { - return fmt.Errorf("env name %q must match %s", name, EnvNameRe) - } - if v != nil { - set[name] = *v - } - } - return set.Validate() -} - -// Apply returns e with p's entries set or removed, nil when none remain; e is left unchanged. -func (p EnvPatch) Apply(e Env) Env { - out := Env{} - maps.Copy(out, e) - for name, v := range p { - if v == nil { - delete(out, name) - } else { - out[name] = *v - } - } - if len(out) == 0 { - return nil - } - return out -} - // PoolKey identifies one warm pool. type PoolKey struct { Template string `json:"template"` @@ -752,62 +463,12 @@ func (r VMRecord) TapDevice() string { return r.NetworkConfigs[0].TAP } -// Volume is one dataset mount; Mode is normalized to "" (read-only) or VolumeModeRW. -type Volume struct { - Name string `json:"name"` - Mount string `json:"mount,omitempty"` - Mode string `json:"mode,omitempty"` - // AttachOnly leaves mounting and unmounting to the caller. - AttachOnly bool `json:"attach_only,omitzero"` -} - -// RW reports whether the entry asks for write access. -func (v Volume) RW() bool { return v.Mode == VolumeModeRW } - // TemplateGossipHash scopes a key hash to its owner; TemplateRootScope covers every tenant. func TemplateGossipHash(keyHash, tenant string) string { sum := sha256.Sum256([]byte(keyHash + "|" + tenant)) return hex.EncodeToString(sum[:16]) } -// ValidVolumeName reports whether name is a legal cocoon data-disk serial. -func ValidVolumeName(name string) bool { - return VolumeNameRe.MatchString(name) && !strings.HasPrefix(name, "cocoon-") -} - -// DefaultVolumeMount returns the guest mount used when a request omits one. -func DefaultVolumeMount(name string) string { - return "/volumes/" + name -} - -// ValidDirectIO reports whether mode is a legal volume direct-I/O setting. -func ValidDirectIO(mode string) bool { - return mode == DirectIOOn || mode == DirectIOOff || mode == DirectIOAuto -} - -// VolumeNames projects the entries' names in order; nil for none. -func VolumeNames(volumes []Volume) []string { - if len(volumes) == 0 { - return nil - } - names := make([]string, len(volumes)) - for i, volume := range volumes { - names[i] = volume.Name - } - return names -} - -// VolumeRWNames projects the names of the write-enabled entries; nil for none. -func VolumeRWNames(volumes []Volume) []string { - var names []string - for _, volume := range volumes { - if volume.RW() { - names = append(names, volume.Name) - } - } - return names -} - // ValidateClaimRef bounds a caller reference named field like a metadata value. func ValidateClaimRef(field, ref string) error { if len(ref) > maxMetadataValueBytes || !validMetadataValue(ref) { @@ -816,70 +477,6 @@ func ValidateClaimRef(field, ref string) error { return nil } -// ValidateVolumes returns detached entries with defaults filled and modes normalized. -func ValidateVolumes(volumes []Volume) ([]Volume, error) { - if len(volumes) > MaxClaimVolumes { - return nil, fmt.Errorf("volumes must contain at most %d entries, got %d", MaxClaimVolumes, len(volumes)) - } - applied := make([]Volume, len(volumes)) - names := make(map[string]struct{}, len(volumes)) - for i, volume := range volumes { - if !ValidVolumeName(volume.Name) { - return nil, fmt.Errorf("volumes[%d] name %q must match %s and not start with cocoon-", i, volume.Name, VolumeNameRe) - } - if _, ok := names[volume.Name]; ok { - return nil, fmt.Errorf("volumes[%d] duplicates name %q", i, volume.Name) - } - names[volume.Name] = struct{}{} - mode := volume.Mode - switch mode { - case VolumeModeRO: - mode = "" - case "", VolumeModeRW: - default: - return nil, fmt.Errorf("volumes[%d] mode %q must be %s or %s", i, mode, VolumeModeRO, VolumeModeRW) - } - if volume.AttachOnly { - if volume.Mount != "" { - return nil, fmt.Errorf("volumes[%d] mount %q is meaningless when the caller mounts the device itself", i, volume.Mount) - } - applied[i] = Volume{Name: volume.Name, Mode: mode, AttachOnly: true} - continue - } - mount := cmp.Or(volume.Mount, DefaultVolumeMount(volume.Name)) - if err := validateVolumeMount(mount); err != nil { - return nil, fmt.Errorf("volumes[%d] mount %q: %w", i, mount, err) - } - for j := range i { - other := applied[j].Mount - if other == "" { - continue - } - switch { - case mount == other: - return nil, fmt.Errorf("volumes[%d] mount %q duplicates volumes[%d]", i, mount, j) - case pathWithin(other, mount), pathWithin(mount, other): - return nil, fmt.Errorf("volumes[%d] mount %q nests with volumes[%d] mount %q", i, mount, j, other) - } - } - applied[i] = Volume{Name: volume.Name, Mount: mount, Mode: mode} - } - return applied, nil -} - -func validateVolumeMount(mount string) error { - if !filepath.IsAbs(mount) { - return errors.New("must be absolute") - } - if filepath.Clean(mount) != mount { - return errors.New("must be clean") - } - if mount == "/" || slices.ContainsFunc(guestOSMountRoots, func(root string) bool { return mount == root || pathWithin(root, mount) }) { - return errors.New("must be outside the guest OS tree") - } - return nil -} - func escapedLen(s string) int { return len(s) + 2 + strings.Count(s, `"`) + strings.Count(s, `\`) } @@ -891,7 +488,3 @@ func validMetadataValue(v string) bool { func validMetadataKey(k string) bool { return k != "" && len(k) <= maxMetadataKeyBytes && !strings.ContainsFunc(k, func(r rune) bool { return r < ' ' || r > '~' || r == '=' || r == '&' }) } - -func pathWithin(parent, child string) bool { - return strings.HasPrefix(child, parent+"/") -} diff --git a/sandboxd/types/volume.go b/sandboxd/types/volume.go new file mode 100644 index 000000000..b87a955aa --- /dev/null +++ b/sandboxd/types/volume.go @@ -0,0 +1,151 @@ +package types + +import ( + "cmp" + "errors" + "fmt" + "path/filepath" + "regexp" + "slices" + "strings" +) + +const ( + MaxClaimVolumes = 8 + + // Also the guest `mount -o` option literals: renaming them changes the mount flags. + VolumeModeRO = "ro" + VolumeModeRW = "rw" + + DirectIOOn = "on" + DirectIOOff = "off" + DirectIOAuto = "auto" +) + +var ( + // VolumeNameRe is the virtio disk serial grammar shared with cocoon. + VolumeNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,19}$`) + + guestOSMountRoots = []string{ + "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", + "/run", "/sbin", "/sys", "/usr", "/var", + } +) + +// Volume is one dataset mount; Mode is normalized to "" (read-only) or VolumeModeRW. +type Volume struct { + Name string `json:"name"` + Mount string `json:"mount,omitempty"` + Mode string `json:"mode,omitempty"` + // AttachOnly leaves mounting and unmounting to the caller. + AttachOnly bool `json:"attach_only,omitzero"` +} + +// RW reports whether the entry asks for write access. +func (v Volume) RW() bool { return v.Mode == VolumeModeRW } + +// ValidVolumeName reports whether name is a legal cocoon data-disk serial. +func ValidVolumeName(name string) bool { + return VolumeNameRe.MatchString(name) && !strings.HasPrefix(name, "cocoon-") +} + +// DefaultVolumeMount returns the guest mount used when a request omits one. +func DefaultVolumeMount(name string) string { + return "/volumes/" + name +} + +// ValidDirectIO reports whether mode is a legal volume direct-I/O setting. +func ValidDirectIO(mode string) bool { + return mode == DirectIOOn || mode == DirectIOOff || mode == DirectIOAuto +} + +// VolumeNames projects the entries' names in order; nil for none. +func VolumeNames(volumes []Volume) []string { + if len(volumes) == 0 { + return nil + } + names := make([]string, len(volumes)) + for i, volume := range volumes { + names[i] = volume.Name + } + return names +} + +// VolumeRWNames projects the names of the write-enabled entries; nil for none. +func VolumeRWNames(volumes []Volume) []string { + var names []string + for _, volume := range volumes { + if volume.RW() { + names = append(names, volume.Name) + } + } + return names +} + +// ValidateVolumes returns detached entries with defaults filled and modes normalized. +func ValidateVolumes(volumes []Volume) ([]Volume, error) { + if len(volumes) > MaxClaimVolumes { + return nil, fmt.Errorf("volumes must contain at most %d entries, got %d", MaxClaimVolumes, len(volumes)) + } + applied := make([]Volume, len(volumes)) + names := make(map[string]struct{}, len(volumes)) + for i, volume := range volumes { + if !ValidVolumeName(volume.Name) { + return nil, fmt.Errorf("volumes[%d] name %q must match %s and not start with cocoon-", i, volume.Name, VolumeNameRe) + } + if _, ok := names[volume.Name]; ok { + return nil, fmt.Errorf("volumes[%d] duplicates name %q", i, volume.Name) + } + names[volume.Name] = struct{}{} + mode := volume.Mode + switch mode { + case VolumeModeRO: + mode = "" + case "", VolumeModeRW: + default: + return nil, fmt.Errorf("volumes[%d] mode %q must be %s or %s", i, mode, VolumeModeRO, VolumeModeRW) + } + if volume.AttachOnly { + if volume.Mount != "" { + return nil, fmt.Errorf("volumes[%d] mount %q is meaningless when the caller mounts the device itself", i, volume.Mount) + } + applied[i] = Volume{Name: volume.Name, Mode: mode, AttachOnly: true} + continue + } + mount := cmp.Or(volume.Mount, DefaultVolumeMount(volume.Name)) + if err := validateVolumeMount(mount); err != nil { + return nil, fmt.Errorf("volumes[%d] mount %q: %w", i, mount, err) + } + for j := range i { + other := applied[j].Mount + if other == "" { + continue + } + switch { + case mount == other: + return nil, fmt.Errorf("volumes[%d] mount %q duplicates volumes[%d]", i, mount, j) + case pathWithin(other, mount), pathWithin(mount, other): + return nil, fmt.Errorf("volumes[%d] mount %q nests with volumes[%d] mount %q", i, mount, j, other) + } + } + applied[i] = Volume{Name: volume.Name, Mount: mount, Mode: mode} + } + return applied, nil +} + +func validateVolumeMount(mount string) error { + if !filepath.IsAbs(mount) { + return errors.New("must be absolute") + } + if filepath.Clean(mount) != mount { + return errors.New("must be clean") + } + if mount == "/" || slices.ContainsFunc(guestOSMountRoots, func(root string) bool { return mount == root || pathWithin(root, mount) }) { + return errors.New("must be outside the guest OS tree") + } + return nil +} + +func pathWithin(parent, child string) bool { + return strings.HasPrefix(child, parent+"/") +} From 71ffaf6419f436e961beb876194a5f47e2a7e6eb Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:06:37 +0800 Subject: [PATCH 43/70] egress: an upstream dying mid-body truncates the guest's response relay dropped the copy error, so net/http wrote the terminating chunk and the guest read a partial body as complete. Hijack and close the conn on a copy error, which leaves the chunked stream unterminated. Success costs one nil check. --- sandboxd/egress/intercept_test.go | 35 +++++++++++++++++++++++++ sandboxd/egress/proxy.go | 29 ++++++++++++++------- sandboxd/egress/proxy_test.go | 43 +++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/sandboxd/egress/intercept_test.go b/sandboxd/egress/intercept_test.go index e5ae4bde8..789623973 100644 --- a/sandboxd/egress/intercept_test.go +++ b/sandboxd/egress/intercept_test.go @@ -7,6 +7,7 @@ import ( "crypto/x509" "encoding/hex" "encoding/json/v2" + "errors" "fmt" "io" "net" @@ -553,6 +554,40 @@ func TestInterceptStreamsAnEventStreamAsItArrives(t *testing.T) { } } +func TestAnUpstreamDyingMidBodyTruncatesTheInterceptedResponse(t *testing.T) { + scrub := credSecrets{creds: map[string][]Credential{"example.com": {{Name: "SERP", Query: "api_key", Value: "real", Placeholder: "@SERP@"}}}} + for _, tt := range []struct { + name, length, target string + secrets Secrets + }{ + {"streamed", "", "/x", nil}, + {"scrubbed with a declared length", "100", "/x?api_key=@SERP@", scrub}, + } { + t.Run(tt.name, func(t *testing.T) { + upstream := httptest.NewTLSServer(dyingMidBody(tt.length)) + defer upstream.Close() + ca, _ := testCA(t) + p := New(Policy{Allow: []Rule{{Host: "example.com", Intercept: InterceptAlways}}}, tt.secrets, ca, fixedDial(upstream.Listener.Addr().String()), nil, nil) + guestRoots := x509.NewCertPool() + guestRoots.AppendCertsFromPEM(ca.CertPEM()) + tc := connectTLS(t, serveTrusting(t, p, upstream).Listener.Addr().String(), "example.com:443", "example.com", guestRoots) + defer func() { _ = tc.Close() }() + req := newRequest(t, http.MethodGet, tt.target, "") + if err := req.Write(tc); err != nil { + t.Fatalf("write request: %v", err) + } + resp, err := http.ReadResponse(bufio.NewReader(tc), req) + if err != nil { + t.Fatalf("read response: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if _, err = io.ReadAll(resp.Body); !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("read body: %v, want io.ErrUnexpectedEOF", err) + } + }) + } +} + func TestInterceptResumesTheGuestsTLSSession(t *testing.T) { p, guestRoots, upstream := interceptProxy(t, Rule{Host: "example.com", Intercept: InterceptAlways}, nil) front := serveTrusting(t, p, upstream) diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go index b95473cc7..642aa5697 100644 --- a/sandboxd/egress/proxy.go +++ b/sandboxd/egress/proxy.go @@ -424,11 +424,17 @@ func (p *Proxy) relay(w http.ResponseWriter, r *http.Request, ev Event, rule Rul src = newScrubReader(resp.Body, sc, *buf) } w.WriteHeader(resp.StatusCode) + var copyErr error if streamed(resp) { - received = copyFlushing(w, src) - return + received, copyErr = copyFlushing(w, src) + } else { + received, copyErr = io.Copy(w, src) + } + if copyErr != nil { + if conn, _, err := http.NewResponseController(w).Hijack(); err == nil { + _ = conn.Close() + } } - received, _ = io.Copy(w, src) } // transport returns the pool for route; the direct route keeps the base transports. @@ -695,11 +701,10 @@ func mediaType(h http.Header) string { return strings.ToLower(strings.TrimSpace(mt)) } -func copyFlushing(w http.ResponseWriter, body io.Reader) int64 { +func copyFlushing(w http.ResponseWriter, body io.Reader) (int64, error) { rc := http.NewResponseController(w) if rc.Flush() != nil { - n, _ := io.Copy(w, body) - return n + return io.Copy(w, body) } buf := copyBufs.Get().(*[]byte) defer copyBufs.Put(buf) @@ -709,12 +714,18 @@ func copyFlushing(w http.ResponseWriter, body io.Reader) int64 { if n > 0 { written, werr := w.Write((*buf)[:n]) total += int64(written) - if werr != nil || rc.Flush() != nil { - return total + if werr != nil { + return total, werr } + if werr = rc.Flush(); werr != nil { + return total, werr + } + } + if errors.Is(err, io.EOF) { + return total, nil } if err != nil { - return total + return total, err } } } diff --git a/sandboxd/egress/proxy_test.go b/sandboxd/egress/proxy_test.go index 8c52e0a98..4a9f5870f 100644 --- a/sandboxd/egress/proxy_test.go +++ b/sandboxd/egress/proxy_test.go @@ -598,6 +598,35 @@ func TestRoutedConnReportsItsTargetNotTheUpstream(t *testing.T) { } } +func TestAnUpstreamDyingMidBodyTruncatesTheForwardedResponse(t *testing.T) { + upstream := httptest.NewServer(dyingMidBody("")) + defer upstream.Close() + p := New(Policy{Allow: []Rule{{Host: "api.internal"}}}, nil, nil, fixedDial(upstream.Listener.Addr().String()), nil, nil) + resp := proxiedGet(t, serveFront(t, p).URL, "http://api.internal/x") + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Errorf("read body: %v, want io.ErrUnexpectedEOF", err) + } + if string(body) != "partial" { + t.Errorf("body %q, want the bytes that arrived", body) + } +} + +func TestAChunkedResponseEndsCleanlyThroughTheProxy(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "whole") + _ = http.NewResponseController(w).Flush() + })) + defer upstream.Close() + p := New(Policy{Allow: []Rule{{Host: "api.internal"}}}, nil, nil, fixedDial(upstream.Listener.Addr().String()), nil, nil) + resp := proxiedGet(t, serveFront(t, p).URL, "http://api.internal/x") + defer func() { _ = resp.Body.Close() }() + if body, err := io.ReadAll(resp.Body); err != nil || string(body) != "whole" { + t.Errorf("body %q, %v; want the whole body and no error", body, err) + } +} + type fakeSecrets map[string][2]string func (f fakeSecrets) Header(name string) (string, string, bool) { @@ -631,6 +660,20 @@ func (c shortWriteConn) Write(b []byte) (int, error) { return n, errors.New("connection reset") } +func dyingMidBody(length string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if length != "" { + w.Header().Set("Content-Length", length) + } + _, _ = io.WriteString(w, "partial") + rc := http.NewResponseController(w) + _ = rc.Flush() + if conn, _, err := rc.Hijack(); err == nil { + _ = conn.Close() + } + }) +} + func sseHandler(release <-chan struct{}) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") From d56966fd035a3cbec00c9159d06fbf91e1a98a79 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:08:32 +0800 Subject: [PATCH 44/70] outbound: ArmProxy closes a displaced door before it binds The close unlinks the socket path, so a re-arm that bound first left the guest with no door while Route reported relay. A re-arm with no policy left now closes the door too, which makes the Disarm in restartLocked dead. The claim path pays one map delete under a lock it already holds. --- sandboxd/outbound/doors_test.go | 42 +++++++++++++++++++++++++++++++++ sandboxd/outbound/outbound.go | 26 ++++++++++---------- sandboxd/pool/liveness.go | 2 -- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/sandboxd/outbound/doors_test.go b/sandboxd/outbound/doors_test.go index 5f52f9317..233cbbb91 100644 --- a/sandboxd/outbound/doors_test.go +++ b/sandboxd/outbound/doors_test.go @@ -7,13 +7,16 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "sync/atomic" "syscall" "testing" "time" + "github.com/cocoonstack/sandbox/sandboxd/config" "github.com/cocoonstack/sandbox/sandboxd/egress" "github.com/cocoonstack/sandbox/sandboxd/engine" "github.com/cocoonstack/sandbox/sandboxd/outbound/outboundtest" + "github.com/cocoonstack/sandbox/sandboxd/types" ) func TestEgressDoorHalfCloseReachesTheGuest(t *testing.T) { @@ -145,6 +148,45 @@ func TestReachedSeesAGuestInTheBacklog(t *testing.T) { } } +func TestARearmedClaimKeepsADoorItsGuestCanDial(t *testing.T) { + pol := &egress.Policy{Socks5: true, Allow: []egress.Rule{{Host: "example.com"}}} + h := newHost(t, &fakeEngine{}, poolConfig(testKey, pol), nil) + sb := outboundtest.VsockSandbox(t, "sb_rearm", testKey) + t.Cleanup(func() { h.Disarm(sb.ID, true) }) + for range 2 { + if err := h.ArmProxy(t.Context(), sb); err != nil { + t.Fatalf("arm proxy: %v", err) + } + } + for _, path := range []string{engine.EgressSocketPath(sb.VsockSocket), engine.SocksSocketPath(sb.VsockSocket)} { + conn, err := net.Dial("unix", path) + if err != nil { + t.Fatalf("dial after the second arm: %v", err) + } + _ = conn.Close() + } +} + +func TestARearmWithNoPolicyLeftClosesTheDoor(t *testing.T) { + var cur atomic.Pointer[View] + cur.Store(NewView(poolConfig(testKey, &egress.Policy{Allow: []egress.Rule{{Host: "example.com"}}}), nil)) + h := newHost(t, &fakeEngine{}, &config.Config{}, func(o *Options) { o.View = cur.Load }) + sb := outboundtest.VsockSandbox(t, "sb_rearm_none", testKey) + if err := h.ArmProxy(t.Context(), sb); err != nil { + t.Fatalf("arm proxy: %v", err) + } + cur.Store(NewView(&config.Config{}, nil)) + if err := h.ArmProxy(t.Context(), sb); err != nil { + t.Fatalf("second arm: %v", err) + } + if route := h.Route(sb); route != types.NetRouteNone { + t.Errorf("route %q, want none", route) + } + if _, err := net.Dial("unix", engine.EgressSocketPath(sb.VsockSocket)); err == nil { + t.Error("the door still accepts with no policy left") + } +} + func armDialingDoor(t *testing.T, id string, pol *egress.Policy) (*Host, string) { t.Helper() h := newHost(t, &fakeEngine{}, poolConfig(testKey, pol), func(o *Options) { o.Dial = (&net.Dialer{}).DialContext }) diff --git a/sandboxd/outbound/outbound.go b/sandboxd/outbound/outbound.go index 01854238f..24667d9f0 100644 --- a/sandboxd/outbound/outbound.go +++ b/sandboxd/outbound/outbound.go @@ -100,22 +100,24 @@ func (h *Host) Arm(ctx context.Context, sb *types.Sandbox) error { // ArmProxy serves the proxy on the pre-bound doors, binding them when refill could not, once the effective policy permits anything. func (h *Host) ArmProxy(ctx context.Context, sb *types.Sandbox) error { - v := h.o.View() - if !v.guarded || sb.VsockSocket == "" { - h.ClosePrebound(sb.VMName) - return nil + h.mu.Lock() + d, displaced := h.prebound[sb.VMName], h.doors[sb.ID] + delete(h.prebound, sb.VMName) + delete(h.doors, sb.ID) + h.mu.Unlock() + if displaced != nil { + displaced.close() } + v := h.o.View() policy, ok := v.Resolve(sb, h.o.Pooled) - if !ok { - h.ClosePrebound(sb.VMName) + if !ok || sb.VsockSocket == "" { + if d != nil { + d.close() + } return nil } id, tenant := sb.ID, sb.Tenant evCtx := context.WithoutCancel(ctx) - h.mu.Lock() - d := h.prebound[sb.VMName] - delete(h.prebound, sb.VMName) - h.mu.Unlock() // Only an intercepting pool pays for the TLS transport and leaf cache ca := h.o.CA if !v.Intercepts(sb.PolicyKey()) { @@ -145,12 +147,8 @@ func (h *Host) ArmProxy(ctx context.Context, sb *types.Sandbox) error { d.srv = &http.Server{Handler: proxy, ReadHeaderTimeout: 30 * time.Second} d.proxy = proxy h.mu.Lock() - displaced := h.doors[id] h.doors[id] = d h.mu.Unlock() - if displaced != nil { - displaced.close() - } go func() { _ = d.srv.Serve(d.ln) }() if d.socks != nil { go proxy.ServeSOCKS(evCtx, d.socks) diff --git a/sandboxd/pool/liveness.go b/sandboxd/pool/liveness.go index 43cb96c5b..4c103da72 100644 --- a/sandboxd/pool/liveness.go +++ b/sandboxd/pool/liveness.go @@ -265,8 +265,6 @@ func (m *Manager) restartLocked(ctx context.Context, sb *types.Sandbox) error { m.mu.Lock() sb.VsockSocket = sock m.mu.Unlock() - // A displaced door's close unlinks the rebound path, so the old door goes first - m.out.Disarm(sb.ID, false) err = m.out.ArmProxy(ctx, sb) if err == nil { err = m.deliverEnv(ctx, []*types.Sandbox{sb}, false) From 6487307dc74e30bf85685b2c22be60c53cf6620f Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:11:29 +0800 Subject: [PATCH 45/70] engine: a restore gets the dump budget A dump may take 15 minutes while an eager-copy restore of the same image had 2, so a slow page cache could fail the wake it had just made possible. cocoon gives vm.restore the same limit as vm.snapshot. Clone and cold boot keep the command budget. --- sandboxd/engine/engine.go | 12 ++++----- sandboxd/engine/engine_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index 66934029d..134656f50 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -129,17 +129,17 @@ func (e *Engine) VersionWarning(ctx context.Context) (version, warning string) { // Clone restores a VM from an exported golden directory. func (e *Engine) Clone(ctx context.Context, fromDir, name string, key types.PoolKey) (types.VMRecord, error) { - return e.runRecord(ctx, e.cloneArgs(fromDir, name, key)...) + return e.runRecord(ctx, cmdTimeout, cmdTermGrace, e.cloneArgs(fromDir, name, key)...) } // CloneSnap clones from a local-store snapshot by name — fork's fast path. func (e *Engine) CloneSnap(ctx context.Context, snap, name string, key types.PoolKey) (types.VMRecord, error) { - return e.runRecord(ctx, e.cloneSnapArgs(snap, name, key)...) + return e.runRecord(ctx, cmdTimeout, cmdTermGrace, e.cloneSnapArgs(snap, name, key)...) } // RunCold boots a VM from the template image with a storage-sized disk (empty = cocoon's default), returning its lifecycle record. func (e *Engine) RunCold(ctx context.Context, name string, key types.PoolKey, storage string) (types.VMRecord, error) { - return e.runRecord(ctx, e.runColdArgs(name, key, storage)...) + return e.runRecord(ctx, cmdTimeout, cmdTermGrace, e.runColdArgs(name, key, storage)...) } // Remove force-deletes a VM; a refused delete gets a force stop and one more delete. @@ -192,7 +192,7 @@ func (e *Engine) Hibernate(ctx context.Context, vmName, snapName string) error { // Restore resumes a VM from a snapshot, returning its vsock UDS. func (e *Engine) Restore(ctx context.Context, vmName, snapRef string) (string, error) { - rec, err := e.runRecord(ctx, e.restoreCmdArgs(vmName, snapRef)...) + rec, err := e.runRecord(ctx, dumpTimeout, dumpTermGrace, e.restoreCmdArgs(vmName, snapRef)...) return rec.VsockSocket, err } @@ -454,8 +454,8 @@ func (e *Engine) runDump(ctx context.Context, args ...string) error { return err } -func (e *Engine) runRecord(ctx context.Context, args ...string) (types.VMRecord, error) { - out, err := e.run(ctx, args...) +func (e *Engine) runRecord(ctx context.Context, timeout, termGrace time.Duration, args ...string) (types.VMRecord, error) { + out, err := e.runFor(ctx, timeout, termGrace, args...) if err != nil { return types.VMRecord{}, err } diff --git a/sandboxd/engine/engine_test.go b/sandboxd/engine/engine_test.go index e78cea88c..a29f1568e 100644 --- a/sandboxd/engine/engine_test.go +++ b/sandboxd/engine/engine_test.go @@ -9,10 +9,13 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "sync" "testing" "time" + + "github.com/cocoonstack/sandbox/sandboxd/types" ) const ( @@ -235,6 +238,48 @@ func TestACanceledCommandGetsSIGTERMSoCocoonCanResumeTheVM(t *testing.T) { } } +func TestARestoreGetsTheDumpTermGraceAndACloneTheCommandOne(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + run func(ctx context.Context, e *Engine) error + want string + }{ + {"restore", func(ctx context.Context, e *Engine) error { + _, err := e.Restore(ctx, "vm", "snap") + return err + }, "exit status 3"}, + {"clone", func(ctx context.Context, e *Engine) error { + _, err := e.Clone(ctx, "dir", "vm", types.PoolKey{}) + return err + }, "signal: killed"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + bin, ready := filepath.Join(dir, "cocoon"), filepath.Join(dir, "ready") + script := "#!/bin/sh\ntrap 'kill $!; sleep " + strconv.Itoa(int(cmdTermGrace/time.Second)+2) + " >/dev/null 2>&1; exit 3' TERM\n" + + "sleep 300 >/dev/null 2>&1 &\n: > " + ready + "\nwait\n" + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + done := make(chan error, 1) + go func() { done <- tt.run(ctx, New(bin, nil, nil, false, false, "")) }() + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + if _, err := os.Stat(ready); err == nil { + break + } + } + cancel() + if err := <-done; err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("got %v, want %q", err, tt.want) + } + }) + } +} + func TestADialOfAGuestThatNeverAnswersFails(t *testing.T) { t.Parallel() path := sockPath(t) From 0917a5e321eb8552782e29c9112d47e668d0c892 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:13:17 +0800 Subject: [PATCH 46/70] review: drop checks and fields the code already guarantees ReadSlice caps a line at the reader's buffer, IsGlobalUnicast already excludes link-local and multicast, UnixListener.Close already unlinks, marshalCA's public key is its PEM key's, and interDER copied interCert.Raw. --- sandboxd/egress/ca.go | 4 +--- sandboxd/egress/ca_issue.go | 10 +++++----- sandboxd/engine/engine.go | 9 +++------ sandboxd/outbound/dial.go | 4 ++-- sandboxd/outbound/doors.go | 20 +++++++------------- 5 files changed, 18 insertions(+), 29 deletions(-) diff --git a/sandboxd/egress/ca.go b/sandboxd/egress/ca.go index fc6e414c6..2b333c9e2 100644 --- a/sandboxd/egress/ca.go +++ b/sandboxd/egress/ca.go @@ -29,7 +29,6 @@ type CA struct { rootPEM []byte rootFP string interCert *x509.Certificate - interDER []byte interKey *ecdsa.PrivateKey leafKey *ecdsa.PrivateKey } @@ -74,7 +73,6 @@ func LoadCA(rootCertPEM, interCertPEM, interKeyPEM []byte) (*CA, error) { rootPEM: rootCertPEM, rootFP: hex.EncodeToString(sum[:]), interCert: inter, - interDER: inter.Raw, interKey: interKey, leafKey: leafKey, }, nil @@ -120,7 +118,7 @@ func (c *CA) SignLeaf(host string) (*tls.Certificate, error) { if err != nil { return nil, fmt.Errorf("parse leaf %s: %w", host, err) } - return &tls.Certificate{Certificate: [][]byte{der, c.interDER}, PrivateKey: c.leafKey, Leaf: leaf}, nil + return &tls.Certificate{Certificate: [][]byte{der, c.interCert.Raw}, PrivateKey: c.leafKey, Leaf: leaf}, nil } // parseCert returns the file's first certificate and rejects any block that is not a CA certificate: guests trust the whole file. diff --git a/sandboxd/egress/ca_issue.go b/sandboxd/egress/ca_issue.go index 2666bf2ba..b0378782a 100644 --- a/sandboxd/egress/ca_issue.go +++ b/sandboxd/egress/ca_issue.go @@ -36,7 +36,7 @@ func GenerateRoot(commonName string) (certPEM, keyPEM []byte, err error) { BasicConstraintsValid: true, IsCA: true, } - return marshalCA(tmpl, tmpl, &key.PublicKey, key, key) + return marshalCA(tmpl, tmpl, key, key) } // IssueIntermediate signs a per-node intermediate CA from the cluster root. @@ -68,15 +68,15 @@ func IssueIntermediate(rootCertPEM, rootKeyPEM []byte, nodeID string) (certPEM, IsCA: true, MaxPathLenZero: true, } - return marshalCA(tmpl, root, &key.PublicKey, rootKey, key) + return marshalCA(tmpl, root, rootKey, key) } -func marshalCA(tmpl, parent *x509.Certificate, pub *ecdsa.PublicKey, signer, keyToPEM *ecdsa.PrivateKey) (certPEM, keyPEM []byte, err error) { - der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, signer) +func marshalCA(tmpl, parent *x509.Certificate, signer, key *ecdsa.PrivateKey) (certPEM, keyPEM []byte, err error) { + der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, &key.PublicKey, signer) if err != nil { return nil, nil, fmt.Errorf("create cert: %w", err) } - keyDER, err := x509.MarshalPKCS8PrivateKey(keyToPEM) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return nil, nil, fmt.Errorf("marshal key: %w", err) } diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index 134656f50..876a2e9d0 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -302,7 +302,7 @@ func (e *Engine) DialGuestPort(ctx context.Context, vsockSocket string, port uin } // The forwarded stream's own reader takes the handshake reply, so an over-read stays buffered. r := bufio.NewReaderSize(conn, portForwardMax) - line, readErr := readBufLine(r, portForwardMax) + line, readErr := readBufLine(r) if readErr != nil { _ = conn.Close() if ctxErr := ctx.Err(); ctxErr != nil { @@ -337,7 +337,7 @@ func (e *Engine) dialVsock(ctx context.Context, vsockSocket string, port int) (n return nil, fmt.Errorf("write CONNECT: %w", err) } r := bufio.NewReaderSize(conn, connectMax) - reply, err := readBufLine(r, connectMax) + reply, err := readBufLine(r) if err != nil { _ = conn.Close() if ctxErr := ctx.Err(); ctxErr != nil { @@ -549,14 +549,11 @@ func belowFloor(v, floor string) (below, known bool) { return semver.Compare(v, floor) < 0, true } -func readBufLine(r *bufio.Reader, limit int) (string, error) { +func readBufLine(r *bufio.Reader) (string, error) { line, err := r.ReadSlice('\n') if err != nil { return "", err } - if len(line) > limit { - return "", fmt.Errorf("reply exceeds %d bytes", limit) - } return string(line[:len(line)-1]), nil } diff --git a/sandboxd/outbound/dial.go b/sandboxd/outbound/dial.go index 6a42dbb83..79b2a6de3 100644 --- a/sandboxd/outbound/dial.go +++ b/sandboxd/outbound/dial.go @@ -116,8 +116,8 @@ func canonicalAddr(ip netip.Addr) netip.Addr { } func internalAddr(self []netip.Addr, ip netip.Addr) bool { - return !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || - slices.Contains(self, ip) || slices.ContainsFunc(internalRanges, func(p netip.Prefix) bool { return p.Contains(ip) }) + return !ip.IsGlobalUnicast() || ip.IsPrivate() || slices.Contains(self, ip) || + slices.ContainsFunc(internalRanges, func(p netip.Prefix) bool { return p.Contains(ip) }) } // parseInternalAllow turns the allow-list into entries; config validation already rejected bad ones. diff --git a/sandboxd/outbound/doors.go b/sandboxd/outbound/doors.go index 25c2b6d9e..a280704a9 100644 --- a/sandboxd/outbound/doors.go +++ b/sandboxd/outbound/doors.go @@ -19,12 +19,10 @@ const doorConns = 256 // doors are a sandbox's proxy door and optional SOCKS5 door, with the proxy serving them once armed. type doors struct { - srv *http.Server - proxy *egress.Proxy - ln net.Listener - socks net.Listener - path string - socksPath string + srv *http.Server + proxy *egress.Proxy + ln net.Listener + socks net.Listener } func (d *doors) close() { @@ -33,10 +31,8 @@ func (d *doors) close() { d.proxy.Close() } _ = d.ln.Close() - _ = os.Remove(d.path) if d.socks != nil { _ = d.socks.Close() - _ = os.Remove(d.socksPath) } } @@ -86,15 +82,13 @@ func (c *doorConn) Close() error { func (c *doorConn) NetConn() net.Conn { return c.UnixConn } func bindDoors(vsock string, socks bool) (*doors, error) { - d := &doors{path: engine.EgressSocketPath(vsock)} - ln, err := listenUnix(d.path) + ln, err := listenUnix(engine.EgressSocketPath(vsock)) if err != nil { return nil, fmt.Errorf("listen egress: %w", err) } - d.ln = limitDoor(ln) + d := &doors{ln: limitDoor(ln)} if socks { - d.socksPath = engine.SocksSocketPath(vsock) - if ln, err = listenUnix(d.socksPath); err != nil { + if ln, err = listenUnix(engine.SocksSocketPath(vsock)); err != nil { d.close() return nil, fmt.Errorf("listen socks: %w", err) } From 34dc83bbcd8378d524db2a9a9d71e113fe13569c Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:14:30 +0800 Subject: [PATCH 47/70] review: DialFunc's Router methods serve only tests The only production egress.New takes a claim as its router, so the two methods move to the egress test file. --- sandboxd/egress/proxy.go | 8 +------- sandboxd/egress/proxy_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go index 642aa5697..c55438d47 100644 --- a/sandboxd/egress/proxy.go +++ b/sandboxd/egress/proxy.go @@ -128,15 +128,9 @@ type connPools struct { routed map[routeKey]*http.Transport } -// DialFunc opens the upstream connection for a permitted request; as a Router it sends every connection direct. +// DialFunc opens the upstream connection for a permitted request. type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) -func (d DialFunc) Route() string { return "" } - -func (d DialFunc) Dial(ctx context.Context, _, network, addr string) (net.Conn, error) { - return d(ctx, network, addr) -} - // Proxy is one sandbox's forward proxy, gated by Policy and audited per request. type Proxy struct { policy Evaluator diff --git a/sandboxd/egress/proxy_test.go b/sandboxd/egress/proxy_test.go index 4a9f5870f..574098441 100644 --- a/sandboxd/egress/proxy_test.go +++ b/sandboxd/egress/proxy_test.go @@ -627,6 +627,12 @@ func TestAChunkedResponseEndsCleanlyThroughTheProxy(t *testing.T) { } } +func (d DialFunc) Route() string { return "" } + +func (d DialFunc) Dial(ctx context.Context, _, network, addr string) (net.Conn, error) { + return d(ctx, network, addr) +} + type fakeSecrets map[string][2]string func (f fakeSecrets) Header(name string) (string, string, bool) { From 57b15c665b2a3e0c3c69a3e56ae30da841a6846f Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:15:03 +0800 Subject: [PATCH 48/70] review: a CA key error names the key its caller parsed parseECKey said intermediate key while parsing the root, so IssueIntermediate reported root key: intermediate key. validHostName takes the package's maxHostLen for the same DNS limit. --- sandboxd/egress/ca.go | 8 ++++---- sandboxd/egress/upstream.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sandboxd/egress/ca.go b/sandboxd/egress/ca.go index 2b333c9e2..5f88be677 100644 --- a/sandboxd/egress/ca.go +++ b/sandboxd/egress/ca.go @@ -51,7 +51,7 @@ func LoadCA(rootCertPEM, interCertPEM, interKeyPEM []byte) (*CA, error) { } interKey, err := parseECKey(interKeyPEM) if err != nil { - return nil, err + return nil, fmt.Errorf("intermediate key: %w", err) } if err = inter.CheckSignatureFrom(root); err != nil { return nil, fmt.Errorf("intermediate not signed by root: %w", err) @@ -161,15 +161,15 @@ func checkValidity(cert *x509.Certificate, what string) error { func parseECKey(pemBytes []byte) (*ecdsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { - return nil, fmt.Errorf("intermediate key: no pem block") + return nil, fmt.Errorf("no pem block") } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { - return nil, fmt.Errorf("parse intermediate key: %w", err) + return nil, fmt.Errorf("parse: %w", err) } key, ok := parsed.(*ecdsa.PrivateKey) if !ok { - return nil, fmt.Errorf("intermediate key: want ecdsa, got %T", parsed) + return nil, fmt.Errorf("want ecdsa, got %T", parsed) } return key, nil } diff --git a/sandboxd/egress/upstream.go b/sandboxd/egress/upstream.go index 08caef7d8..b4b69b6b7 100644 --- a/sandboxd/egress/upstream.go +++ b/sandboxd/egress/upstream.go @@ -219,7 +219,7 @@ func connectTunnel(ctx context.Context, conn net.Conn, u *url.URL, target string } func validHostName(s string) bool { - if s == "" || len(s) > 253 { + if s == "" || len(s) > maxHostLen { return false } return !strings.ContainsFunc(s, func(r rune) bool { From d6cd7181e1c64fbd81579677439cc35b9336ed34 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:15:29 +0800 Subject: [PATCH 49/70] review: cut a restating godoc and fold the engine package doc to one line --- sandboxd/engine/engine.go | 4 +--- sandboxd/outbound/outbound.go | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index 876a2e9d0..3103f9391 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -1,6 +1,4 @@ -// Package engine drives VM lifecycle through the cocoon CLI and dials the -// in-guest silkd over hybrid vsock. The CLI is cocoon's only stable contract -// (it exports no lifecycle library); no lifecycle call sits on the warm-claim path. +// Package engine drives VM lifecycle through the cocoon CLI, its only stable contract, and dials the in-guest silkd over hybrid vsock. package engine import ( diff --git a/sandboxd/outbound/outbound.go b/sandboxd/outbound/outbound.go index 24667d9f0..ea399c081 100644 --- a/sandboxd/outbound/outbound.go +++ b/sandboxd/outbound/outbound.go @@ -68,7 +68,6 @@ type Host struct { taps map[string]string } -// New builds a Host. func New(o Options) *Host { h := &Host{o: o, doors: map[string]*doors{}, prebound: map[string]*doors{}, taps: map[string]string{}} h.RefreshSelf() From 733e90c1abd91ddf6e34cffbc2343d930d3a6ec8 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:17:02 +0800 Subject: [PATCH 50/70] review: layout: silkd helpers live in silkd.go, tcpAddrPort beside proxy.go's other address helpers installca.go kept the generic guest write and exec helpers every other engine file uses; installOutputMax becomes guestOutputMax since it caps every guest exec. --- sandboxd/egress/proxy.go | 6 +++ sandboxd/egress/upstream.go | 6 --- sandboxd/engine/installca.go | 101 +---------------------------------- sandboxd/engine/silkd.go | 101 +++++++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 108 deletions(-) diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go index c55438d47..0f6de2976 100644 --- a/sandboxd/egress/proxy.go +++ b/sandboxd/egress/proxy.go @@ -752,6 +752,12 @@ func hostPort(authority string, def uint16) (host string, port uint16, ok bool) return host, uint16(n), err == nil && n != 0 && len(host) <= maxHostLen } +func tcpAddrPort(a net.Addr) netip.AddrPort { + ta, _ := a.(*net.TCPAddr) + ap := ta.AddrPort() + return netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port()) +} + // replaceParam fills the first name=placeholder pair of a query or form string, leaving every other byte as sent. func replaceParam(raw, name, placeholder, value string) string { for rest, at := raw, 0; rest != ""; { diff --git a/sandboxd/egress/upstream.go b/sandboxd/egress/upstream.go index b4b69b6b7..69841f07d 100644 --- a/sandboxd/egress/upstream.go +++ b/sandboxd/egress/upstream.go @@ -154,12 +154,6 @@ func DialUpstream(ctx context.Context, u *url.URL, target string, dial DialFunc) return &upstreamConn{Conn: conn, reader: reader, host: u.Host, target: dest}, nil } -func tcpAddrPort(a net.Addr) netip.AddrPort { - ta, _ := a.(*net.TCPAddr) - ap := ta.AddrPort() - return netip.AddrPortFrom(ap.Addr().Unmap(), ap.Port()) -} - // socksTunnel handshakes on conn itself, so the tunnel keeps the TCP conn's CloseWrite. func socksTunnel(ctx context.Context, conn net.Conn, u *url.URL, target string) error { var auth *proxy.Auth diff --git a/sandboxd/engine/installca.go b/sandboxd/engine/installca.go index 4c0da7717..75b9228a8 100644 --- a/sandboxd/engine/installca.go +++ b/sandboxd/engine/installca.go @@ -1,32 +1,15 @@ package engine import ( - "bytes" "context" - "errors" "fmt" - "slices" - - "github.com/cocoonstack/sandbox/protocol/wire" ) const ( - caCertGuestPath = "/usr/local/share/ca-certificates/sandbox-egress.crt" - caBundlePath = "/etc/ssl/certs/ca-certificates.crt" - installOutputMax = 4096 - // guestExecPATH is set because silkd starts the guest command with an empty environment. - guestExecPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + caCertGuestPath = "/usr/local/share/ca-certificates/sandbox-egress.crt" + caBundlePath = "/etc/ssl/certs/ca-certificates.crt" ) -type guestExitError struct { - code int32 - output string -} - -func (e *guestExitError) Error() string { - return fmt.Sprintf("exit code %d: %s", e.code, e.output) -} - // InstallCACert makes the guest trust the cluster root without update-ca-certificates. func (e *Engine) InstallCACert(ctx context.Context, vsockSocket string, certPEM []byte) error { ctx, cancel := context.WithTimeout(ctx, cmdTimeout) @@ -39,83 +22,3 @@ func (e *Engine) InstallCACert(ctx context.Context, vsockSocket string, certPEM } return nil } - -func (e *Engine) silkdWriteFile(ctx context.Context, vsockSocket, path string, mode uint32, data []byte) error { - s, err := e.dialSilkdSession(ctx, vsockSocket) - if err != nil { - return err - } - defer s.close() - // A send racing the guest's early answer+close hits EPIPE; prefer the buffered verdict. - sendErr := func() error { - if serr := s.send(wire.FsWrite{Path: path, Mode: &mode}); serr != nil { - return serr - } - for chunk := range slices.Chunk(data, wire.BulkChunk) { - if serr := s.send(wire.Data{Data: chunk}); serr != nil { - return serr - } - } - return s.send(wire.DataEnd{}) - }() - frame, err := s.recv() - if err != nil { - return errors.Join(sendErr, err) - } - switch resp := frame.(type) { - case *wire.Done: - return nil - case *wire.ErrorResp: - return fmt.Errorf("silkd %w", resp) - default: - return fmt.Errorf("unexpected silkd frame %q", resp.RespType()) - } -} - -func (e *Engine) silkdExec(ctx context.Context, vsockSocket string, argv ...string) error { - _, err := e.silkdOutput(ctx, vsockSocket, argv...) - return err -} - -func (e *Engine) silkdOutput(ctx context.Context, vsockSocket string, argv ...string) ([]byte, error) { - s, err := e.dialSilkdSession(ctx, vsockSocket) - if err != nil { - return nil, err - } - defer s.close() - sendErr := s.send(wire.Exec{Argv: argv, Env: map[string]string{"PATH": guestExecPATH}}) - if sendErr == nil { - // A child exiting without reading stdin races this close; prefer the buffered exit frame. - sendErr = s.send(wire.StdinClose{}) - } - var out, stdout []byte - for { - frame, err := s.recv() - if err != nil { - return nil, errors.Join(sendErr, err) - } - switch resp := frame.(type) { - case *wire.Started: - case *wire.Stdout: - out, stdout = appendCapped(out, resp.Data), appendCapped(stdout, resp.Data) - case *wire.Stderr: - out = appendCapped(out, resp.Data) - case *wire.Exit: - if resp.Code != 0 { - return nil, &guestExitError{code: resp.Code, output: string(bytes.TrimSpace(out))} - } - return stdout, nil - case *wire.ErrorResp: - return nil, fmt.Errorf("silkd %w", resp) - default: - return nil, fmt.Errorf("unexpected silkd frame %q", resp.RespType()) - } - } -} - -func appendCapped(out, data []byte) []byte { - if room := installOutputMax - len(out); room > 0 { - out = append(out, data[:min(len(data), room)]...) - } - return out -} diff --git a/sandboxd/engine/silkd.go b/sandboxd/engine/silkd.go index 211f7fd93..0e5abd729 100644 --- a/sandboxd/engine/silkd.go +++ b/sandboxd/engine/silkd.go @@ -2,20 +2,35 @@ package engine import ( "bufio" + "bytes" "context" + "errors" "fmt" "io" "net" + "slices" "github.com/cocoonstack/sandbox/protocol/wire" ) const ( - silkdListMax = 4096 - silkdReadMax = 64 << 10 - mountInfoMax = 1 << 20 + silkdListMax = 4096 + silkdReadMax = 64 << 10 + mountInfoMax = 1 << 20 + guestOutputMax = 4096 + // guestExecPATH is set because silkd starts the guest command with an empty environment. + guestExecPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" ) +type guestExitError struct { + code int32 + output string +} + +func (e *guestExitError) Error() string { + return fmt.Sprintf("exit code %d: %s", e.code, e.output) +} + // silkdSession is a dialed silkd conn bound to a ctx, with wire-typed request/reply helpers. type silkdSession struct { conn net.Conn @@ -149,3 +164,83 @@ func (e *Engine) silkdOnce[T wire.Response](ctx context.Context, vsockSocket str } return fmt.Errorf("unexpected silkd frame %q", frame.RespType()) } + +func (e *Engine) silkdWriteFile(ctx context.Context, vsockSocket, path string, mode uint32, data []byte) error { + s, err := e.dialSilkdSession(ctx, vsockSocket) + if err != nil { + return err + } + defer s.close() + // A send racing the guest's early answer+close hits EPIPE; prefer the buffered verdict. + sendErr := func() error { + if serr := s.send(wire.FsWrite{Path: path, Mode: &mode}); serr != nil { + return serr + } + for chunk := range slices.Chunk(data, wire.BulkChunk) { + if serr := s.send(wire.Data{Data: chunk}); serr != nil { + return serr + } + } + return s.send(wire.DataEnd{}) + }() + frame, err := s.recv() + if err != nil { + return errors.Join(sendErr, err) + } + switch resp := frame.(type) { + case *wire.Done: + return nil + case *wire.ErrorResp: + return fmt.Errorf("silkd %w", resp) + default: + return fmt.Errorf("unexpected silkd frame %q", resp.RespType()) + } +} + +func (e *Engine) silkdExec(ctx context.Context, vsockSocket string, argv ...string) error { + _, err := e.silkdOutput(ctx, vsockSocket, argv...) + return err +} + +func (e *Engine) silkdOutput(ctx context.Context, vsockSocket string, argv ...string) ([]byte, error) { + s, err := e.dialSilkdSession(ctx, vsockSocket) + if err != nil { + return nil, err + } + defer s.close() + sendErr := s.send(wire.Exec{Argv: argv, Env: map[string]string{"PATH": guestExecPATH}}) + if sendErr == nil { + // A child exiting without reading stdin races this close; prefer the buffered exit frame. + sendErr = s.send(wire.StdinClose{}) + } + var out, stdout []byte + for { + frame, err := s.recv() + if err != nil { + return nil, errors.Join(sendErr, err) + } + switch resp := frame.(type) { + case *wire.Started: + case *wire.Stdout: + out, stdout = appendCapped(out, resp.Data), appendCapped(stdout, resp.Data) + case *wire.Stderr: + out = appendCapped(out, resp.Data) + case *wire.Exit: + if resp.Code != 0 { + return nil, &guestExitError{code: resp.Code, output: string(bytes.TrimSpace(out))} + } + return stdout, nil + case *wire.ErrorResp: + return nil, fmt.Errorf("silkd %w", resp) + default: + return nil, fmt.Errorf("unexpected silkd frame %q", resp.RespType()) + } + } +} + +func appendCapped(out, data []byte) []byte { + if room := guestOutputMax - len(out); room > 0 { + out = append(out, data[:min(len(data), room)]...) + } + return out +} From 9f5b04f38bfa5dacee2264d52fc6283afba57ba6 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:20:58 +0800 Subject: [PATCH 51/70] docs: name the capture fence, the restore budget, a preview bind failure and a cut upstream --- docs/cluster.md | 2 +- docs/deploy.md | 6 +++--- docs/egress.md | 3 ++- docs/sandboxd-api.md | 8 +++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index 0b3f80629..1c3023588 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -400,7 +400,7 @@ checkpoint store and keeps each claim's registry row on the latest snapshot: - **Self-fencing.** A node whose renewal is overdue by 12 seconds (the lease length minus a 3 second margin, counted from the start of the last renewal that answered) stops serving every replicated claim it holds: relay, exec, - ports and preview answer `503 lease lost; retry` (a read through a + ports, preview, fork, checkpoint and promote answer `503 lease lost; retry` (a read through a [reader](deploy.md#reading-sleeping-claims-without-waking-them) after the `101` gets an `error` frame instead), and `/owner` answers 404 so a `Lookup` moves on to the other nodes. A claim is replicated when its policy diff --git a/docs/deploy.md b/docs/deploy.md index fe0b87ad7..c69d40496 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -676,7 +676,7 @@ VMM that cocoon could not move into its own scope, and any `cocoon` call still in flight, outlive the stop instead of dying with the service's cgroup. Claimed sandboxes are reaped when their TTL expires (default 5m, capped at 24h). -A snapshot save, hibernate or export has 15 minutes, plus 3 to exit once +A snapshot save, hibernate, export or restore has 15 minutes, plus 3 to exit once canceled, and at most 8 saves and hibernates run at once on a node. A call that dials a guest gives up after 10 s, since a paused or wedged guest never answers, and gets `502`. @@ -1170,8 +1170,8 @@ returned endpoint is deliberately mapped through a local bridge. ## Preview URLs `preview_listen` starts a second HTTP server that serves a sandbox's guest -HTTP port under a signed, expiring shareable URL. The whole mechanism is in -sandboxd: +HTTP port under a signed, expiring shareable URL; a node that cannot bind it +exits. The whole mechanism is in sandboxd: - **Minting** (`sb.PreviewURL(ctx, port, ttl)`): the owner node signs a token embedding `{sandbox, port, owner, exp}` with `preview_secret`; the URL's diff --git a/docs/egress.md b/docs/egress.md index 9c66891b8..38ff153b5 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -18,7 +18,8 @@ connection to sandboxd over vsock (`CID2:2049` → the host UDS `_2049`). sandboxd serves a per-sandbox forward proxy there, bound to that sandbox's identity by the socket path — no in-band token. The proxy evaluates the policy, injects the matched rule's secret host-side, dials -the origin, and relays the response. +the origin, and relays the response. An origin that dies mid-response has the +guest's connection cut, so the client sees a truncated body, never a clean end. The base image sets `http_proxy`/`https_proxy`/`no_proxy` on silkd's unit, and silkd forwards exactly those variables into every exec — whenever nothing diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index ffb325959..b2db9e51d 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -606,7 +606,7 @@ an unknown `on_expire`; 401 bad api token; 403 the parent's tenant was removed parent (neither forks, checkpoints, or promotes; see [egress](egress.md)), a failed parent, or an archived parent (`POST /wake` or an exec wakes it first); 429 node or the parent's tenant at `max_claims`, the node at `max_running`, the node draining, or the disk -under `disk_reserve`. +under `disk_reserve`; 503 `lease lost; retry` for a replicated parent while its node's lease is overdue. ## POST /v1/sandboxes/{id}/promote @@ -648,7 +648,8 @@ the export they were set on, which the new one replaces. configured pool, the template is owned by another tenant, or the sandbox is on the egress lane, has volumes attached (see [egress](egress.md)), or is archived (`POST /wake` or an exec wakes it first), 404 unknown id or wrong -sandbox token, 429 while the disk is under [`disk_reserve`](deploy.md). +sandbox token, 429 while the disk is under [`disk_reserve`](deploy.md), 503 +`lease lost; retry` for a replicated sandbox while its node's lease is overdue. ## DELETE /v1/templates?template=…&net=…&size=… @@ -866,7 +867,8 @@ created_at}}` — `tenant` records the calling tenant, absent for root. 400 bad body or name, 401 bad api token, 404 unknown id or wrong sandbox token, 409 egress-lane sandbox, one with volumes attached (see [egress](egress.md)), or an archived one (`POST /wake` or an exec wakes it -first), 429 while the disk is under [`disk_reserve`](deploy.md). +first), 429 while the disk is under [`disk_reserve`](deploy.md), 503 `lease lost; retry` +for a replicated sandbox while its node's lease is overdue. ## POST /v1/checkpoints/{id}/claim From fe5e09f0e1feb58da217015b4accc36703c6f29b Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:28:59 +0800 Subject: [PATCH 52/70] review: VolumePatch keeps the SDK's omitzero tags, as the wire parity test requires --- sandboxd/types/api.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sandboxd/types/api.go b/sandboxd/types/api.go index 218e0f546..0d81b5407 100644 --- a/sandboxd/types/api.go +++ b/sandboxd/types/api.go @@ -32,8 +32,8 @@ type VolumeRegistration struct { // VolumePatch is the wire body of PATCH /v1/sandboxes/{id}/volumes. type VolumePatch struct { - Add []Volume `json:"add,omitempty"` - Remove []string `json:"remove,omitempty"` + Add []Volume `json:"add,omitzero"` + Remove []string `json:"remove,omitzero"` } // VolumePhase is how far one held volume has converged. From 0823ea541b72d851ae52668507f604a87be122a9 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:28:59 +0800 Subject: [PATCH 53/70] engine: a clone restores memory like a restore, so it gets the dump budget cocoon's vm clone and vm restore share restoreVM and its 10 minute memory transfer, and an archived wake and a fork go through clone. A hung refill clone now holds its slot for up to 18 minutes, which stays bounded and loud. --- docs/deploy.md | 2 +- sandboxd/engine/engine.go | 4 ++-- sandboxd/engine/engine_test.go | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index c69d40496..a52e0dc15 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -676,7 +676,7 @@ VMM that cocoon could not move into its own scope, and any `cocoon` call still in flight, outlive the stop instead of dying with the service's cgroup. Claimed sandboxes are reaped when their TTL expires (default 5m, capped at 24h). -A snapshot save, hibernate, export or restore has 15 minutes, plus 3 to exit once +A snapshot save, hibernate, export, restore or clone has 15 minutes, plus 3 to exit once canceled, and at most 8 saves and hibernates run at once on a node. A call that dials a guest gives up after 10 s, since a paused or wedged guest never answers, and gets `502`. diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index 3103f9391..15ff99f76 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -127,12 +127,12 @@ func (e *Engine) VersionWarning(ctx context.Context) (version, warning string) { // Clone restores a VM from an exported golden directory. func (e *Engine) Clone(ctx context.Context, fromDir, name string, key types.PoolKey) (types.VMRecord, error) { - return e.runRecord(ctx, cmdTimeout, cmdTermGrace, e.cloneArgs(fromDir, name, key)...) + return e.runRecord(ctx, dumpTimeout, dumpTermGrace, e.cloneArgs(fromDir, name, key)...) } // CloneSnap clones from a local-store snapshot by name — fork's fast path. func (e *Engine) CloneSnap(ctx context.Context, snap, name string, key types.PoolKey) (types.VMRecord, error) { - return e.runRecord(ctx, cmdTimeout, cmdTermGrace, e.cloneSnapArgs(snap, name, key)...) + return e.runRecord(ctx, dumpTimeout, dumpTermGrace, e.cloneSnapArgs(snap, name, key)...) } // RunCold boots a VM from the template image with a storage-sized disk (empty = cocoon's default), returning its lifecycle record. diff --git a/sandboxd/engine/engine_test.go b/sandboxd/engine/engine_test.go index a29f1568e..a45ae68c2 100644 --- a/sandboxd/engine/engine_test.go +++ b/sandboxd/engine/engine_test.go @@ -238,7 +238,7 @@ func TestACanceledCommandGetsSIGTERMSoCocoonCanResumeTheVM(t *testing.T) { } } -func TestARestoreGetsTheDumpTermGraceAndACloneTheCommandOne(t *testing.T) { +func TestAMemoryRestoreGetsTheDumpTermGraceAndAColdBootTheCommandOne(t *testing.T) { t.Parallel() for _, tt := range []struct { name string @@ -252,6 +252,10 @@ func TestARestoreGetsTheDumpTermGraceAndACloneTheCommandOne(t *testing.T) { {"clone", func(ctx context.Context, e *Engine) error { _, err := e.Clone(ctx, "dir", "vm", types.PoolKey{}) return err + }, "exit status 3"}, + {"cold boot", func(ctx context.Context, e *Engine) error { + _, err := e.RunCold(ctx, "vm", types.PoolKey{}, "") + return err }, "signal: killed"}, } { t.Run(tt.name, func(t *testing.T) { From 85ac68a416823d86201c221c048b182f5b110303 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 03:29:34 +0800 Subject: [PATCH 54/70] review: silkd's language server feed needs no flag or channel, and one helper sets the claim env --- silkd/src/lsp.rs | 36 +++++++++++++----------------------- silkd/src/pty.rs | 12 +++--------- silkd/src/session.rs | 13 ++++--------- silkd/src/sysutil.rs | 5 +++++ 4 files changed, 25 insertions(+), 41 deletions(-) diff --git a/silkd/src/lsp.rs b/silkd/src/lsp.rs index 6f7193e6d..4e2bcb869 100644 --- a/silkd/src/lsp.rs +++ b/silkd/src/lsp.rs @@ -9,7 +9,8 @@ use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::process::{Child, ChildStdin, ChildStdout, Command}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; use crate::proto::{self, BULK_CHUNK, ErrorKind, Request, Response}; use crate::sysutil; @@ -120,9 +121,8 @@ impl Broker { return Err(e); } - let (gone_tx, gone_rx) = oneshot::channel(); - let feed = tokio::spawn(feed_stdin(client, stdin, gone_tx)); - let res = pump_stdout(stdout, w, gone_rx).await; + let mut feed = tokio::spawn(feed_stdin(client, stdin)); + let res = pump_stdout(stdout, w, &mut feed).await; // aborting mid-write is safe: the child is killed next. feed.abort(); self.reap(server_id).await; @@ -180,31 +180,21 @@ async fn kill(mut server: Server) { let _ = server.child.wait().await; } -async fn feed_stdin( - mut client: mpsc::Receiver, - stdin: ChildStdin, - gone: oneshot::Sender<()>, -) { - let mut stdin = Some(stdin); - while let Some(req) = client.recv().await { - // a stray frame closes rather than errors: this task has no writer. - let fed = match (req, stdin.as_mut()) { - (Request::Data { data }, Some(sink)) => { - sink.write_all(&data).await.is_ok() && sink.flush().await.is_ok() - } - _ => false, - }; - if !fed { - stdin = None; +async fn feed_stdin(mut client: mpsc::Receiver, mut stdin: ChildStdin) { + // a stray frame closes rather than errors: this task has no writer. + while let Some(Request::Data { data }) = client.recv().await { + if stdin.write_all(&data).await.is_err() || stdin.flush().await.is_err() { + break; } } - let _ = gone.send(()); + drop(stdin); + while client.recv().await.is_some() {} } async fn pump_stdout( mut stdout: ChildStdout, w: &mut W, - mut gone: oneshot::Receiver<()>, + client_gone: &mut JoinHandle<()>, ) -> std::io::Result<()> { let mut buf = vec![0u8; BULK_CHUNK]; let mut frame = Vec::new(); @@ -215,7 +205,7 @@ async fn pump_stdout( Ok(n) => proto::write_chunk_frame(w, &mut frame, "data", &buf[..n]).await?, Err(e) => return proto::err_frame(w, &e, "read lsp stdout").await, }, - _ = &mut gone => break, + _ = &mut *client_gone => break, } } proto::write_frame(w, &Response::Done).await diff --git a/silkd/src/pty.rs b/silkd/src/pty.rs index 5d77eb9f6..9991c1c13 100644 --- a/silkd/src/pty.rs +++ b/silkd/src/pty.rs @@ -38,20 +38,14 @@ pub async fn open( }; let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()); - let claim = sysutil::claim_env(); let mut cmd = Command::new(&shell); cmd.arg("-i"); if let Some(dir) = &req.cwd { cmd.current_dir(dir); } - cmd.env_clear() - .envs(sysutil::base_env().iter().copied()) - .envs( - sysutil::parse_env_file(&claim) - .iter() - .map(|(k, v)| (*k, &**v)), - ) - .envs(&req.env); + cmd.env_clear().envs(sysutil::base_env().iter().copied()); + sysutil::apply_claim_env(&mut cmd); + cmd.envs(&req.env); if let Some(user) = &req.user && let Err(e) = sysutil::apply_user(&mut cmd, user) { diff --git a/silkd/src/session.rs b/silkd/src/session.rs index 9dddb94a6..04251d33a 100644 --- a/silkd/src/session.rs +++ b/silkd/src/session.rs @@ -44,15 +44,10 @@ impl Table { format!("environment name {name:?} is not a shell identifier"), )); } - let claim = sysutil::claim_env(); - let mut child = Command::new("bash") - .env_clear() - .envs(sysutil::base_env().iter().copied()) - .envs( - sysutil::parse_env_file(&claim) - .iter() - .map(|(k, v)| (*k, &**v)), - ) + let mut cmd = Command::new("bash"); + cmd.env_clear().envs(sysutil::base_env().iter().copied()); + sysutil::apply_claim_env(&mut cmd); + let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) // null, not piped: the init line `exec 2>&1` repoints fd 2 at the stdout pipe. diff --git a/silkd/src/sysutil.rs b/silkd/src/sysutil.rs index c7e56ad6d..1458dc508 100644 --- a/silkd/src/sysutil.rs +++ b/silkd/src/sysutil.rs @@ -90,6 +90,11 @@ pub fn claim_env() -> String { std::fs::read_to_string(CLAIM_ENV_PATH).unwrap_or_default() } +pub fn apply_claim_env(cmd: &mut Command) { + let claim = claim_env(); + cmd.envs(parse_env_file(&claim).iter().map(|(k, v)| (*k, &**v))); +} + /// Parses the subset the host writes: one `KEY="value"` per line, with systemd's double-quote escapes. pub fn parse_env_file(text: &str) -> Vec<(&str, Cow<'_, str>)> { text.lines() From d815506e75415a069a0451e2f1b4617d99d746ac Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:54:41 +0800 Subject: [PATCH 55/70] review: refill.go keeps the refill loop; golden builds go to golden.go, VM provisioning to provision.go, destroyAll and dropSnap beside destroy, runBounded to pool.go with its callers across the package --- sandboxd/pool/golden.go | 231 +++++++++++++++++++++ sandboxd/pool/pool.go | 20 ++ sandboxd/pool/provision.go | 162 +++++++++++++++ sandboxd/pool/refill.go | 405 ------------------------------------- sandboxd/pool/remove.go | 14 ++ 5 files changed, 427 insertions(+), 405 deletions(-) create mode 100644 sandboxd/pool/golden.go create mode 100644 sandboxd/pool/provision.go diff --git a/sandboxd/pool/golden.go b/sandboxd/pool/golden.go new file mode 100644 index 000000000..a74ca64c9 --- /dev/null +++ b/sandboxd/pool/golden.go @@ -0,0 +1,231 @@ +package pool + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/projecteru2/core/log" + + "github.com/cocoonstack/sandbox/sandboxd/config" + "github.com/cocoonstack/sandbox/sandboxd/engine" + "github.com/cocoonstack/sandbox/sandboxd/types" +) + +func (m *Manager) buildGolden(ctx context.Context, p *pool) { + logger := log.WithFunc("pool.buildGolden") + hash := p.hash + name := vmPrefix + "gb-" + hash + snap := goldenPrefix + hash + final := filepath.Join(m.goldensDir(), hash) + imageID := m.imageIDs(ctx)[p.key.Template] + v := m.view.Load() + m.mu.Lock() + adopted := m.adoptGolden(p, imageID) + p.building = !adopted + m.mu.Unlock() + if adopted { + m.refillOnce(ctx) + return + } + + err := m.buildGoldenSteps(ctx, v, p.key, name, snap, final, imageID) + if rmErr := m.eng.SnapshotRemove(ctx, snap); rmErr != nil && err == nil { + logger.Debugf(ctx, "drop golden snapshot %s: %v", snap, rmErr) + } + m.destroy(ctx, name) + + m.mu.Lock() + p.building = false + stale := err == nil && m.goldenStamp(m.view.Load(), p.key, imageID) != m.goldenStamp(v, p.key, imageID) + switch { + case stale: + p.nextBuild = time.Time{} + case err == nil: + p.goldenDir, p.imageID = final, imageID + p.goldenGen++ + default: + p.nextBuild = time.Now().Add(buildRetryDelay) + // A cold boot hits the same bridge and disk as a refill, so it feeds the park too + if reason := engine.CapacitySignature(err); reason != "" { + m.noteCapacityLocked(time.Now(), reason) + } + } + m.mu.Unlock() + if err != nil { + logger.Errorf(ctx, err, "build golden for %s", hash) + return + } + if stale { + logger.Infof(ctx, "golden for %s was built against a superseded config; rebuilding", hash) + m.kickRefill() + return + } + logger.Infof(ctx, "golden ready for %s (%s)", hash, p.key.Template) + m.kickRefill() +} + +func (m *Manager) buildGoldenSteps(ctx context.Context, v *configView, key types.PoolKey, name, snap, final, imageID string) error { + rec, err := m.eng.RunCold(ctx, name, key, v.poolStorage[key]) + if err != nil { + return err + } + sock, err := m.probeReady(ctx, name, rec.VsockSocket, coldProbeTimeout) + if err != nil { + return err + } + if err = m.out.PrepareGuest(ctx, v.out, key, sock); err != nil { + return err + } + if err = m.runWarmup(ctx, v.poolWarmups[key], sock); err != nil { + return fmt.Errorf("warmup: %w", err) + } + if err = m.eng.SettleGuest(ctx, sock); err != nil { + log.WithFunc("pool.buildGoldenSteps").Warnf(ctx, "capture golden of %s before its guest settled: %v", key.Template, err) + } + if err := m.dumpGolden(ctx, name, snap, final); err != nil { + return err + } + stamp := m.goldenStamp(v, key, imageID) + if err := os.WriteFile(final+goldenStampSuffix, []byte(stamp), 0o644); err != nil { //nolint:gosec // public stamp + return fmt.Errorf("write golden stamp: %w", err) + } + return nil +} + +func (m *Manager) runWarmup(ctx context.Context, warmup []string, sock string) error { + if len(warmup) == 0 { + return nil + } + return m.eng.Warmup(ctx, sock, warmup) +} + +// adoptGolden points p at an on-disk golden whose stamp matches what the pool bakes now. +func (m *Manager) adoptGolden(p *pool, imageID string) bool { + g := filepath.Join(m.goldensDir(), p.hash) + stamp, err := os.ReadFile(g + goldenStampSuffix) //nolint:gosec // node-local golden path + want := m.goldenStamp(m.view.Load(), p.key, imageID) + if err != nil || string(stamp) != want || !dirExists(g) { + return false + } + p.goldenDir, p.imageID = g, imageID + p.goldenGen++ + return true +} + +// dropPoolLocked's rename hides the golden from adoptGolden, and the slow unlink runs off the lock. +func (m *Manager) dropPoolLocked(ctx context.Context, p *pool) { + delete(m.pools, p.key) + g := filepath.Join(m.goldensDir(), p.hash) + _ = os.Remove(g + goldenStampSuffix) + doomed := g + "-" + randHex(4) + ".tmp" + if os.Rename(g, doomed) != nil { + return + } + go func() { + if os.RemoveAll(doomed) == nil { + log.WithFunc("pool.dropPoolLocked").Infof(ctx, "removed the golden of dropped pool %s", p.key.Template) + } + }() +} + +func (m *Manager) retireUnclonableLocked(p *pool, gen uint64, now time.Time) bool { + if p.cloneFails < refillFailStreak || p.goldenGen != gen || p.goldenDir == "" || now.Sub(p.goldenRetiredAt) < buildRetryDelay { + return false + } + p.goldenDir, p.imageID = "", "" + p.goldenGen++ + p.nextBuild, p.goldenRetiredAt = time.Time{}, now + p.refillFails, p.nextRefill, p.cloneFails = 0, time.Time{}, 0 + _ = os.Remove(filepath.Join(m.goldensDir(), p.hash) + goldenStampSuffix) + return true +} + +func (m *Manager) rebuildRetiredGolden(ctx context.Context, hash string) { + log.WithFunc("pool.rebuildRetiredGolden").Warnf(ctx, "golden of %s failed %d clones running; rebuilding it", hash, refillFailStreak) + m.kickRefill() +} + +func (m *Manager) goldenStamp(v *configView, key types.PoolKey, imageID string) string { + var fp string + if v.out.Intercepts(key) { + fp = m.out.CAFingerprint() + } + fields := []string{fp, string(m.out.Lane(key)), imageID} + // Only a sized pool stamps its disk, in bytes so "40G" and "40GiB" match and unsized goldens stay adoptable + if storage := v.poolStorage[key]; storage != "" { + n, _ := config.StorageBytes(storage) + fields = append(fields, "storage="+strconv.FormatInt(n, 10)) + } + if v.noBalloon { + fields = append(fields, "no_balloon") + } + return strings.Join(append(fields, v.poolWarmups[key]...), "\x00") +} + +func (m *Manager) checkImagesOnce(ctx context.Context) { + m.mu.Lock() + empty := len(m.pools) == 0 + m.mu.Unlock() + if empty { + return + } + ids, err := m.eng.ImageIDs(ctx) + if err != nil { + log.WithFunc("pool.checkImagesOnce").Warnf(ctx, "list images: %v", err) + return + } + var trim []string + m.mu.Lock() + for _, p := range m.pools { + if p.goldenDir == "" || ids[p.key.Template] == p.imageID { + continue + } + p.goldenDir, p.imageID = "", "" + trim = append(trim, p.trimWarm(0)...) + } + m.mu.Unlock() + m.destroyAll(ctx, trim) +} + +func (m *Manager) imageIDs(ctx context.Context) map[string]string { + ids, err := m.eng.ImageIDs(ctx) + if err != nil { + log.WithFunc("pool.imageIDs").Warnf(ctx, "list images: %v", err) + } + return ids +} + +func (m *Manager) dumpGolden(ctx context.Context, name, snap, final string) error { + select { + case m.goldenDumps <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-m.goldenDumps }() + if err := m.eng.SnapshotSave(ctx, name, snap); err != nil { + return err + } + return m.exportGolden(ctx, snap, final) +} + +// exportGolden exports snap into final through a unique sibling *.tmp dir. +func (m *Manager) exportGolden(ctx context.Context, snap, final string) error { + staging, err := os.MkdirTemp(filepath.Dir(final), filepath.Base(final)+"-*.tmp") + if err != nil { + return fmt.Errorf("stage golden: %w", err) + } + defer func() { _ = os.RemoveAll(staging) }() + tmp := filepath.Join(staging, "export") // cocoon wants the target absent + if err := m.eng.SnapshotExport(ctx, snap, tmp); err != nil { + return err + } + if err := os.RemoveAll(final); err != nil { + return fmt.Errorf("clear golden dir: %w", err) + } + return os.Rename(tmp, final) +} diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index f81ba9824..f0ffcf669 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -741,6 +741,26 @@ func (m *Manager) sweepStoreGenerations(ctx context.Context) { } } +// runBounded fans f over n items on the refill semaphore, sharing the node-wide budget but never the share maintSem leaves to refill. +func (m *Manager) runBounded(ctx context.Context, n int, f func(context.Context, int)) *sync.WaitGroup { + var wg sync.WaitGroup + for i := range n { + // Acquire inside the goroutine so a batch larger than the budget cannot block the caller + wg.Go(func() { + for _, sem := range [...]chan struct{}{m.maintSem, m.refillSem} { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return + } + defer func() { <-sem }() + } + f(ctx, i) + }) + } + return &wg +} + func (m *Manager) untrack(set map[string]struct{}, key string) { m.mu.Lock() delete(set, key) diff --git a/sandboxd/pool/provision.go b/sandboxd/pool/provision.go new file mode 100644 index 000000000..786ed1e5c --- /dev/null +++ b/sandboxd/pool/provision.go @@ -0,0 +1,162 @@ +package pool + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/cocoonstack/sandbox/sandboxd/engine" + "github.com/cocoonstack/sandbox/sandboxd/types" +) + +type vmProvisioner func(name string) (types.VMRecord, error) + +func (m *Manager) provision(ctx context.Context, key types.PoolKey, golden string) (*types.Sandbox, error) { + if golden == "" { + return m.provisionCold(ctx, key) + } + return m.provisionVM(ctx, key, claimProbeTimeout, func(name string) (types.VMRecord, error) { + return m.eng.Clone(ctx, golden, name, key) + }) +} + +func (m *Manager) provisionResolved(ctx context.Context, key types.PoolKey, g goldenResolution) (*types.Sandbox, error) { + if g.held == nil { + return m.provision(ctx, key, g.dir) + } + return m.provisionVM(ctx, key, claimProbeTimeout, func(name string) (types.VMRecord, error) { + rec, err := m.eng.Clone(ctx, g.dir, name, key) + m.noteClaimClone(ctx, g, err) + return rec, err + }) +} + +func (m *Manager) noteClaimClone(ctx context.Context, g goldenResolution, err error) { + capReason := engine.CapacitySignature(err) + m.mu.Lock() + g.held.countClone(ctx, g.gen, err == nil, capReason) + retired := m.retireUnclonableLocked(g.held, g.gen, time.Now()) + m.mu.Unlock() + if retired { + m.rebuildRetiredGolden(ctx, g.held.hash) + } +} + +func (m *Manager) provisionCold(ctx context.Context, key types.PoolKey) (*types.Sandbox, error) { + sb, err := m.provisionVM(ctx, key, coldProbeTimeout, func(name string) (types.VMRecord, error) { + return m.eng.RunCold(ctx, name, key, m.view.Load().poolStorage[key]) + }) + if err != nil { + return nil, err + } + // A pre-golden cold claim must trust the root, or intercepted hosts fail TLS for its life + if err := m.out.PrepareGuest(ctx, m.view.Load().out, key, sb.VsockSocket); err != nil { + m.destroy(ctx, sb.VMName) + return nil, err + } + return sb, nil +} + +func (m *Manager) provisionVM(ctx context.Context, key types.PoolKey, probeTimeout time.Duration, create vmProvisioner) (*types.Sandbox, error) { + sb, err := m.startVM(ctx, key, create) + if err != nil { + return nil, err + } + return m.readyVM(ctx, sb, time.Now().Add(probeTimeout)) +} + +func (m *Manager) startVM(ctx context.Context, key types.PoolKey, create vmProvisioner) (*types.Sandbox, error) { + name := vmName(key) + rec, err := create(name) + if err != nil { + m.destroy(ctx, name) + return nil, err + } + return &types.Sandbox{VMName: name, Key: key, VsockSocket: rec.VsockSocket, TAP: rec.TapDevice()}, nil +} + +func (m *Manager) readyVM(ctx context.Context, sb *types.Sandbox, deadline time.Time) (*types.Sandbox, error) { + sock, err := m.probeReady(ctx, sb.VMName, sb.VsockSocket, time.Until(deadline)) + if err != nil { + m.destroy(ctx, sb.VMName) + return nil, err + } + sb.VsockSocket = sock + return sb, nil +} + +func (m *Manager) readyBounded(ctx context.Context, sb *types.Sandbox, deadline time.Time) (*types.Sandbox, error) { + probeCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + select { + case m.probeSem <- struct{}{}: + defer func() { <-m.probeSem }() + case <-probeCtx.Done(): + m.destroy(ctx, sb.VMName) + return nil, probeCtx.Err() + } + return m.readyVM(probeCtx, sb, deadline) +} + +func (m *Manager) cloneBatch(ctx context.Context, count int, key types.PoolKey, create vmProvisioner) ([]*types.Sandbox, error) { + children := make([]*types.Sandbox, count) + errs := make([]error, count) + var wg sync.WaitGroup + for i := range count { + m.refillSem <- struct{}{} + wg.Go(func() { + child, err := m.startVM(ctx, key, create) + <-m.refillSem + if err == nil { + child, err = m.readyBounded(ctx, child, time.Now().Add(claimProbeTimeout)) + } + children[i], errs[i] = child, err + }) + } + wg.Wait() + if err := errors.Join(errs...); err != nil { + for _, child := range children { + if child != nil { + m.destroy(ctx, child.VMName) + } + } + return nil, err + } + return children, nil +} + +// probeReady waits until a VM's silkd answers, polling `vm list` when sock is empty. +func (m *Manager) probeReady(ctx context.Context, name, sock string, timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + if sock == "" { + var err error + sock, err = m.vsockOf(ctx, name) + for err != nil && time.Now().Before(deadline) && ctx.Err() == nil { + time.Sleep(vsockPollInterval) + sock, err = m.vsockOf(ctx, name) + } + if err != nil { + return "", err + } + } + if err := m.eng.Probe(ctx, sock, time.Until(deadline)); err != nil { + return "", err + } + return sock, nil +} + +func (m *Manager) vsockOf(ctx context.Context, name string) (string, error) { + vm, ok, err := m.eng.Inspect(ctx, name) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("vm %s not found after create", name) + } + if vm.VsockSocket == "" { + return "", fmt.Errorf("vm %s has no vsock socket", name) + } + return vm.VsockSocket, nil +} diff --git a/sandboxd/pool/refill.go b/sandboxd/pool/refill.go index be6f4e9b4..b730407fa 100644 --- a/sandboxd/pool/refill.go +++ b/sandboxd/pool/refill.go @@ -2,24 +2,15 @@ package pool import ( "context" - "errors" "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "sync" "time" "github.com/projecteru2/core/log" - "github.com/cocoonstack/sandbox/sandboxd/config" "github.com/cocoonstack/sandbox/sandboxd/engine" "github.com/cocoonstack/sandbox/sandboxd/types" ) -type vmProvisioner func(name string) (types.VMRecord, error) - // kickRefill nudges Run past its refill ticker without blocking the claim path. func (m *Manager) kickRefill() { select { @@ -181,94 +172,6 @@ func (m *Manager) capacityReasonLocked(now time.Time) string { return "" } -func (m *Manager) buildGolden(ctx context.Context, p *pool) { - logger := log.WithFunc("pool.buildGolden") - hash := p.hash - name := vmPrefix + "gb-" + hash - snap := goldenPrefix + hash - final := filepath.Join(m.goldensDir(), hash) - imageID := m.imageIDs(ctx)[p.key.Template] - v := m.view.Load() - m.mu.Lock() - adopted := m.adoptGolden(p, imageID) - p.building = !adopted - m.mu.Unlock() - if adopted { - m.refillOnce(ctx) - return - } - - err := m.buildGoldenSteps(ctx, v, p.key, name, snap, final, imageID) - if rmErr := m.eng.SnapshotRemove(ctx, snap); rmErr != nil && err == nil { - logger.Debugf(ctx, "drop golden snapshot %s: %v", snap, rmErr) - } - m.destroy(ctx, name) - - m.mu.Lock() - p.building = false - stale := err == nil && m.goldenStamp(m.view.Load(), p.key, imageID) != m.goldenStamp(v, p.key, imageID) - switch { - case stale: - p.nextBuild = time.Time{} - case err == nil: - p.goldenDir, p.imageID = final, imageID - p.goldenGen++ - default: - p.nextBuild = time.Now().Add(buildRetryDelay) - // A cold boot hits the same bridge and disk as a refill, so it feeds the park too - if reason := engine.CapacitySignature(err); reason != "" { - m.noteCapacityLocked(time.Now(), reason) - } - } - m.mu.Unlock() - if err != nil { - logger.Errorf(ctx, err, "build golden for %s", hash) - return - } - if stale { - logger.Infof(ctx, "golden for %s was built against a superseded config; rebuilding", hash) - m.kickRefill() - return - } - logger.Infof(ctx, "golden ready for %s (%s)", hash, p.key.Template) - m.kickRefill() -} - -func (m *Manager) buildGoldenSteps(ctx context.Context, v *configView, key types.PoolKey, name, snap, final, imageID string) error { - rec, err := m.eng.RunCold(ctx, name, key, v.poolStorage[key]) - if err != nil { - return err - } - sock, err := m.probeReady(ctx, name, rec.VsockSocket, coldProbeTimeout) - if err != nil { - return err - } - if err = m.out.PrepareGuest(ctx, v.out, key, sock); err != nil { - return err - } - if err = m.runWarmup(ctx, v.poolWarmups[key], sock); err != nil { - return fmt.Errorf("warmup: %w", err) - } - if err = m.eng.SettleGuest(ctx, sock); err != nil { - log.WithFunc("pool.buildGoldenSteps").Warnf(ctx, "capture golden of %s before its guest settled: %v", key.Template, err) - } - if err := m.dumpGolden(ctx, name, snap, final); err != nil { - return err - } - stamp := m.goldenStamp(v, key, imageID) - if err := os.WriteFile(final+goldenStampSuffix, []byte(stamp), 0o644); err != nil { //nolint:gosec // public stamp - return fmt.Errorf("write golden stamp: %w", err) - } - return nil -} - -func (m *Manager) runWarmup(ctx context.Context, warmup []string, sock string) error { - if len(warmup) == 0 { - return nil - } - return m.eng.Warmup(ctx, sock, warmup) -} - // A restore maps the golden memory lazily, so the warmup's pages fault in here instead of under the first claim func (m *Manager) warmClone(ctx context.Context, sb *types.Sandbox) error { if err := m.runWarmup(ctx, m.view.Load().poolWarmups[sb.Key], sb.VsockSocket); err != nil { @@ -277,311 +180,3 @@ func (m *Manager) warmClone(ctx context.Context, sb *types.Sandbox) error { } return nil } - -// adoptGolden points p at an on-disk golden whose stamp matches what the pool bakes now. -func (m *Manager) adoptGolden(p *pool, imageID string) bool { - g := filepath.Join(m.goldensDir(), p.hash) - stamp, err := os.ReadFile(g + goldenStampSuffix) //nolint:gosec // node-local golden path - want := m.goldenStamp(m.view.Load(), p.key, imageID) - if err != nil || string(stamp) != want || !dirExists(g) { - return false - } - p.goldenDir, p.imageID = g, imageID - p.goldenGen++ - return true -} - -// dropPoolLocked's rename hides the golden from adoptGolden, and the slow unlink runs off the lock. -func (m *Manager) dropPoolLocked(ctx context.Context, p *pool) { - delete(m.pools, p.key) - g := filepath.Join(m.goldensDir(), p.hash) - _ = os.Remove(g + goldenStampSuffix) - doomed := g + "-" + randHex(4) + ".tmp" - if os.Rename(g, doomed) != nil { - return - } - go func() { - if os.RemoveAll(doomed) == nil { - log.WithFunc("pool.dropPoolLocked").Infof(ctx, "removed the golden of dropped pool %s", p.key.Template) - } - }() -} - -func (m *Manager) retireUnclonableLocked(p *pool, gen uint64, now time.Time) bool { - if p.cloneFails < refillFailStreak || p.goldenGen != gen || p.goldenDir == "" || now.Sub(p.goldenRetiredAt) < buildRetryDelay { - return false - } - p.goldenDir, p.imageID = "", "" - p.goldenGen++ - p.nextBuild, p.goldenRetiredAt = time.Time{}, now - p.refillFails, p.nextRefill, p.cloneFails = 0, time.Time{}, 0 - _ = os.Remove(filepath.Join(m.goldensDir(), p.hash) + goldenStampSuffix) - return true -} - -func (m *Manager) rebuildRetiredGolden(ctx context.Context, hash string) { - log.WithFunc("pool.rebuildRetiredGolden").Warnf(ctx, "golden of %s failed %d clones running; rebuilding it", hash, refillFailStreak) - m.kickRefill() -} - -func (m *Manager) goldenStamp(v *configView, key types.PoolKey, imageID string) string { - var fp string - if v.out.Intercepts(key) { - fp = m.out.CAFingerprint() - } - fields := []string{fp, string(m.out.Lane(key)), imageID} - // Only a sized pool stamps its disk, in bytes so "40G" and "40GiB" match and unsized goldens stay adoptable - if storage := v.poolStorage[key]; storage != "" { - n, _ := config.StorageBytes(storage) - fields = append(fields, "storage="+strconv.FormatInt(n, 10)) - } - if v.noBalloon { - fields = append(fields, "no_balloon") - } - return strings.Join(append(fields, v.poolWarmups[key]...), "\x00") -} - -func (m *Manager) checkImagesOnce(ctx context.Context) { - m.mu.Lock() - empty := len(m.pools) == 0 - m.mu.Unlock() - if empty { - return - } - ids, err := m.eng.ImageIDs(ctx) - if err != nil { - log.WithFunc("pool.checkImagesOnce").Warnf(ctx, "list images: %v", err) - return - } - var trim []string - m.mu.Lock() - for _, p := range m.pools { - if p.goldenDir == "" || ids[p.key.Template] == p.imageID { - continue - } - p.goldenDir, p.imageID = "", "" - trim = append(trim, p.trimWarm(0)...) - } - m.mu.Unlock() - m.destroyAll(ctx, trim) -} - -func (m *Manager) imageIDs(ctx context.Context) map[string]string { - ids, err := m.eng.ImageIDs(ctx) - if err != nil { - log.WithFunc("pool.imageIDs").Warnf(ctx, "list images: %v", err) - } - return ids -} - -func (m *Manager) dumpGolden(ctx context.Context, name, snap, final string) error { - select { - case m.goldenDumps <- struct{}{}: - case <-ctx.Done(): - return ctx.Err() - } - defer func() { <-m.goldenDumps }() - if err := m.eng.SnapshotSave(ctx, name, snap); err != nil { - return err - } - return m.exportGolden(ctx, snap, final) -} - -// exportGolden exports snap into final through a unique sibling *.tmp dir. -func (m *Manager) exportGolden(ctx context.Context, snap, final string) error { - staging, err := os.MkdirTemp(filepath.Dir(final), filepath.Base(final)+"-*.tmp") - if err != nil { - return fmt.Errorf("stage golden: %w", err) - } - defer func() { _ = os.RemoveAll(staging) }() - tmp := filepath.Join(staging, "export") // cocoon wants the target absent - if err := m.eng.SnapshotExport(ctx, snap, tmp); err != nil { - return err - } - if err := os.RemoveAll(final); err != nil { - return fmt.Errorf("clear golden dir: %w", err) - } - return os.Rename(tmp, final) -} - -func (m *Manager) provision(ctx context.Context, key types.PoolKey, golden string) (*types.Sandbox, error) { - if golden == "" { - return m.provisionCold(ctx, key) - } - return m.provisionVM(ctx, key, claimProbeTimeout, func(name string) (types.VMRecord, error) { - return m.eng.Clone(ctx, golden, name, key) - }) -} - -func (m *Manager) provisionResolved(ctx context.Context, key types.PoolKey, g goldenResolution) (*types.Sandbox, error) { - if g.held == nil { - return m.provision(ctx, key, g.dir) - } - return m.provisionVM(ctx, key, claimProbeTimeout, func(name string) (types.VMRecord, error) { - rec, err := m.eng.Clone(ctx, g.dir, name, key) - m.noteClaimClone(ctx, g, err) - return rec, err - }) -} - -func (m *Manager) noteClaimClone(ctx context.Context, g goldenResolution, err error) { - capReason := engine.CapacitySignature(err) - m.mu.Lock() - g.held.countClone(ctx, g.gen, err == nil, capReason) - retired := m.retireUnclonableLocked(g.held, g.gen, time.Now()) - m.mu.Unlock() - if retired { - m.rebuildRetiredGolden(ctx, g.held.hash) - } -} - -func (m *Manager) provisionCold(ctx context.Context, key types.PoolKey) (*types.Sandbox, error) { - sb, err := m.provisionVM(ctx, key, coldProbeTimeout, func(name string) (types.VMRecord, error) { - return m.eng.RunCold(ctx, name, key, m.view.Load().poolStorage[key]) - }) - if err != nil { - return nil, err - } - // A pre-golden cold claim must trust the root, or intercepted hosts fail TLS for its life - if err := m.out.PrepareGuest(ctx, m.view.Load().out, key, sb.VsockSocket); err != nil { - m.destroy(ctx, sb.VMName) - return nil, err - } - return sb, nil -} - -func (m *Manager) provisionVM(ctx context.Context, key types.PoolKey, probeTimeout time.Duration, create vmProvisioner) (*types.Sandbox, error) { - sb, err := m.startVM(ctx, key, create) - if err != nil { - return nil, err - } - return m.readyVM(ctx, sb, time.Now().Add(probeTimeout)) -} - -func (m *Manager) startVM(ctx context.Context, key types.PoolKey, create vmProvisioner) (*types.Sandbox, error) { - name := vmName(key) - rec, err := create(name) - if err != nil { - m.destroy(ctx, name) - return nil, err - } - return &types.Sandbox{VMName: name, Key: key, VsockSocket: rec.VsockSocket, TAP: rec.TapDevice()}, nil -} - -func (m *Manager) readyVM(ctx context.Context, sb *types.Sandbox, deadline time.Time) (*types.Sandbox, error) { - sock, err := m.probeReady(ctx, sb.VMName, sb.VsockSocket, time.Until(deadline)) - if err != nil { - m.destroy(ctx, sb.VMName) - return nil, err - } - sb.VsockSocket = sock - return sb, nil -} - -func (m *Manager) readyBounded(ctx context.Context, sb *types.Sandbox, deadline time.Time) (*types.Sandbox, error) { - probeCtx, cancel := context.WithDeadline(ctx, deadline) - defer cancel() - select { - case m.probeSem <- struct{}{}: - defer func() { <-m.probeSem }() - case <-probeCtx.Done(): - m.destroy(ctx, sb.VMName) - return nil, probeCtx.Err() - } - return m.readyVM(probeCtx, sb, deadline) -} - -func (m *Manager) cloneBatch(ctx context.Context, count int, key types.PoolKey, create vmProvisioner) ([]*types.Sandbox, error) { - children := make([]*types.Sandbox, count) - errs := make([]error, count) - var wg sync.WaitGroup - for i := range count { - m.refillSem <- struct{}{} - wg.Go(func() { - child, err := m.startVM(ctx, key, create) - <-m.refillSem - if err == nil { - child, err = m.readyBounded(ctx, child, time.Now().Add(claimProbeTimeout)) - } - children[i], errs[i] = child, err - }) - } - wg.Wait() - if err := errors.Join(errs...); err != nil { - for _, child := range children { - if child != nil { - m.destroy(ctx, child.VMName) - } - } - return nil, err - } - return children, nil -} - -// probeReady waits until a VM's silkd answers, polling `vm list` when sock is empty. -func (m *Manager) probeReady(ctx context.Context, name, sock string, timeout time.Duration) (string, error) { - deadline := time.Now().Add(timeout) - if sock == "" { - var err error - sock, err = m.vsockOf(ctx, name) - for err != nil && time.Now().Before(deadline) && ctx.Err() == nil { - time.Sleep(vsockPollInterval) - sock, err = m.vsockOf(ctx, name) - } - if err != nil { - return "", err - } - } - if err := m.eng.Probe(ctx, sock, time.Until(deadline)); err != nil { - return "", err - } - return sock, nil -} - -func (m *Manager) vsockOf(ctx context.Context, name string) (string, error) { - vm, ok, err := m.eng.Inspect(ctx, name) - if err != nil { - return "", err - } - if !ok { - return "", fmt.Errorf("vm %s not found after create", name) - } - if vm.VsockSocket == "" { - return "", fmt.Errorf("vm %s has no vsock socket", name) - } - return vm.VsockSocket, nil -} - -func (m *Manager) destroyAll(ctx context.Context, names []string) *sync.WaitGroup { - return m.runBounded(ctx, len(names), func(ctx context.Context, i int) { m.destroy(ctx, names[i]) }) -} - -// runBounded fans f over n items on the refill semaphore, sharing the node-wide budget but never the share maintSem leaves to refill. -func (m *Manager) runBounded(ctx context.Context, n int, f func(context.Context, int)) *sync.WaitGroup { - var wg sync.WaitGroup - for i := range n { - // Acquire inside the goroutine so a batch larger than the budget cannot block the caller - wg.Go(func() { - for _, sem := range [...]chan struct{}{m.maintSem, m.refillSem} { - select { - case sem <- struct{}{}: - case <-ctx.Done(): - return - } - defer func() { <-sem }() - } - f(ctx, i) - }) - } - return &wg -} - -func (m *Manager) dropSnap(ctx context.Context, snap string) { - if snap == "" { - return - } - ctx = context.WithoutCancel(ctx) - if err := m.eng.SnapshotRemove(ctx, snap); err != nil { - log.WithFunc("pool.dropSnap").Warnf(ctx, "drop snapshot %s: %v", snap, err) - } -} diff --git a/sandboxd/pool/remove.go b/sandboxd/pool/remove.go index ebea44ca1..06bb7af8c 100644 --- a/sandboxd/pool/remove.go +++ b/sandboxd/pool/remove.go @@ -116,3 +116,17 @@ func (m *Manager) finishRemoval(ctx context.Context, pending pendingRemoval) { func (m *Manager) destroy(ctx context.Context, name string) { m.removeOrRetry(ctx, name, "", volumeTeardown{}) } + +func (m *Manager) destroyAll(ctx context.Context, names []string) *sync.WaitGroup { + return m.runBounded(ctx, len(names), func(ctx context.Context, i int) { m.destroy(ctx, names[i]) }) +} + +func (m *Manager) dropSnap(ctx context.Context, snap string) { + if snap == "" { + return + } + ctx = context.WithoutCancel(ctx) + if err := m.eng.SnapshotRemove(ctx, snap); err != nil { + log.WithFunc("pool.dropSnap").Warnf(ctx, "drop snapshot %s: %v", snap, err) + } +} From e9b5c02c76d3395f4610dbaf328d4243dd3c28b4 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:54:44 +0800 Subject: [PATCH 56/70] review: the reaper leaves claim.go for reap.go, since expiry handling is not claim admission --- sandboxd/pool/claim.go | 181 -------------------------------------- sandboxd/pool/reap.go | 192 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 181 deletions(-) create mode 100644 sandboxd/pool/reap.go diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index a8432169a..9e03ac346 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -1,12 +1,10 @@ package pool import ( - "cmp" "context" "crypto/subtle" "fmt" "net" - "slices" "sync" "time" @@ -16,15 +14,6 @@ import ( "github.com/cocoonstack/sandbox/sandboxd/usage" ) -const ( - reapDestroy reapAction = iota - reapArchive - reapPurge - reapPause -) - -type reapAction int - // ClaimOptions is what a claim asks for beyond its key; the zero value takes every default. type ClaimOptions struct { TTL time.Duration @@ -574,176 +563,6 @@ func (m *Manager) abortVolumeClaim(ctx context.Context, vmName string, reserved *reserved = nil } -func (m *Manager) reapOnce(ctx context.Context) { - now := time.Now() - type victim struct { - action reapAction - id, vmName, snap, ck, tenant string - deadline time.Time - sb *types.Sandbox - holds []types.Volume - } - m.mu.Lock() - var expired []victim - var dropped, purges []string - var unrows []*types.Sandbox - for id, sb := range m.claimed { - // A zero deadline means no expiry (an archived claim kept forever). - if sb.Deadline.IsZero() || !now.After(sb.Deadline) { - continue - } - switch { - case m.expiryDropsRow(sb): - if _, busy := m.archiving[id]; !busy { - m.archiving[id] = struct{}{} - unrows = append(unrows, sb) - } - case sb.ArchiveCk != "": - expired = append(expired, victim{action: reapPurge, id: id, ck: sb.ArchiveCk, tenant: sb.Tenant, deadline: sb.Deadline, sb: sb}) - purges = append(purges, sb.ArchiveCk) - case m.archivesAtDeadline(sb): - // Archive instead of destroy, kept in m.claimed for archive() to move - if _, busy := m.archiving[id]; busy { - continue - } - m.archiving[id] = struct{}{} - action := reapArchive - if sb.HibernateSnap == "" { - action = reapPause - } - expired = append(expired, victim{action: action, id: id, sb: sb}) - default: - expired = append(expired, victim{action: reapDestroy, id: id, vmName: sb.VMName, snap: sb.HibernateSnap, tenant: sb.Tenant, deadline: sb.Deadline, sb: sb, holds: m.cancelVolumeRun(sb)}) - m.dropClaimLocked(sb) - dropped = append(dropped, id) - } - } - if len(dropped) > 0 { - m.store.del(dropped...) - } - m.mu.Unlock() - if len(unrows) > 0 { - m.runBounded(ctx, len(unrows), func(ctx context.Context, i int) { m.unrowExpired(ctx, unrows[i]) }) - } - if len(expired) == 0 { - return - } - - logger := log.WithFunc("pool.reapOnce") - failed := m.markArchiveCks(purges) - expired = slices.DeleteFunc(expired, func(v victim) bool { - markErr, bad := failed[v.ck] - if bad { - logger.Errorf(ctx, markErr, "mark archive ck %s; keeping %s", v.ck, v.id) - } - return bad - }) - if len(expired) == 0 { - return - } - m.mu.Lock() - keep := expired[:0] - var purgeCks, purged []string - for _, v := range expired { - if v.action == reapPurge { - if m.claimed[v.id] != v.sb || v.sb.ArchiveCk != v.ck { - continue - } - m.pendingCks[v.ck] = struct{}{} - m.dropClaimLocked(v.sb) - purgeCks = append(purgeCks, v.ck) - purged = append(purged, v.id) - } - keep = append(keep, v) - } - expired = keep - if len(expired) == 0 { - m.mu.Unlock() - return - } - js := m.store.del(purged...) - m.mu.Unlock() - if saveErr := m.store.commit(js); saveErr != nil { - m.mu.Lock() - var restored []*types.Sandbox - for _, v := range expired { - if v.action == reapArchive || v.action == reapPause { - delete(m.archiving, v.id) // never removed from m.claimed - } else { - m.addClaimLocked(v.sb) - m.restoreVolumeHolds(v.sb, v.holds) - delete(m.pendingCks, v.ck) - restored = append(restored, v.sb) - } - } - rb := m.store.set(restored...) - m.mu.Unlock() - for _, ck := range purgeCks { - if clearErr := m.clearArchiveCk(ck); clearErr != nil { - logger.Warnf(ctx, "clear archive ck %s: %v", ck, clearErr) - } - } - m.store.recommit(ctx, rb) - logger.Error(ctx, saveErr, "persist reap; rolled back") - return - } - // Engine teardown can take minutes, so fan out bounded and never stall the ticker loop - m.runBounded(ctx, len(expired), func(ctx context.Context, i int) { - v := expired[i] - switch v.action { - case reapPurge: - m.out.Disarm(v.id, true) - m.purgeArchiveCk(ctx, v.id, v.ck, v.tenant) - m.untrack(m.pendingCks, v.ck) - m.counters.reaps.Add(1) - m.recordUsage(ctx, usage.Event{Event: reapEvent, ID: v.id, Tenant: v.tenant, Deadline: v.deadline}) - logger.Infof(ctx, "purged archived sandbox %s", v.id) - case reapArchive: - logSweepResult(ctx, logger, m.archive(ctx, v.sb), "archived expired sandbox "+v.id, "archive expired sandbox "+v.id) - case reapPause: - logSweepResult(ctx, logger, m.pauseArchive(ctx, v.sb), "archived expired sandbox "+v.id, "archive expired sandbox "+v.id) - default: - td := m.quiesceVolumes(ctx, v.sb) - td.holds = v.holds - m.out.Disarm(v.id, m.removeOrRetry(ctx, v.vmName, v.id, td)) - m.dropSnap(ctx, v.snap) - m.counters.reaps.Add(1) - m.recordUsage(ctx, usage.Event{Event: reapEvent, ID: v.id, VMName: v.vmName, Tenant: v.tenant, Deadline: v.deadline}) - logger.Infof(ctx, "reaped expired sandbox %s (%s)", v.id, v.vmName) - } - }) -} - -func (m *Manager) archivesAtDeadline(sb *types.Sandbox) bool { - return sb.Failed == "" && !m.tenantGone(sb.Tenant) && (sb.OnExpire == types.ExpireArchive || sb.HibernateSnap != "" && m.archiveAfterFor(sb.Key) > 0) -} - -func (m *Manager) pauseArchive(ctx context.Context, sb *types.Sandbox) error { - sb.Transition.Lock() - m.mu.Lock() - live := m.claimed[sb.ID] == sb && sb.ArchiveCk == "" - m.mu.Unlock() - var err error - if live { - err = m.hibernateLocked(ctx, sb) - } - sb.Transition.Unlock() - if !live || err != nil { - m.untrack(m.archiving, sb.ID) - return cmp.Or(err, errWokeMeanwhile) - } - return m.archive(ctx, sb) -} - -func (m *Manager) purgeArchiveCk(ctx context.Context, id, ck, tenant string) { - logger := log.WithFunc("pool.purgeArchiveCk") - if _, err := m.finishArchiveCkDelete(ctx, ck); err != nil { - logger.Warnf(ctx, "delete archive ck %s: %v", ck, err) - } - m.counters.archiveDeletes.Add(1) - m.recordUsage(ctx, usage.Event{Event: "archive_delete", ID: id, Reference: ck, Tenant: tenant}) -} - func (m *Manager) setIfLive(sb *types.Sandbox, mutate func()) (claimSnapshot, bool) { m.mu.Lock() defer m.mu.Unlock() diff --git a/sandboxd/pool/reap.go b/sandboxd/pool/reap.go new file mode 100644 index 000000000..cd81ebd25 --- /dev/null +++ b/sandboxd/pool/reap.go @@ -0,0 +1,192 @@ +package pool + +import ( + "cmp" + "context" + "slices" + "time" + + "github.com/projecteru2/core/log" + + "github.com/cocoonstack/sandbox/sandboxd/types" + "github.com/cocoonstack/sandbox/sandboxd/usage" +) + +const ( + reapDestroy reapAction = iota + reapArchive + reapPurge + reapPause +) + +type reapAction int + +func (m *Manager) reapOnce(ctx context.Context) { + now := time.Now() + type victim struct { + action reapAction + id, vmName, snap, ck, tenant string + deadline time.Time + sb *types.Sandbox + holds []types.Volume + } + m.mu.Lock() + var expired []victim + var dropped, purges []string + var unrows []*types.Sandbox + for id, sb := range m.claimed { + // A zero deadline means no expiry (an archived claim kept forever). + if sb.Deadline.IsZero() || !now.After(sb.Deadline) { + continue + } + switch { + case m.expiryDropsRow(sb): + if _, busy := m.archiving[id]; !busy { + m.archiving[id] = struct{}{} + unrows = append(unrows, sb) + } + case sb.ArchiveCk != "": + expired = append(expired, victim{action: reapPurge, id: id, ck: sb.ArchiveCk, tenant: sb.Tenant, deadline: sb.Deadline, sb: sb}) + purges = append(purges, sb.ArchiveCk) + case m.archivesAtDeadline(sb): + // Archive instead of destroy, kept in m.claimed for archive() to move + if _, busy := m.archiving[id]; busy { + continue + } + m.archiving[id] = struct{}{} + action := reapArchive + if sb.HibernateSnap == "" { + action = reapPause + } + expired = append(expired, victim{action: action, id: id, sb: sb}) + default: + expired = append(expired, victim{action: reapDestroy, id: id, vmName: sb.VMName, snap: sb.HibernateSnap, tenant: sb.Tenant, deadline: sb.Deadline, sb: sb, holds: m.cancelVolumeRun(sb)}) + m.dropClaimLocked(sb) + dropped = append(dropped, id) + } + } + if len(dropped) > 0 { + m.store.del(dropped...) + } + m.mu.Unlock() + if len(unrows) > 0 { + m.runBounded(ctx, len(unrows), func(ctx context.Context, i int) { m.unrowExpired(ctx, unrows[i]) }) + } + if len(expired) == 0 { + return + } + + logger := log.WithFunc("pool.reapOnce") + failed := m.markArchiveCks(purges) + expired = slices.DeleteFunc(expired, func(v victim) bool { + markErr, bad := failed[v.ck] + if bad { + logger.Errorf(ctx, markErr, "mark archive ck %s; keeping %s", v.ck, v.id) + } + return bad + }) + if len(expired) == 0 { + return + } + m.mu.Lock() + keep := expired[:0] + var purgeCks, purged []string + for _, v := range expired { + if v.action == reapPurge { + if m.claimed[v.id] != v.sb || v.sb.ArchiveCk != v.ck { + continue + } + m.pendingCks[v.ck] = struct{}{} + m.dropClaimLocked(v.sb) + purgeCks = append(purgeCks, v.ck) + purged = append(purged, v.id) + } + keep = append(keep, v) + } + expired = keep + if len(expired) == 0 { + m.mu.Unlock() + return + } + js := m.store.del(purged...) + m.mu.Unlock() + if saveErr := m.store.commit(js); saveErr != nil { + m.mu.Lock() + var restored []*types.Sandbox + for _, v := range expired { + if v.action == reapArchive || v.action == reapPause { + delete(m.archiving, v.id) // never removed from m.claimed + } else { + m.addClaimLocked(v.sb) + m.restoreVolumeHolds(v.sb, v.holds) + delete(m.pendingCks, v.ck) + restored = append(restored, v.sb) + } + } + rb := m.store.set(restored...) + m.mu.Unlock() + for _, ck := range purgeCks { + if clearErr := m.clearArchiveCk(ck); clearErr != nil { + logger.Warnf(ctx, "clear archive ck %s: %v", ck, clearErr) + } + } + m.store.recommit(ctx, rb) + logger.Error(ctx, saveErr, "persist reap; rolled back") + return + } + // Engine teardown can take minutes, so fan out bounded and never stall the ticker loop + m.runBounded(ctx, len(expired), func(ctx context.Context, i int) { + v := expired[i] + switch v.action { + case reapPurge: + m.out.Disarm(v.id, true) + m.purgeArchiveCk(ctx, v.id, v.ck, v.tenant) + m.untrack(m.pendingCks, v.ck) + m.counters.reaps.Add(1) + m.recordUsage(ctx, usage.Event{Event: reapEvent, ID: v.id, Tenant: v.tenant, Deadline: v.deadline}) + logger.Infof(ctx, "purged archived sandbox %s", v.id) + case reapArchive: + logSweepResult(ctx, logger, m.archive(ctx, v.sb), "archived expired sandbox "+v.id, "archive expired sandbox "+v.id) + case reapPause: + logSweepResult(ctx, logger, m.pauseArchive(ctx, v.sb), "archived expired sandbox "+v.id, "archive expired sandbox "+v.id) + default: + td := m.quiesceVolumes(ctx, v.sb) + td.holds = v.holds + m.out.Disarm(v.id, m.removeOrRetry(ctx, v.vmName, v.id, td)) + m.dropSnap(ctx, v.snap) + m.counters.reaps.Add(1) + m.recordUsage(ctx, usage.Event{Event: reapEvent, ID: v.id, VMName: v.vmName, Tenant: v.tenant, Deadline: v.deadline}) + logger.Infof(ctx, "reaped expired sandbox %s (%s)", v.id, v.vmName) + } + }) +} + +func (m *Manager) archivesAtDeadline(sb *types.Sandbox) bool { + return sb.Failed == "" && !m.tenantGone(sb.Tenant) && (sb.OnExpire == types.ExpireArchive || sb.HibernateSnap != "" && m.archiveAfterFor(sb.Key) > 0) +} + +func (m *Manager) pauseArchive(ctx context.Context, sb *types.Sandbox) error { + sb.Transition.Lock() + m.mu.Lock() + live := m.claimed[sb.ID] == sb && sb.ArchiveCk == "" + m.mu.Unlock() + var err error + if live { + err = m.hibernateLocked(ctx, sb) + } + sb.Transition.Unlock() + if !live || err != nil { + m.untrack(m.archiving, sb.ID) + return cmp.Or(err, errWokeMeanwhile) + } + return m.archive(ctx, sb) +} + +func (m *Manager) purgeArchiveCk(ctx context.Context, id, ck, tenant string) { + logger := log.WithFunc("pool.purgeArchiveCk") + if _, err := m.finishArchiveCkDelete(ctx, ck); err != nil { + logger.Warnf(ctx, "delete archive ck %s: %v", ck, err) + } + m.counters.archiveDeletes.Add(1) + m.recordUsage(ctx, usage.Event{Event: "archive_delete", ID: id, Reference: ck, Tenant: tenant}) +} From 8f5e3f1a8aa22d50dfd3c3c359a239d2dadda135 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:54:45 +0800 Subject: [PATCH 57/70] review: claim and authed resolve a credential, so they sit in operator.go with resolve and byID --- sandboxd/pool/claim.go | 16 ---------------- sandboxd/pool/operator.go | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 9e03ac346..18f42403b 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -2,7 +2,6 @@ package pool import ( "context" - "crypto/subtle" "fmt" "net" "sync" @@ -585,27 +584,12 @@ func (m *Manager) commitIfLive(ctx context.Context, sb *types.Sandbox, mutate fu return true, nil } -func (m *Manager) claim(id, token string) (*types.Sandbox, bool) { - m.mu.Lock() - defer m.mu.Unlock() - return m.authed(id, token) -} - func (m *Manager) deadlineOf(sb *types.Sandbox) time.Time { m.mu.Lock() defer m.mu.Unlock() return sb.Deadline } -// Callers hold m.mu. -func (m *Manager) authed(id, token string) (*types.Sandbox, bool) { - sb := m.claimed[id] - if sb == nil || subtle.ConstantTimeCompare([]byte(sb.Token), []byte(token)) != 1 { - return nil, false - } - return sb, true -} - // admitOptions validates the key and options and admits the claim's volumes, which the caller unreserves until finalize. func (m *Manager) admitOptions(ctx context.Context, key types.PoolKey, o ClaimOptions) ([]resolvedVolume, []types.Volume, error) { if err := m.validate(key); err != nil { diff --git a/sandboxd/pool/operator.go b/sandboxd/pool/operator.go index 83fab9187..f28c34e38 100644 --- a/sandboxd/pool/operator.go +++ b/sandboxd/pool/operator.go @@ -2,6 +2,7 @@ package pool import ( "context" + "crypto/subtle" "github.com/cocoonstack/sandbox/sandboxd/types" ) @@ -81,6 +82,21 @@ func (m *Manager) byID(id string) (*types.Sandbox, bool) { return sb, sb != nil } +func (m *Manager) claim(id, token string) (*types.Sandbox, bool) { + m.mu.Lock() + defer m.mu.Unlock() + return m.authed(id, token) +} + +// Callers hold m.mu. +func (m *Manager) authed(id, token string) (*types.Sandbox, bool) { + sb := m.claimed[id] + if sb == nil || subtle.ConstantTimeCompare([]byte(sb.Token), []byte(token)) != 1 { + return nil, false + } + return sb, true +} + func (m *Manager) wake(ctx context.Context, sb *types.Sandbox) error { sb.Touch() _, err := m.wakeResolved(ctx, sb) From 9d52157a84c09ab22a6c8e31923ab5aeac853c27 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:54:55 +0800 Subject: [PATCH 58/70] review: the pool type joins its watermark methods in watermark.go, so its type block is atomic --- sandboxd/pool/pool.go | 105 ----------------------------------- sandboxd/pool/watermark.go | 110 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 105 deletions(-) diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index f0ffcf669..e18bb8c9f 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -247,111 +247,6 @@ type pendingRemoval struct { notBefore time.Time } -type pool struct { - key types.PoolKey - hash string - - // floor and warmMax bound the demand-adaptive target computed in watermark.go - floor int - warmMax int - idle time.Duration - archiveAfter time.Duration - archiveDelete time.Duration - rate float64 - lead time.Duration - lastArrival time.Time - binStart time.Time - binCount int - overTargetSince time.Time - - goldenDir string - imageID string - // goldenGen counts installs, so a clone of a retired golden never lands after a rebuild at the same path. - goldenGen uint64 - building bool - nextBuild time.Time - removed bool // dropped from the desired set while unsettled; swept by refillOnce - warm []*types.Sandbox - refilling int - claimClones atomic.Int32 - - // Without a streak gate a node-wide dead cause is retried every tick at full concurrency - refillFails int - nextRefill time.Time - cloneFails int - goldenRetiredAt time.Time -} - -func newPool(key types.PoolKey) *pool { - return &pool{key: key, hash: key.Hash()} -} - -func (p *pool) settled() bool { - return !p.building && p.refilling == 0 && p.claimClones.Load() == 0 -} - -// refillGated reports whether a refill may not spawn: backoff wait, or one probe in flight. -func (p *pool) refillGated(now time.Time) bool { - return now.Before(p.nextRefill) || (p.refillFails >= refillFailStreak && p.refilling > 0) -} - -// noteRefillResult records one refill outcome and reports whether it started a backoff. -func (p *pool) noteRefillResult(now time.Time, failed bool) bool { - if !failed { - p.refillFails, p.nextRefill = 0, time.Time{} - return false - } - p.refillFails++ - if p.refillFails < refillFailStreak { - return false - } - first := !now.Before(p.nextRefill) - backoff := refillBackoffBase << min(p.refillFails-refillFailStreak, 16) - p.nextRefill = now.Add(min(backoff, refillBackoffMax)) - return first -} - -func (p *pool) countClone(ctx context.Context, gen uint64, cloned bool, capReason string) bool { - if ctx.Err() != nil || capReason != "" || gen != p.goldenGen { - return false - } - if cloned { - p.cloneFails = 0 - } else { - p.cloneFails++ - } - return true -} - -func (p *pool) applySpec(spec config.PoolSpec) { - p.floor = spec.Warm - p.warmMax = spec.WarmMax - p.idle = time.Duration(spec.IdleHibernateSeconds) * time.Second - p.archiveAfter = time.Duration(spec.ArchiveAfterSeconds) * time.Second - p.archiveDelete = time.Duration(spec.ArchiveDeleteAfterSeconds) * time.Second -} - -func (p *pool) popWarm() *types.Sandbox { - n := len(p.warm) - if n == 0 { - return nil - } - sb := p.warm[n-1] - p.warm = p.warm[:n-1] - return sb -} - -// trimWarm shrinks p.warm to at most target and returns the trimmed VM names; callers hold m.mu. -func (p *pool) trimWarm(target int) []string { - var trim []string - for len(p.warm) > target { - n := len(p.warm) - 1 - trim = append(trim, p.warm[n].VMName) - p.warm = p.warm[:n] - } - return trim -} - // Manager owns the node's pools, claims, and their persistence. type Manager struct { eng Engine diff --git a/sandboxd/pool/watermark.go b/sandboxd/pool/watermark.go index 50411a196..3aeaf0ae9 100644 --- a/sandboxd/pool/watermark.go +++ b/sandboxd/pool/watermark.go @@ -1,8 +1,13 @@ package pool import ( + "context" "math" + "sync/atomic" "time" + + "github.com/cocoonstack/sandbox/sandboxd/config" + "github.com/cocoonstack/sandbox/sandboxd/types" ) const ( @@ -17,6 +22,111 @@ const ( warmShrinkDwell = rateDecayTau ) +type pool struct { + key types.PoolKey + hash string + + // floor and warmMax bound the demand-adaptive target + floor int + warmMax int + idle time.Duration + archiveAfter time.Duration + archiveDelete time.Duration + rate float64 + lead time.Duration + lastArrival time.Time + binStart time.Time + binCount int + overTargetSince time.Time + + goldenDir string + imageID string + // goldenGen counts installs, so a clone of a retired golden never lands after a rebuild at the same path. + goldenGen uint64 + building bool + nextBuild time.Time + removed bool // dropped from the desired set while unsettled; swept by refillOnce + warm []*types.Sandbox + refilling int + claimClones atomic.Int32 + + // Without a streak gate a node-wide dead cause is retried every tick at full concurrency + refillFails int + nextRefill time.Time + cloneFails int + goldenRetiredAt time.Time +} + +func newPool(key types.PoolKey) *pool { + return &pool{key: key, hash: key.Hash()} +} + +func (p *pool) settled() bool { + return !p.building && p.refilling == 0 && p.claimClones.Load() == 0 +} + +// refillGated reports whether a refill may not spawn: backoff wait, or one probe in flight. +func (p *pool) refillGated(now time.Time) bool { + return now.Before(p.nextRefill) || (p.refillFails >= refillFailStreak && p.refilling > 0) +} + +// noteRefillResult records one refill outcome and reports whether it started a backoff. +func (p *pool) noteRefillResult(now time.Time, failed bool) bool { + if !failed { + p.refillFails, p.nextRefill = 0, time.Time{} + return false + } + p.refillFails++ + if p.refillFails < refillFailStreak { + return false + } + first := !now.Before(p.nextRefill) + backoff := refillBackoffBase << min(p.refillFails-refillFailStreak, 16) + p.nextRefill = now.Add(min(backoff, refillBackoffMax)) + return first +} + +func (p *pool) countClone(ctx context.Context, gen uint64, cloned bool, capReason string) bool { + if ctx.Err() != nil || capReason != "" || gen != p.goldenGen { + return false + } + if cloned { + p.cloneFails = 0 + } else { + p.cloneFails++ + } + return true +} + +func (p *pool) applySpec(spec config.PoolSpec) { + p.floor = spec.Warm + p.warmMax = spec.WarmMax + p.idle = time.Duration(spec.IdleHibernateSeconds) * time.Second + p.archiveAfter = time.Duration(spec.ArchiveAfterSeconds) * time.Second + p.archiveDelete = time.Duration(spec.ArchiveDeleteAfterSeconds) * time.Second +} + +func (p *pool) popWarm() *types.Sandbox { + n := len(p.warm) + if n == 0 { + return nil + } + sb := p.warm[n-1] + p.warm = p.warm[:n-1] + return sb +} + +// trimWarm shrinks p.warm to at most target and returns the trimmed VM names; callers hold m.mu. +func (p *pool) trimWarm(target int) []string { + var trim []string + for len(p.warm) > target { + n := len(p.warm) - 1 + trim = append(trim, p.warm[n].VMName) + p.warm = p.warm[:n] + } + return trim +} + // noteArrival counts one claim; a bin that has spanned rateBin folds its arrivals per second into the rate EWMA. Caller holds the manager mutex. func (p *pool) noteArrival(now time.Time) { switch elapsed := now.Sub(p.binStart); { From e70b8191fbe436a6fada7ce1d95e402d93d7c322 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:54:57 +0800 Subject: [PATCH 59/70] review: rewriteArchiveRow writes an archive row, so it sits in failover.go beside refusedReplace --- sandboxd/pool/env.go | 33 --------------------------------- sandboxd/pool/failover.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/sandboxd/pool/env.go b/sandboxd/pool/env.go index 7e2c7a565..7c9a656bb 100644 --- a/sandboxd/pool/env.go +++ b/sandboxd/pool/env.go @@ -1,16 +1,13 @@ package pool import ( - "cmp" "context" - "errors" "fmt" "slices" "strings" "golang.org/x/sync/errgroup" - "github.com/cocoonstack/sandbox/sandboxd/archives" "github.com/cocoonstack/sandbox/sandboxd/types" ) @@ -115,36 +112,6 @@ func (m *Manager) writeEnv(ctx context.Context, id, tenant, verb string, next fu return nil } -func (m *Manager) rewriteArchiveRow(ctx context.Context, sb *types.Sandbox, locked bool) error { - if !locked { - sb.Transition.Lock() - defer sb.Transition.Unlock() - } - for range 2 { - m.mu.Lock() - ck, replica, live, rowed := sb.ArchiveCk, sb.ReplicaCk, m.claimed[sb.ID] == sb, m.rowFollows(sb) - m.mu.Unlock() - switch { - case !live: - return ErrUnknownSandbox - case !rowed: - return nil - } - epoch, err := m.putRow(ctx, sb, cmp.Or(ck, replica), ck == "", true) - if err == nil { - _, _ = m.commitIfLive(ctx, sb, func() { sb.ArchiveEpoch = epoch }) - return nil - } - if !errors.Is(err, archives.ErrLost) { - return err - } - if err = m.refusedReplace(ctx, sb); err != nil { - return err - } - } - return nil -} - // deliverEnv writes each claim's guest env file before it is handed out; an inherited guest holds its source's file, so it is rewritten even when empty. func (m *Manager) deliverEnv(ctx context.Context, sbs []*types.Sandbox, inherited bool) error { if !inherited && !slices.ContainsFunc(sbs, func(sb *types.Sandbox) bool { return sb.Env.HasGuest() }) { diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index af02fa7c4..3e65bf619 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -1,6 +1,7 @@ package pool import ( + "cmp" "context" "crypto/subtle" "encoding/json/v2" @@ -142,6 +143,36 @@ func (m *Manager) putRow(ctx context.Context, sb *types.Sandbox, ck string, repl return m.archives.Put(ctx, rec) } +func (m *Manager) rewriteArchiveRow(ctx context.Context, sb *types.Sandbox, locked bool) error { + if !locked { + sb.Transition.Lock() + defer sb.Transition.Unlock() + } + for range 2 { + m.mu.Lock() + ck, replica, live, rowed := sb.ArchiveCk, sb.ReplicaCk, m.claimed[sb.ID] == sb, m.rowFollows(sb) + m.mu.Unlock() + switch { + case !live: + return ErrUnknownSandbox + case !rowed: + return nil + } + epoch, err := m.putRow(ctx, sb, cmp.Or(ck, replica), ck == "", true) + if err == nil { + _, _ = m.commitIfLive(ctx, sb, func() { sb.ArchiveEpoch = epoch }) + return nil + } + if !errors.Is(err, archives.ErrLost) { + return err + } + if err = m.refusedReplace(ctx, sb); err != nil { + return err + } + } + return nil +} + // refusedReplace settles a refused replace: a row this node still owns only had a stale epoch, anything else means the claim moved. func (m *Manager) refusedReplace(ctx context.Context, sb *types.Sandbox) error { rec, ok, err := m.archives.Get(ctx, sb.ID) From ca9aae8fd72d20881d47388a72dd8fb647067c0a Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:55:14 +0800 Subject: [PATCH 60/70] review: NewManager delegates template and volume seeding to loadTemplates in template.go and seedVolumes in volume_catalog.go, keeping the order --- sandboxd/pool/pool.go | 23 ++--------------------- sandboxd/pool/template.go | 16 ++++++++++++++++ sandboxd/pool/volume_catalog.go | 10 ++++++++++ 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index e18bb8c9f..5b47211f8 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -6,7 +6,6 @@ import ( "context" "crypto/rand" "encoding/hex" - "encoding/json/v2" "errors" "fmt" "maps" @@ -390,32 +389,14 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg refillKick: make(chan struct{}, 1), healSem: make(chan struct{}, maxConcurrentHeals), } - for _, volume := range cfg.Volumes { - m.catalog.entries[volume.Name] = &catalogVolume{ - disk: engine.VolumeSpec{Name: volume.Name, Path: volume.Path, DirectIO: volume.DirectIO}, - tenants: slices.Clone(volume.Tenants), - writable: volume.Writable, - } - } + m.seedVolumes(cfg.Volumes) if err := m.loadRuntimeVolumes(); err != nil { return nil, err } if err := os.MkdirAll(m.goldensDir(), 0o750); err != nil { return nil, fmt.Errorf("create goldens dir: %w", err) } - m.tplSet = map[string]TemplateInfo{} - metas, listErr := m.tpls.Metas(ctx) - if listErr != nil { - log.WithFunc("pool.NewManager").Warnf(ctx, "list templates skipped: %v", listErr) - } - for _, r := range metas { - var rec templateRecord - if json.Unmarshal(r.Meta, &rec) == nil && rec.ID != "" { - info := TemplateInfo{Key: rec.Key, ContentDigest: r.Digest, Tenant: rec.Tenant, CreatedAt: rec.CreatedAt} - _ = json.Unmarshal(r.Labels, &info.Labels) - m.tplSet[rec.ID] = info - } - } + m.loadTemplates(ctx) usageJournal, err := newJournal(filepath.Join(cfg.DataDir, usage.JournalFile)) if err != nil { return nil, fmt.Errorf("open usage journal: %w", err) diff --git a/sandboxd/pool/template.go b/sandboxd/pool/template.go index b468da9e4..86970fbde 100644 --- a/sandboxd/pool/template.go +++ b/sandboxd/pool/template.go @@ -217,6 +217,22 @@ func (m *Manager) HasPromotedTemplate(ctx context.Context, key types.PoolKey, te return owner == "" || tenantOwns(tenant, owner) } +func (m *Manager) loadTemplates(ctx context.Context) { + m.tplSet = map[string]TemplateInfo{} + metas, listErr := m.tpls.Metas(ctx) + if listErr != nil { + log.WithFunc("pool.loadTemplates").Warnf(ctx, "list templates skipped: %v", listErr) + } + for _, r := range metas { + var rec templateRecord + if json.Unmarshal(r.Meta, &rec) == nil && rec.ID != "" { + info := TemplateInfo{Key: rec.Key, ContentDigest: r.Digest, Tenant: rec.Tenant, CreatedAt: rec.CreatedAt} + _ = json.Unmarshal(r.Labels, &info.Labels) + m.tplSet[rec.ID] = info + } + } +} + // ownedTemplate is ErrUnknownTemplate for a record that is absent or that tenant does not own; root owns every record. func (m *Manager) ownedTemplate(ctx context.Context, id, tenant string) error { raw, err := m.tpls.ReadMeta(ctx, id) diff --git a/sandboxd/pool/volume_catalog.go b/sandboxd/pool/volume_catalog.go index 5ffd9e43d..ee96f7943 100644 --- a/sandboxd/pool/volume_catalog.go +++ b/sandboxd/pool/volume_catalog.go @@ -119,6 +119,16 @@ func (m *Manager) UnregisterVolume(ctx context.Context, name string) error { return nil } +func (m *Manager) seedVolumes(volumes []config.VolumeSpec) { + for _, volume := range volumes { + m.catalog.entries[volume.Name] = &catalogVolume{ + disk: engine.VolumeSpec{Name: volume.Name, Path: volume.Path, DirectIO: volume.DirectIO}, + tenants: slices.Clone(volume.Tenants), + writable: volume.Writable, + } + } +} + func (m *Manager) loadRuntimeVolumes() error { raw, err := os.ReadFile(filepath.Join(m.dataDir, runtimeVolumesFile)) if errors.Is(err, os.ErrNotExist) { From 314db7e056a7070b09353187e674427fe0aaec08 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:56:14 +0800 Subject: [PATCH 61/70] review: the catalog owns its entries and holders, so catalogVolume, catalogSnapshot and the holder bookkeeping move to volume_catalog.go as volumeCatalog methods --- sandboxd/pool/claim.go | 4 +- sandboxd/pool/reconcile.go | 4 +- sandboxd/pool/volume.go | 61 +-------------------- sandboxd/pool/volume_bench_test.go | 6 +-- sandboxd/pool/volume_catalog.go | 67 ++++++++++++++++++++++-- sandboxd/pool/volume_convergence_test.go | 2 +- sandboxd/pool/volume_mutation.go | 12 ++--- sandboxd/pool/volume_mutation_test.go | 2 +- sandboxd/pool/volume_rw_test.go | 2 +- 9 files changed, 80 insertions(+), 80 deletions(-) diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 18f42403b..49fbf7402 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -373,10 +373,10 @@ func (m *Manager) admitClaim(tenant string, volumes []resolvedVolume, applied [] if err := m.quotaErr(1, tenant); err != nil { return err } - if err := m.checkResolvedLocked(volumes); err != nil { + if err := m.catalog.checkResolvedLocked(volumes); err != nil { return err } - m.adoptVolumes(applied) + m.catalog.adoptVolumes(applied) return nil } diff --git a/sandboxd/pool/reconcile.go b/sandboxd/pool/reconcile.go index ec69b2a34..be5b1c8f7 100644 --- a/sandboxd/pool/reconcile.go +++ b/sandboxd/pool/reconcile.go @@ -71,7 +71,7 @@ func (m *Manager) Reconcile(ctx context.Context) error { continue } m.addClaimLocked(sb) - m.adoptVolumes(heldVolumes(sb)) + m.catalog.adoptVolumes(heldVolumes(sb)) owned[sb.VMName] = true referenced[sb.HibernateSnap] = true } @@ -204,7 +204,7 @@ func (m *Manager) holdStaleVolumes(ctx context.Context, name string, rec types.V } } m.mu.Lock() - m.adoptVolumes(holds) + m.catalog.adoptVolumes(holds) m.mu.Unlock() return holds } diff --git a/sandboxd/pool/volume.go b/sandboxd/pool/volume.go index 400b48e42..e0073b4f3 100644 --- a/sandboxd/pool/volume.go +++ b/sandboxd/pool/volume.go @@ -38,17 +38,6 @@ type volumeTeardown struct { clears []string } -type catalogVolume struct { - runtime bool - disk engine.VolumeSpec - tenants []string - writable bool -} - -func (v *catalogVolume) allowed(tenant string) bool { - return tenant == "" || len(v.tenants) == 0 || slices.Contains(v.tenants, tenant) -} - // Volumes reports the caller-visible fleet catalog; an empty tenant means root. func (m *Manager) Volumes(tenant string, holders map[string]int) []types.VolumeInfo { catalog := m.catalogSnapshot() @@ -277,61 +266,13 @@ func (m *Manager) finishVolumeTeardown(ctx context.Context, td volumeTeardown) { m.unreserveVolumes(td.holds) } -func (m *Manager) adoptVolumes(volumes []types.Volume) { - for _, volume := range volumes { - holders := m.catalog.holders[volume.Name] - if volume.RW() { - holders.writers++ - } else { - holders.readers++ - } - m.catalog.holders[volume.Name] = holders - } -} - -func (m *Manager) releaseVolumes(volumes []types.Volume) { - for _, volume := range volumes { - holders := m.catalog.holders[volume.Name] - if volume.RW() { - holders.writers-- - } else { - holders.readers-- - } - if holders.writers < 1 && holders.readers < 1 { - delete(m.catalog.holders, volume.Name) - continue - } - m.catalog.holders[volume.Name] = holders - } -} - func (m *Manager) unreserveVolumes(volumes []types.Volume) { if len(volumes) == 0 { return } m.mu.Lock() defer m.mu.Unlock() - m.releaseVolumes(volumes) -} - -func (m *Manager) catalogSnapshot() map[string]*catalogVolume { - m.mu.Lock() - defer m.mu.Unlock() - return m.catalog.entries -} - -func (m *Manager) checkResolvedLocked(volumes []resolvedVolume) error { - for _, resolved := range volumes { - volume := resolved.applied - if m.catalog.entries[volume.Name] != resolved.entry { - return ErrVolumeUnavailable - } - holders := m.catalog.holders[volume.Name] - if holders.writers > 0 || (volume.RW() && holders.readers > 0) { - return fmt.Errorf("%w: volume %q", ErrVolumeBusy, volume.Name) - } - } - return nil + m.catalog.releaseVolumes(volumes) } func needsUnmount(volume types.Volume) bool { diff --git a/sandboxd/pool/volume_bench_test.go b/sandboxd/pool/volume_bench_test.go index 2b4e39123..a25f7098e 100644 --- a/sandboxd/pool/volume_bench_test.go +++ b/sandboxd/pool/volume_bench_test.go @@ -21,10 +21,10 @@ func BenchmarkVolumeAdmission(b *testing.B) { } applied := appliedVolumes(resolved) m.mu.Lock() - err = m.checkResolvedLocked(resolved) + err = m.catalog.checkResolvedLocked(resolved) if err == nil { - m.adoptVolumes(applied) - m.releaseVolumes(applied) + m.catalog.adoptVolumes(applied) + m.catalog.releaseVolumes(applied) } m.mu.Unlock() if err != nil { diff --git a/sandboxd/pool/volume_catalog.go b/sandboxd/pool/volume_catalog.go index ee96f7943..a01d59b90 100644 --- a/sandboxd/pool/volume_catalog.go +++ b/sandboxd/pool/volume_catalog.go @@ -58,6 +58,63 @@ func (c *volumeCatalog) normalize(name string, spec types.VolumeRegistration) (* return volume, nil } +func (c *volumeCatalog) adoptVolumes(volumes []types.Volume) { + for _, volume := range volumes { + holders := c.holders[volume.Name] + if volume.RW() { + holders.writers++ + } else { + holders.readers++ + } + c.holders[volume.Name] = holders + } +} + +func (c *volumeCatalog) releaseVolumes(volumes []types.Volume) { + for _, volume := range volumes { + holders := c.holders[volume.Name] + if volume.RW() { + holders.writers-- + } else { + holders.readers-- + } + if holders.writers < 1 && holders.readers < 1 { + delete(c.holders, volume.Name) + continue + } + c.holders[volume.Name] = holders + } +} + +func (c *volumeCatalog) volumeReferenced(name string) bool { + return c.holders[name] != (volumeHolders{}) +} + +func (c *volumeCatalog) checkResolvedLocked(volumes []resolvedVolume) error { + for _, resolved := range volumes { + volume := resolved.applied + if c.entries[volume.Name] != resolved.entry { + return ErrVolumeUnavailable + } + holders := c.holders[volume.Name] + if holders.writers > 0 || (volume.RW() && holders.readers > 0) { + return fmt.Errorf("%w: volume %q", ErrVolumeBusy, volume.Name) + } + } + return nil +} + +type catalogVolume struct { + runtime bool + disk engine.VolumeSpec + tenants []string + writable bool +} + +func (v *catalogVolume) allowed(tenant string) bool { + return tenant == "" || len(v.tenants) == 0 || slices.Contains(v.tenants, tenant) +} + // RegisterVolume durably binds a root-managed name to a backing path. func (m *Manager) RegisterVolume(ctx context.Context, name string, spec types.VolumeRegistration) error { volume, err := m.catalog.normalize(name, spec) @@ -75,7 +132,7 @@ func (m *Manager) RegisterVolume(ctx context.Context, name string, spec types.Vo } return fmt.Errorf("%w: volume %q already has different semantics or is static", ErrVolumeRegistrationConflict, name) } - if m.volumeReferenced(name) { + if m.catalog.volumeReferenced(name) { m.mu.Unlock() return fmt.Errorf("%w: volume %q is referenced", ErrVolumeRegistrationConflict, name) } @@ -104,7 +161,7 @@ func (m *Manager) UnregisterVolume(ctx context.Context, name string) error { defer m.catalog.writeMu.Unlock() m.mu.Lock() entry := m.catalog.entries[name] - if (entry != nil && !entry.runtime) || m.volumeReferenced(name) { + if (entry != nil && !entry.runtime) || m.catalog.volumeReferenced(name) { m.mu.Unlock() return fmt.Errorf("%w: volume %q is static or referenced", ErrVolumeRegistrationConflict, name) } @@ -157,8 +214,10 @@ func (m *Manager) loadRuntimeVolumes() error { return nil } -func (m *Manager) volumeReferenced(name string) bool { - return m.catalog.holders[name] != (volumeHolders{}) +func (m *Manager) catalogSnapshot() map[string]*catalogVolume { + m.mu.Lock() + defer m.mu.Unlock() + return m.catalog.entries } func (m *Manager) persistRuntimeCatalog(entries map[string]*catalogVolume) error { diff --git a/sandboxd/pool/volume_convergence_test.go b/sandboxd/pool/volume_convergence_test.go index 2bde19c34..56f6ef749 100644 --- a/sandboxd/pool/volume_convergence_test.go +++ b/sandboxd/pool/volume_convergence_test.go @@ -329,7 +329,7 @@ func TestVolumeHoldsRejectCaptureBeforeTrim(t *testing.T) { t.Fatalf("beginVolumeRun: %v", err) } m.mu.Lock() - m.adoptVolumes(held) + m.catalog.adoptVolumes(held) run.holds = held m.mu.Unlock() defer m.finishVolumeRun(sb, run) diff --git a/sandboxd/pool/volume_mutation.go b/sandboxd/pool/volume_mutation.go index bd7f4e14a..934930af1 100644 --- a/sandboxd/pool/volume_mutation.go +++ b/sandboxd/pool/volume_mutation.go @@ -198,7 +198,7 @@ func (m *Manager) beginVolumeRun(ctx context.Context, sb *types.Sandbox) (contex func (m *Manager) finishVolumeRun(sb *types.Sandbox, run *volumeRun) types.SandboxVolumesResponse { m.mu.Lock() if m.claimed[sb.ID] == sb { - m.releaseVolumes(volumeDifference(run.holds, heldVolumes(sb))) + m.catalog.releaseVolumes(volumeDifference(run.holds, heldVolumes(sb))) } delete(m.volumeRuns, sb) view := m.volumeView(sb) @@ -210,7 +210,7 @@ func (m *Manager) finishVolumeRun(sb *types.Sandbox, run *volumeRun) types.Sandb func (m *Manager) restoreVolumeHolds(sb *types.Sandbox, holds []types.Volume) { if m.volumeRuns[sb] == nil { - m.releaseVolumes(volumeDifference(holds, heldVolumes(sb))) + m.catalog.releaseVolumes(volumeDifference(holds, heldVolumes(sb))) } } @@ -267,11 +267,11 @@ func (m *Manager) stageVolumeTarget(ctx context.Context, sb *types.Sandbox, run m.mu.Unlock() return claimSnapshot{}, false, ErrVolumeCapture } - if err := m.checkResolvedLocked(resolved); err != nil { + if err := m.catalog.checkResolvedLocked(resolved); err != nil { m.mu.Unlock() return claimSnapshot{}, false, err } - m.adoptVolumes(additions) + m.catalog.adoptVolumes(additions) run.holds = append(run.holds, additions...) m.mu.Unlock() cleanErr := m.confirmVolumesClean(resolved) @@ -281,7 +281,7 @@ func (m *Manager) stageVolumeTarget(ctx context.Context, sb *types.Sandbox, run return claimSnapshot{}, false, err } if cleanErr != nil { - m.releaseVolumes(additions) + m.catalog.releaseVolumes(additions) run.holds = volumeDifference(run.holds, additions) return claimSnapshot{}, false, cleanErr } @@ -350,7 +350,7 @@ func (m *Manager) commitVolumeRun(ctx context.Context, sb *types.Sandbox, run *v return err } dropped := volumeDifference(run.holds, heldVolumes(sb)) - m.releaseVolumes(dropped) + m.catalog.releaseVolumes(dropped) run.holds = volumeDifference(run.holds, dropped) return nil } diff --git a/sandboxd/pool/volume_mutation_test.go b/sandboxd/pool/volume_mutation_test.go index 694974b75..0830ce33b 100644 --- a/sandboxd/pool/volume_mutation_test.go +++ b/sandboxd/pool/volume_mutation_test.go @@ -1024,7 +1024,7 @@ func beginAdmittedVolumeRun(t *testing.T, m *Manager, sb *types.Sandbox) *volume m.mu.Lock() defer m.mu.Unlock() transient := []types.Volume{{Name: "data", Mount: "/volumes/data"}} - m.adoptVolumes(transient) + m.catalog.adoptVolumes(transient) run.holds = append(run.holds, transient...) return run } diff --git a/sandboxd/pool/volume_rw_test.go b/sandboxd/pool/volume_rw_test.go index dbc7b6d8d..01f92bbf6 100644 --- a/sandboxd/pool/volume_rw_test.go +++ b/sandboxd/pool/volume_rw_test.go @@ -386,7 +386,7 @@ func TestRollbackQuiescesWritableVolumesBeforeRemoval(t *testing.T) { eng.volumeMounts = sb.Volumes m.mu.Lock() m.claimed[sb.ID] = sb - m.adoptVolumes(sb.Volumes) + m.catalog.adoptVolumes(sb.Volumes) m.mu.Unlock() m.rollbackClaim(t.Context(), []*types.Sandbox{sb}) From 53d1f98177353c1aa3f740febd39b6b80ad75ff1 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:56:28 +0800 Subject: [PATCH 62/70] review: the seven single-flight sweep guards group into one sweeps field on Manager --- sandboxd/pool/archive.go | 10 +++++----- sandboxd/pool/archive_test.go | 2 +- sandboxd/pool/checkpoint.go | 4 ++-- sandboxd/pool/failover.go | 4 ++-- sandboxd/pool/failover_test.go | 2 +- sandboxd/pool/hibernate.go | 8 ++++---- sandboxd/pool/liveness.go | 4 ++-- sandboxd/pool/liveness_test.go | 2 +- sandboxd/pool/pool.go | 24 +++++++++--------------- sandboxd/pool/pool_test.go | 2 +- 10 files changed, 28 insertions(+), 34 deletions(-) diff --git a/sandboxd/pool/archive.go b/sandboxd/pool/archive.go index b9255cde8..026a90bbd 100644 --- a/sandboxd/pool/archive.go +++ b/sandboxd/pool/archive.go @@ -51,7 +51,7 @@ func (m *Manager) archiveDeadline(sb *types.Sandbox) time.Time { } func (m *Manager) archiveOnce(ctx context.Context) { - if !m.archiveSweep.CompareAndSwap(false, true) { + if !m.sweeps.archive.CompareAndSwap(false, true) { return // the previous sweep's archives are still draining } now := time.Now() @@ -73,11 +73,11 @@ func (m *Manager) archiveOnce(ctx context.Context) { } m.mu.Unlock() if len(victims) == 0 { - m.archiveSweep.Store(false) + m.sweeps.archive.Store(false) return } go func() { - defer m.archiveSweep.Store(false) + defer m.sweeps.archive.Store(false) logger := log.WithFunc("pool.archiveOnce") m.runBounded(ctx, len(victims), func(ctx context.Context, i int) { sb := victims[i] @@ -381,10 +381,10 @@ func (m *Manager) finishArchiveCkDelete(ctx context.Context, ckID string) (bool, } func (m *Manager) retryArchiveDeletes(ctx context.Context) { - if !m.archiveDeleteSweep.CompareAndSwap(false, true) { + if !m.sweeps.archiveDelete.CompareAndSwap(false, true) { return } - defer m.archiveDeleteSweep.Store(false) + defer m.sweeps.archiveDelete.Store(false) ids, err := m.archiveDeleteMarkers() if err != nil { log.WithFunc("pool.retryArchiveDeletes").Warnf(ctx, "list: %v", err) diff --git a/sandboxd/pool/archive_test.go b/sandboxd/pool/archive_test.go index 1f0dff0e9..594d894f3 100644 --- a/sandboxd/pool/archive_test.go +++ b/sandboxd/pool/archive_test.go @@ -853,7 +853,7 @@ func TestArchiveOnceSkipsInFlight(t *testing.T) { exportsBefore := eng.exportCount() m.archiveOnce(t.Context()) - waitFor(t, func() bool { return !m.archiveSweep.Load() }) + waitFor(t, func() bool { return !m.sweeps.archive.Load() }) if got := eng.exportCount(); got != exportsBefore { t.Errorf("archiveOnce double-exported an in-flight sandbox: %d→%d", exportsBefore, got) } diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index d8f22b9c8..92ae629e9 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -330,10 +330,10 @@ func (m *Manager) pinnedArchiveCks() map[string]struct{} { func (m *Manager) sweepExpiredCheckpoints(ctx context.Context) { ttl := m.view.Load().ckptTTL - if ttl <= 0 || !m.ckptSweeping.CompareAndSwap(false, true) { + if ttl <= 0 || !m.sweeps.ckpt.CompareAndSwap(false, true) { return } - defer m.ckptSweeping.Store(false) + defer m.sweeps.ckpt.Store(false) logger := log.WithFunc("pool.sweepExpiredCheckpoints") // Checkpoints hides archive images, so the TTL never reaches one; reapPurge retires them ckpts, err := m.Checkpoints(ctx, "") diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index 3e65bf619..957db6cb7 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -443,10 +443,10 @@ func (m *Manager) forgetClaim(ctx context.Context, sb *types.Sandbox) ([]types.V } func (m *Manager) sweepOrphanArchives(ctx context.Context) { - if !m.orphanSweep.CompareAndSwap(false, true) { + if !m.sweeps.orphan.CompareAndSwap(false, true) { return } - defer m.orphanSweep.Store(false) + defer m.sweeps.orphan.Store(false) m.settleIntents(ctx) now := time.Now() recs, err := m.archives.Sweepable(ctx, now, m.nodeID) diff --git a/sandboxd/pool/failover_test.go b/sandboxd/pool/failover_test.go index 1e168a151..1d99b6999 100644 --- a/sandboxd/pool/failover_test.go +++ b/sandboxd/pool/failover_test.go @@ -1388,7 +1388,7 @@ func TestFailoverArchiveSweepExportsNothingWhileTheRegistryIsDown(t *testing.T) for range 3 { backdate(a, sb, time.Minute) a.archiveOnce(t.Context()) - waitFor(t, func() bool { return !a.archiveSweep.Load() }) + waitFor(t, func() bool { return !a.sweeps.archive.Load() }) } if metas, err := a.ckpts.Metas(t.Context()); err != nil || len(metas) != 0 { t.Errorf("three archive ticks with the registry down left %d exports %v, want none", len(metas), err) diff --git a/sandboxd/pool/hibernate.go b/sandboxd/pool/hibernate.go index 9bda609f4..7e767a3ff 100644 --- a/sandboxd/pool/hibernate.go +++ b/sandboxd/pool/hibernate.go @@ -238,7 +238,7 @@ func (m *Manager) stopVM(ctx context.Context, name string) { } func (m *Manager) idleOnce(ctx context.Context) { - if !m.idleSweep.CompareAndSwap(false, true) { + if !m.sweeps.idle.CompareAndSwap(false, true) { return // the previous sweep's hibernates are still draining } now := time.Now() @@ -251,7 +251,7 @@ func (m *Manager) idleOnce(ctx context.Context) { } if !idlePolicy { m.mu.Unlock() - m.idleSweep.Store(false) + m.sweeps.idle.Store(false) return } for _, sb := range m.claimed { @@ -269,12 +269,12 @@ func (m *Manager) idleOnce(ctx context.Context) { } m.mu.Unlock() if len(victims) == 0 { - m.idleSweep.Store(false) + m.sweeps.idle.Store(false) return } // Hibernates are seconds-long engine snapshots, so fan out off the housekeeping loop go func() { - defer m.idleSweep.Store(false) + defer m.sweeps.idle.Store(false) m.runBounded(ctx, len(victims), func(ctx context.Context, i int) { sb := victims[i] logSweepResult(ctx, log.WithFunc("pool.idleOnce"), m.idleHibernate(ctx, sb, now), "idle-hibernated "+sb.ID, "idle-hibernate "+sb.ID) diff --git a/sandboxd/pool/liveness.go b/sandboxd/pool/liveness.go index 4c103da72..083a81d36 100644 --- a/sandboxd/pool/liveness.go +++ b/sandboxd/pool/liveness.go @@ -36,12 +36,12 @@ func (m *Manager) livenessOnce(ctx context.Context) { if m.vmmEvents.Load() && time.Since(time.Unix(0, m.lastVMPoll.Load())) < livenessBackstop { return } - if !m.livenessSweep.CompareAndSwap(false, true) { + if !m.sweeps.liveness.CompareAndSwap(false, true) { return } watched := m.watchedClaims("") go func() { - defer m.livenessSweep.Store(false) + defer m.sweeps.liveness.Store(false) vms, err := m.eng.List(ctx) if err != nil { log.WithFunc("pool.livenessOnce").Warnf(ctx, "list vms: %v", err) diff --git a/sandboxd/pool/liveness_test.go b/sandboxd/pool/liveness_test.go index 0b6d8ceaf..4f197b2f1 100644 --- a/sandboxd/pool/liveness_test.go +++ b/sandboxd/pool/liveness_test.go @@ -629,7 +629,7 @@ func assertRestartFailsUntilBudget(t *testing.T, m *Manager, eng *fakeEngine, sb func runLiveness(t *testing.T, m *Manager) { t.Helper() m.livenessOnce(t.Context()) - waitFor(t, func() bool { return !m.livenessSweep.Load() }) + waitFor(t, func() bool { return !m.sweeps.liveness.Load() }) } func killVMM(eng *fakeEngine, name string) { diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 5b47211f8..5daa9fe23 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -262,23 +262,19 @@ type Manager struct { poolVersion int64 configSeedHash string // config pools' hash, to warn when a file edit is overridden - idleSweep atomic.Bool + sweeps struct{ idle, liveness, archive, archiveDelete, orphan, ckpt, gen atomic.Bool } - livenessSweep atomic.Bool cocoondSocket string // vmmEvents is set while the cocoon daemon stream is live; the poll is then only a backstop. vmmEvents atomic.Bool lastVMPoll atomic.Int64 - archiveSweep atomic.Bool - archiveDeleteSweep atomic.Bool // archiving holds ids with an export or a registry drop in flight; pendingCks pins ids mid-commit; both under m.mu. archiving map[string]struct{} pendingCks map[string]struct{} // archives is the cluster archive registry, nil unless archive_failover is set. archives archives.Registry nodeID string - orphanSweep atomic.Bool adoptFlights singleflight.Group // fence gates replicated claims while the lease is overdue; replicas belongs to the pass holding replicating. fence atomic.Pointer[fenceState] @@ -308,14 +304,12 @@ type Manager struct { healSem chan struct{} healFlights singleflight.Group // healPending/healAbort, guarded by healMu, let a delete veto a heal still staging. - healMu sync.Mutex - healPending map[string]struct{} - healAbort map[string]struct{} - peerDelete PeerDeleteFunc - peerAdopt PeerAdoptFunc - tpls store.Store - ckptSweeping atomic.Bool - genSweeping atomic.Bool + healMu sync.Mutex + healPending map[string]struct{} + healAbort map[string]struct{} + peerDelete PeerDeleteFunc + peerAdopt PeerAdoptFunc + tpls store.Store // tplSet caches each template id's key, digest and owning tenant ("" = operator). tplMu sync.Mutex @@ -604,10 +598,10 @@ func (m *Manager) WithPeerAdopt(fn PeerAdoptFunc) { } func (m *Manager) sweepStoreGenerations(ctx context.Context) { - if !m.genSweeping.CompareAndSwap(false, true) { + if !m.sweeps.gen.CompareAndSwap(false, true) { return } - defer m.genSweeping.Store(false) + defer m.sweeps.gen.Store(false) logger := log.WithFunc("pool.sweepStoreGenerations") if err := m.ckpts.SweepGenerations(ctx); err != nil { logger.Error(ctx, err, "sweep checkpoint generations") diff --git a/sandboxd/pool/pool_test.go b/sandboxd/pool/pool_test.go index e3986cb2e..cc707f59d 100644 --- a/sandboxd/pool/pool_test.go +++ b/sandboxd/pool/pool_test.go @@ -2333,7 +2333,7 @@ func hibernated(m *Manager) int { func sweepIdle(t *testing.T, m *Manager) { t.Helper() m.idleOnce(t.Context()) - waitFor(t, func() bool { return !m.idleSweep.Load() }) + waitFor(t, func() bool { return !m.sweeps.idle.Load() }) } func expire(m *Manager, sb *types.Sandbox) { From 1028de83933ab5da58e1268966f7249938065863 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:56:42 +0800 Subject: [PATCH 63/70] review: recordLocks owns the per-record lock maps and their mutex, and recLock's doc says it returns the lock without taking it --- sandboxd/pool/archive.go | 6 ++-- sandboxd/pool/checkpoint.go | 10 +++--- sandboxd/pool/checkpoint_heal_test.go | 12 +++---- sandboxd/pool/pool.go | 8 ++--- sandboxd/pool/pool_test.go | 12 +++---- sandboxd/pool/reclock.go | 47 +++++++++++++++------------ sandboxd/pool/template.go | 8 ++--- 7 files changed, 53 insertions(+), 50 deletions(-) diff --git a/sandboxd/pool/archive.go b/sandboxd/pool/archive.go index 026a90bbd..23af212c8 100644 --- a/sandboxd/pool/archive.go +++ b/sandboxd/pool/archive.go @@ -211,7 +211,7 @@ func (m *Manager) wakeArchived(ctx context.Context, sb *types.Sandbox) (string, return "", err } ck := sb.ArchiveCk - defer m.lockRec(ck)() + defer m.recs.lockRec(ck)() dir, _, _, err := m.ckpts.Fetch(ctx, ck) if errors.Is(err, store.ErrNotFound) { return "", ErrUnknownSandbox // record disagrees with the store @@ -364,7 +364,7 @@ func (m *Manager) deleteArchiveCk(ctx context.Context, ckID string) (bool, error } func (m *Manager) finishArchiveCkDelete(ctx context.Context, ckID string) (bool, error) { - unlock := m.lockRec(ckID) + unlock := m.recs.lockRec(ckID) if m.store.referencesCheckpoint(ckID) { unlock() return false, nil @@ -401,7 +401,7 @@ func (m *Manager) retryArchiveDeletes(ctx context.Context) { } func (m *Manager) retryArchiveDelete(ctx context.Context, ckID string) { - unlock := m.lockRec(ckID) + unlock := m.recs.lockRec(ckID) if m.archiveCkPinned(ckID) || !m.deleteMarked(ckID) { unlock() return diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 92ae629e9..5d5c3c72f 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -117,7 +117,7 @@ func (m *Manager) DeleteCheckpoint(ctx context.Context, ckptID, tenant string, s if !store.CheckpointIDRe.MatchString(ckptID) { return ErrUnknownCheckpoint } - defer m.lockRec(ckptID)() + defer m.recs.lockRec(ckptID)() ckpt, err := m.loadCheckpoint(ctx, ckptID) if err != nil { m.vetoIfHealPending(ckptID) @@ -152,7 +152,7 @@ func (m *Manager) FetchCheckpoint(ctx context.Context, ckptID string) (string, [ if _, err := m.loadCheckpoint(ctx, ckptID); err != nil { return "", nil, nil, err } - unlock := m.rlockRec(ckptID) + unlock := m.recs.rlockRec(ckptID) dir, meta, _, err := m.ckpts.Fetch(ctx, ckptID) if err != nil { unlock() @@ -208,7 +208,7 @@ func (m *Manager) claimLoaded(ctx context.Context, ckpt types.Checkpoint, o Clai if err := m.checkEnv(o.Env); err != nil { return nil, err } - defer m.rlockRec(ckpt.ID)() + defer m.recs.rlockRec(ckpt.ID)() if ckpt.Archive { return nil, ErrUnknownCheckpoint // a wake image, not a branchable checkpoint } @@ -279,7 +279,7 @@ func (m *Manager) runHeal(ctx context.Context, ckptID string) (types.Checkpoint, return types.Checkpoint{}, fmt.Errorf("heal checkpoint: %w", err) } - defer m.lockRec(ckptID)() + defer m.recs.lockRec(ckptID)() if aborted := m.clearHealPending(ckptID); aborted { return types.Checkpoint{}, ErrUnknownCheckpoint // a concurrent delete vetoed this heal } @@ -346,7 +346,7 @@ func (m *Manager) sweepExpiredCheckpoints(ctx context.Context) { if ckpt.CreatedAt.After(cutoff) { continue } - unlock := m.lockRec(ckpt.ID) + unlock := m.recs.lockRec(ckpt.ID) err := m.ckpts.Delete(ctx, ckpt.ID) unlock() if err != nil { diff --git a/sandboxd/pool/checkpoint_heal_test.go b/sandboxd/pool/checkpoint_heal_test.go index 45030a5be..c7ece2b60 100644 --- a/sandboxd/pool/checkpoint_heal_test.go +++ b/sandboxd/pool/checkpoint_heal_test.go @@ -186,25 +186,25 @@ func TestRecLockEvictionWaitsForAllHolders(t *testing.T) { m := newTestManager(t, newFakeEngine()) const id = "ck_00000000000000aa" - l1 := m.recLock(id) + l1 := m.recs.recLock(id) l1.Lock() - l2 := m.recLock(id) + l2 := m.recs.recLock(id) if l2 != l1 { t.Fatalf("recLock returned a different mutex while the first holder is still outstanding") } l1.Unlock() - m.recDone(id) + m.recs.recDone(id) if !hasRecLock(m, id) { t.Fatal("entry evicted while a second holder still references it") } - l3 := m.recLock(id) + l3 := m.recs.recLock(id) if l3 != l1 { t.Fatal("a concurrent recLock diverged onto a different mutex for the same id — the lock-identity split") } - m.recDone(id) - m.recDone(id) + m.recs.recDone(id) + m.recs.recDone(id) if hasRecLock(m, id) { t.Error("entry not evicted once every holder released") } diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 5daa9fe23..3ed8cb7f0 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -315,10 +315,7 @@ type Manager struct { tplMu sync.Mutex tplSet map[string]TemplateInfo - // recLocks serializes same-id record mutations; an entry lives exactly as long as a recLock reference to it. - recLocks map[string]*sync.RWMutex - recLocksMu sync.Mutex - recRefs map[string]int + recs recordLocks // notifyTemplates is set before serving starts and fires after a promote or template delete. notifyTemplates func() @@ -372,8 +369,7 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg readers: &readerSet{live: map[string]*reader{}}, archiving: map[string]struct{}{}, pendingCks: map[string]struct{}{}, - recLocks: map[string]*sync.RWMutex{}, - recRefs: map[string]int{}, + recs: recordLocks{held: map[string]*sync.RWMutex{}, refs: map[string]int{}}, healPending: map[string]struct{}{}, healAbort: map[string]struct{}{}, refillSem: make(chan struct{}, refill), diff --git a/sandboxd/pool/pool_test.go b/sandboxd/pool/pool_test.go index cc707f59d..fd4311282 100644 --- a/sandboxd/pool/pool_test.go +++ b/sandboxd/pool/pool_test.go @@ -2224,15 +2224,15 @@ func testSecrets(t testing.TB, specs ...egress.SecretSpec) *egress.SecretStore { } func lockCount(m *Manager) int { - m.recLocksMu.Lock() - defer m.recLocksMu.Unlock() - return len(m.recLocks) + m.recs.mu.Lock() + defer m.recs.mu.Unlock() + return len(m.recs.held) } func hasRecLock(m *Manager, id string) bool { - m.recLocksMu.Lock() - defer m.recLocksMu.Unlock() - _, ok := m.recLocks[id] + m.recs.mu.Lock() + defer m.recs.mu.Unlock() + _, ok := m.recs.held[id] return ok } diff --git a/sandboxd/pool/reclock.go b/sandboxd/pool/reclock.go index 22985c078..15cb59023 100644 --- a/sandboxd/pool/reclock.go +++ b/sandboxd/pool/reclock.go @@ -2,38 +2,45 @@ package pool import "sync" -// recLock takes the per-record lock and a live reference; pair with recDone. -func (m *Manager) recLock(id string) *sync.RWMutex { - m.recLocksMu.Lock() - defer m.recLocksMu.Unlock() - m.recRefs[id]++ - l := m.recLocks[id] +// recordLocks serializes same-id record mutations; an entry lives exactly as long as a recLock reference to it. +type recordLocks struct { + mu sync.Mutex + held map[string]*sync.RWMutex + refs map[string]int +} + +// recLock returns the per-record lock and takes a live reference; pair with recDone. +func (r *recordLocks) recLock(id string) *sync.RWMutex { + r.mu.Lock() + defer r.mu.Unlock() + r.refs[id]++ + l := r.held[id] if l == nil { l = &sync.RWMutex{} - m.recLocks[id] = l + r.held[id] = l } return l } -func (m *Manager) lockRec(id string) func() { - l := m.recLock(id) +func (r *recordLocks) lockRec(id string) func() { + l := r.recLock(id) l.Lock() - return func() { l.Unlock(); m.recDone(id) } + return func() { l.Unlock(); r.recDone(id) } } -func (m *Manager) rlockRec(id string) func() { - l := m.recLock(id) +func (r *recordLocks) rlockRec(id string) func() { + l := r.recLock(id) l.RLock() - return func() { l.RUnlock(); m.recDone(id) } + return func() { l.RUnlock(); r.recDone(id) } } // recDone releases a reference taken by recLock; call after Unlock/RUnlock, never before. -func (m *Manager) recDone(id string) { - m.recLocksMu.Lock() - defer m.recLocksMu.Unlock() - m.recRefs[id]-- - if m.recRefs[id] <= 0 { - delete(m.recRefs, id) - delete(m.recLocks, id) +func (r *recordLocks) recDone(id string) { + r.mu.Lock() + defer r.mu.Unlock() + r.refs[id]-- + if r.refs[id] <= 0 { + delete(r.refs, id) + delete(r.held, id) } } diff --git a/sandboxd/pool/template.go b/sandboxd/pool/template.go index 86970fbde..7a0fa0e90 100644 --- a/sandboxd/pool/template.go +++ b/sandboxd/pool/template.go @@ -99,7 +99,7 @@ func (m *Manager) DeleteTemplate(ctx context.Context, key types.PoolKey, tenant, return ErrPooledTemplate } id := store.TemplateID(key.Hash()) - defer m.lockRec(id)() + defer m.recs.lockRec(id)() if err := m.ownedTemplate(ctx, id, tenant); err != nil { return err } @@ -142,7 +142,7 @@ func (m *Manager) SetTemplateLabels(ctx context.Context, key types.PoolKey, labe return ErrPooledTemplate } id := store.TemplateID(key.Hash()) - defer m.lockRec(id)() + defer m.recs.lockRec(id)() err := m.ownedTemplate(ctx, id, tenant) if err != nil { return err @@ -322,7 +322,7 @@ func (m *Manager) commitTemplate(ctx context.Context, staging string, rec templa if err != nil { return "", fmt.Errorf("publish template: %w", err) } - defer m.lockRec(id)() + defer m.recs.lockRec(id)() if ownerErr := m.checkTemplateOwner(ctx, id, rec.Tenant); ownerErr != nil { return "", ownerErr } @@ -376,7 +376,7 @@ func (m *Manager) resolveGolden(ctx context.Context, key types.PoolKey, tenant s return goldenResolution{}, nil // never resume a live-captured template on the egress lane; cold-boot instead } id := store.TemplateID(key.Hash()) - cleanup := m.rlockRec(id) + cleanup := m.recs.rlockRec(id) dir, meta, digest, err := m.tpls.Fetch(ctx, id) if err != nil { cleanup() From 09f48f336bfc728f07e19d39a901b9ffc4dbb72d Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:56:59 +0800 Subject: [PATCH 64/70] review: healVetoes owns the heal veto maps and their mutex, replacing the three Manager methods and fields --- sandboxd/pool/checkpoint.go | 73 ++++++++++++++++++++----------------- sandboxd/pool/pool.go | 8 +--- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/sandboxd/pool/checkpoint.go b/sandboxd/pool/checkpoint.go index 5d5c3c72f..d781978a5 100644 --- a/sandboxd/pool/checkpoint.go +++ b/sandboxd/pool/checkpoint.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "slices" + "sync" "time" "github.com/projecteru2/core/log" @@ -120,7 +121,7 @@ func (m *Manager) DeleteCheckpoint(ctx context.Context, ckptID, tenant string, s defer m.recs.lockRec(ckptID)() ckpt, err := m.loadCheckpoint(ctx, ckptID) if err != nil { - m.vetoIfHealPending(ckptID) + m.heals.veto(ckptID) return err } if !tenantOwns(tenant, ckpt.Tenant) { @@ -133,7 +134,7 @@ func (m *Manager) DeleteCheckpoint(ctx context.Context, ckptID, tenant string, s if err := m.ckpts.Delete(ctx, ckptID); err != nil { return fmt.Errorf("delete checkpoint: %w", err) } - m.vetoIfHealPending(ckptID) + m.heals.veto(ckptID) // A shared backend has no per-node replicas to chase. if scope == DeleteFleet && m.peerDelete != nil && !m.ckptsShared { m.peerDelete(context.WithoutCancel(ctx), ckptID) @@ -263,16 +264,16 @@ func (m *Manager) runHeal(ctx context.Context, ckptID string) (types.Checkpoint, default: return types.Checkpoint{}, ErrHealBusy } - m.markHealPending(ckptID) + m.heals.mark(ckptID) staging, err := m.ckpts.Stage(ckptID) if err != nil { - m.clearHealPending(ckptID) + m.heals.clear(ckptID) return types.Checkpoint{}, fmt.Errorf("stage healed checkpoint: %w", err) } defer func() { _ = os.RemoveAll(staging) }() validate := func(dir string) error { return validateHealedCheckpoint(dir, ckptID) } if err := m.healer.Pull(ctx, ckptID, staging, validate); err != nil { - m.clearHealPending(ckptID) + m.heals.clear(ckptID) if errors.Is(err, store.ErrNotFound) { return types.Checkpoint{}, ErrUnknownCheckpoint } @@ -280,7 +281,7 @@ func (m *Manager) runHeal(ctx context.Context, ckptID string) (types.Checkpoint, } defer m.recs.lockRec(ckptID)() - if aborted := m.clearHealPending(ckptID); aborted { + if aborted := m.heals.clear(ckptID); aborted { return types.Checkpoint{}, ErrUnknownCheckpoint // a concurrent delete vetoed this heal } if ckpt, err := m.loadCheckpoint(ctx, ckptID); err == nil { @@ -292,33 +293,6 @@ func (m *Manager) runHeal(ctx context.Context, ckptID string) (types.Checkpoint, return m.loadCheckpoint(ctx, ckptID) } -// markHealPending records ckptID as staging or pulling, so a delete can veto it. -func (m *Manager) markHealPending(ckptID string) { - m.healMu.Lock() - defer m.healMu.Unlock() - m.healPending[ckptID] = struct{}{} -} - -// clearHealPending un-marks ckptID and reports a veto. -func (m *Manager) clearHealPending(ckptID string) (aborted bool) { - m.healMu.Lock() - defer m.healMu.Unlock() - delete(m.healPending, ckptID) - if _, aborted = m.healAbort[ckptID]; aborted { - delete(m.healAbort, ckptID) - } - return aborted -} - -// vetoIfHealPending aborts a pending heal so its publish cannot resurrect a deleted checkpoint. -func (m *Manager) vetoIfHealPending(ckptID string) { - m.healMu.Lock() - defer m.healMu.Unlock() - if _, pending := m.healPending[ckptID]; pending { - m.healAbort[ckptID] = struct{}{} - } -} - // pinnedArchiveCks is the set of wake-image ids the listing hides and delete/TTL must spare. func (m *Manager) pinnedArchiveCks() map[string]struct{} { m.mu.Lock() @@ -369,6 +343,39 @@ func (m *Manager) loadCheckpoint(ctx context.Context, ckptID string) (types.Chec return parseCheckpoint(raw) } +// healVetoes lets a delete veto a heal still staging. +type healVetoes struct { + mu sync.Mutex + pending, aborted map[string]struct{} +} + +// mark records ckptID as staging or pulling, so a delete can veto it. +func (h *healVetoes) mark(ckptID string) { + h.mu.Lock() + defer h.mu.Unlock() + h.pending[ckptID] = struct{}{} +} + +// clear un-marks ckptID and reports a veto. +func (h *healVetoes) clear(ckptID string) (aborted bool) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.pending, ckptID) + if _, aborted = h.aborted[ckptID]; aborted { + delete(h.aborted, ckptID) + } + return aborted +} + +// veto aborts a pending heal so its publish cannot resurrect a deleted checkpoint. +func (h *healVetoes) veto(ckptID string) { + h.mu.Lock() + defer h.mu.Unlock() + if _, pending := h.pending[ckptID]; pending { + h.aborted[ckptID] = struct{}{} + } +} + // validateHealedCheckpoint checks a staged pull's shape: a bad record suppresses later heals. func validateHealedCheckpoint(staging, wantID string) error { raw, err := os.ReadFile(filepath.Join(staging, store.MetaFile)) //nolint:gosec // staging dir is this manager's own diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index 3ed8cb7f0..c94f0ea6c 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -303,10 +303,7 @@ type Manager struct { healer *peer.Healer healSem chan struct{} healFlights singleflight.Group - // healPending/healAbort, guarded by healMu, let a delete veto a heal still staging. - healMu sync.Mutex - healPending map[string]struct{} - healAbort map[string]struct{} + heals healVetoes peerDelete PeerDeleteFunc peerAdopt PeerAdoptFunc tpls store.Store @@ -370,8 +367,7 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg archiving: map[string]struct{}{}, pendingCks: map[string]struct{}{}, recs: recordLocks{held: map[string]*sync.RWMutex{}, refs: map[string]int{}}, - healPending: map[string]struct{}{}, - healAbort: map[string]struct{}{}, + heals: healVetoes{pending: map[string]struct{}{}, aborted: map[string]struct{}{}}, refillSem: make(chan struct{}, refill), goldenDumps: make(chan struct{}, 1), maintSem: make(chan struct{}, max(1, refill-max(1, refill/4))), From 0f3cacda7b0c418d0a863bf309b097a56cd126c7 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 07:57:13 +0800 Subject: [PATCH 65/70] review: ckMarkers owns the archive delete markers, and the delete and retry chain moves with it to archive_delete.go --- sandboxd/pool/archive.go | 150 +-------------------- sandboxd/pool/archive_delete.go | 160 +++++++++++++++++++++++ sandboxd/pool/archive_test.go | 4 +- sandboxd/pool/checkpoint_cleanup_test.go | 6 +- sandboxd/pool/claim.go | 4 +- sandboxd/pool/failover.go | 4 +- sandboxd/pool/failover_test.go | 6 +- sandboxd/pool/pool.go | 3 + sandboxd/pool/reap.go | 4 +- sandboxd/pool/replica_test.go | 2 +- sandboxd/pool/stale_claim_test.go | 2 +- 11 files changed, 181 insertions(+), 164 deletions(-) create mode 100644 sandboxd/pool/archive_delete.go diff --git a/sandboxd/pool/archive.go b/sandboxd/pool/archive.go index 23af212c8..d562431af 100644 --- a/sandboxd/pool/archive.go +++ b/sandboxd/pool/archive.go @@ -4,10 +4,6 @@ import ( "context" "errors" "fmt" - "io/fs" - "os" - "path/filepath" - "slices" "time" "github.com/projecteru2/core/log" @@ -15,11 +11,8 @@ import ( "github.com/cocoonstack/sandbox/sandboxd/store" "github.com/cocoonstack/sandbox/sandboxd/types" "github.com/cocoonstack/sandbox/sandboxd/usage" - "github.com/cocoonstack/sandbox/sandboxd/utils" ) -const archiveDeleteDir = "archive-deletes" - // archiveAfterFor resolves a key's threshold (pool's, else node default); callers hold m.mu. func (m *Manager) archiveAfterFor(key types.PoolKey) time.Duration { if p, ok := m.activePool(key); ok { @@ -228,7 +221,7 @@ func (m *Manager) wakeArchived(ctx context.Context, sb *types.Sandbox) (string, m.destroy(ctx, built.VMName) return "", fenceErr } - if markErr := m.markArchiveCk(ck); markErr != nil { + if markErr := m.ckMarks.mark(ck); markErr != nil { m.destroy(ctx, built.VMName) return "", fmt.Errorf("wake %s: track archive checkpoint: %w", sb.ID, markErr) } @@ -241,7 +234,7 @@ func (m *Manager) wakeArchived(ctx context.Context, sb *types.Sandbox) (string, if delErr := m.ckpts.Delete(ctx, ck); delErr != nil { logger.Warnf(ctx, "delete consumed archive ck %s: %v", ck, delErr) } else { - if clearErr := m.clearArchiveCk(ck); clearErr != nil { + if clearErr := m.ckMarks.clear(ck); clearErr != nil { logger.Warnf(ctx, "clear archive ck %s: %v", ck, clearErr) } m.dropTombstone(ctx, sb.ID, consumed) @@ -289,142 +282,3 @@ func (m *Manager) deleteOrphanArchiveCk(ctx context.Context, ckID string) { log.WithFunc("pool.deleteOrphanArchiveCk").Warnf(ctx, "delete orphaned archive ck %s: %v", ckID, err) } } - -// markArchiveCk records durable delete intent before the journal drops its reference. -func (m *Manager) markArchiveCk(ckID string) error { - return m.markArchiveCks([]string{ckID})[ckID] -} - -// markArchiveCks writes a delete marker per checkpoint and syncs the directory once; failed holds each id whose marker is not durable. -func (m *Manager) markArchiveCks(ckIDs []string) (failed map[string]error) { - if len(ckIDs) == 0 { - return nil - } - failed = map[string]error{} - dir := filepath.Join(m.dataDir, archiveDeleteDir) - if err := os.MkdirAll(dir, 0o750); err != nil { - for _, id := range ckIDs { - failed[id] = err - } - return failed - } - written := make([]string, 0, len(ckIDs)) - for _, id := range ckIDs { - if !store.CheckpointIDRe.MatchString(id) { - failed[id] = fmt.Errorf("invalid checkpoint id %q", id) - continue - } - if err := os.WriteFile(filepath.Join(dir, id), nil, 0o600); err != nil { - failed[id] = err - continue - } - written = append(written, id) - } - if len(written) == 0 { - return failed - } - if err := utils.SyncDir(dir); err != nil { - for _, id := range written { - failed[id] = err - } - } - return failed -} - -func (m *Manager) archiveDeleteMarkers() ([]string, error) { - entries, err := os.ReadDir(filepath.Join(m.dataDir, archiveDeleteDir)) - if errors.Is(err, fs.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, err - } - ids := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.Type().IsRegular() && store.CheckpointIDRe.MatchString(entry.Name()) { - ids = append(ids, entry.Name()) - } - } - return ids, nil -} - -func (m *Manager) clearArchiveCk(ckID string) error { - err := os.Remove(filepath.Join(m.dataDir, archiveDeleteDir, ckID)) - if errors.Is(err, fs.ErrNotExist) { - return nil - } - return err -} - -func (m *Manager) deleteArchiveCk(ctx context.Context, ckID string) (bool, error) { - if err := m.markArchiveCk(ckID); err != nil { - return false, fmt.Errorf("track: %w", err) - } - return m.finishArchiveCkDelete(ctx, ckID) -} - -func (m *Manager) finishArchiveCkDelete(ctx context.Context, ckID string) (bool, error) { - unlock := m.recs.lockRec(ckID) - if m.store.referencesCheckpoint(ckID) { - unlock() - return false, nil - } - err := m.ckpts.Delete(ctx, ckID) - unlock() - if err != nil { - return false, err - } - if err := m.clearArchiveCk(ckID); err != nil { - return false, fmt.Errorf("clear: %w", err) - } - return true, nil -} - -func (m *Manager) retryArchiveDeletes(ctx context.Context) { - if !m.sweeps.archiveDelete.CompareAndSwap(false, true) { - return - } - defer m.sweeps.archiveDelete.Store(false) - ids, err := m.archiveDeleteMarkers() - if err != nil { - log.WithFunc("pool.retryArchiveDeletes").Warnf(ctx, "list: %v", err) - return - } - if len(ids) == 0 { - return - } - pinned := m.pinnedArchiveCks() - ids = slices.DeleteFunc(ids, func(id string) bool { _, ok := pinned[id]; return ok }) - m.runBounded(ctx, len(ids), func(ctx context.Context, i int) { - m.retryArchiveDelete(ctx, ids[i]) - }).Wait() -} - -func (m *Manager) retryArchiveDelete(ctx context.Context, ckID string) { - unlock := m.recs.lockRec(ckID) - if m.archiveCkPinned(ckID) || !m.deleteMarked(ckID) { - unlock() - return - } - err := m.ckpts.Delete(ctx, ckID) - unlock() - if err != nil { - log.WithFunc("pool.retryArchiveDelete").Warnf(ctx, "delete %s: %v", ckID, err) - return - } - if err := m.clearArchiveCk(ckID); err != nil { - log.WithFunc("pool.retryArchiveDelete").Warnf(ctx, "clear %s: %v", ckID, err) - } -} - -func (m *Manager) deleteMarked(ckID string) bool { - _, err := os.Stat(filepath.Join(m.dataDir, archiveDeleteDir, ckID)) - return err == nil -} - -func (m *Manager) archiveCkPinned(ckID string) bool { - m.mu.Lock() - defer m.mu.Unlock() - _, pending := m.pendingCks[ckID] - return pending || m.store.referencesCheckpoint(ckID) -} diff --git a/sandboxd/pool/archive_delete.go b/sandboxd/pool/archive_delete.go new file mode 100644 index 000000000..17d1f8bb7 --- /dev/null +++ b/sandboxd/pool/archive_delete.go @@ -0,0 +1,160 @@ +package pool + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + + "github.com/projecteru2/core/log" + + "github.com/cocoonstack/sandbox/sandboxd/store" + "github.com/cocoonstack/sandbox/sandboxd/utils" +) + +const archiveDeleteDir = "archive-deletes" + +type ckMarkers struct { + dir string +} + +// mark records durable delete intent before the journal drops its reference. +func (c ckMarkers) mark(ckID string) error { + return c.markAll([]string{ckID})[ckID] +} + +// markAll writes a delete marker per checkpoint and syncs the directory once; failed holds each id whose marker is not durable. +func (c ckMarkers) markAll(ckIDs []string) (failed map[string]error) { + if len(ckIDs) == 0 { + return nil + } + failed = map[string]error{} + if err := os.MkdirAll(c.dir, 0o750); err != nil { + for _, id := range ckIDs { + failed[id] = err + } + return failed + } + written := make([]string, 0, len(ckIDs)) + for _, id := range ckIDs { + if !store.CheckpointIDRe.MatchString(id) { + failed[id] = fmt.Errorf("invalid checkpoint id %q", id) + continue + } + if err := os.WriteFile(filepath.Join(c.dir, id), nil, 0o600); err != nil { + failed[id] = err + continue + } + written = append(written, id) + } + if len(written) == 0 { + return failed + } + if err := utils.SyncDir(c.dir); err != nil { + for _, id := range written { + failed[id] = err + } + } + return failed +} + +func (c ckMarkers) list() ([]string, error) { + entries, err := os.ReadDir(c.dir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.Type().IsRegular() && store.CheckpointIDRe.MatchString(entry.Name()) { + ids = append(ids, entry.Name()) + } + } + return ids, nil +} + +func (c ckMarkers) clear(ckID string) error { + err := os.Remove(filepath.Join(c.dir, ckID)) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (c ckMarkers) marked(ckID string) bool { + _, err := os.Stat(filepath.Join(c.dir, ckID)) + return err == nil +} + +func (m *Manager) deleteArchiveCk(ctx context.Context, ckID string) (bool, error) { + if err := m.ckMarks.mark(ckID); err != nil { + return false, fmt.Errorf("track: %w", err) + } + return m.finishArchiveCkDelete(ctx, ckID) +} + +func (m *Manager) finishArchiveCkDelete(ctx context.Context, ckID string) (bool, error) { + unlock := m.recs.lockRec(ckID) + if m.store.referencesCheckpoint(ckID) { + unlock() + return false, nil + } + err := m.ckpts.Delete(ctx, ckID) + unlock() + if err != nil { + return false, err + } + if err := m.ckMarks.clear(ckID); err != nil { + return false, fmt.Errorf("clear: %w", err) + } + return true, nil +} + +func (m *Manager) retryArchiveDeletes(ctx context.Context) { + if !m.sweeps.archiveDelete.CompareAndSwap(false, true) { + return + } + defer m.sweeps.archiveDelete.Store(false) + ids, err := m.ckMarks.list() + if err != nil { + log.WithFunc("pool.retryArchiveDeletes").Warnf(ctx, "list: %v", err) + return + } + if len(ids) == 0 { + return + } + pinned := m.pinnedArchiveCks() + ids = slices.DeleteFunc(ids, func(id string) bool { _, ok := pinned[id]; return ok }) + m.runBounded(ctx, len(ids), func(ctx context.Context, i int) { + m.retryArchiveDelete(ctx, ids[i]) + }).Wait() +} + +func (m *Manager) retryArchiveDelete(ctx context.Context, ckID string) { + unlock := m.recs.lockRec(ckID) + if m.archiveCkPinned(ckID) || !m.ckMarks.marked(ckID) { + unlock() + return + } + err := m.ckpts.Delete(ctx, ckID) + unlock() + if err != nil { + log.WithFunc("pool.retryArchiveDelete").Warnf(ctx, "delete %s: %v", ckID, err) + return + } + if err := m.ckMarks.clear(ckID); err != nil { + log.WithFunc("pool.retryArchiveDelete").Warnf(ctx, "clear %s: %v", ckID, err) + } +} + +func (m *Manager) archiveCkPinned(ckID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, pending := m.pendingCks[ckID] + return pending || m.store.referencesCheckpoint(ckID) +} diff --git a/sandboxd/pool/archive_test.go b/sandboxd/pool/archive_test.go index 594d894f3..67c9f3dc7 100644 --- a/sandboxd/pool/archive_test.go +++ b/sandboxd/pool/archive_test.go @@ -883,7 +883,7 @@ func TestArchiveWakeEvictsRecLock(t *testing.T) { func TestMarkArchiveCksKeepsOnlyTheIDsItCouldNotMark(t *testing.T) { m := newTestManager(t, newFakeEngine()) good := []string{"ck_00000000000000a1", "ck_00000000000000a2"} - failed := m.markArchiveCks([]string{good[0], "not-a-ck", good[1]}) + failed := m.ckMarks.markAll([]string{good[0], "not-a-ck", good[1]}) if len(failed) != 1 || failed["not-a-ck"] == nil { t.Fatalf("failed %v, want only the invalid id", failed) } @@ -892,7 +892,7 @@ func TestMarkArchiveCksKeepsOnlyTheIDsItCouldNotMark(t *testing.T) { t.Errorf("%s not marked: an invalid id beside it blocked the batch", ck) } } - if failed := m.markArchiveCks(nil); failed != nil { + if failed := m.ckMarks.markAll(nil); failed != nil { t.Errorf("an empty batch reported %v", failed) } } diff --git a/sandboxd/pool/checkpoint_cleanup_test.go b/sandboxd/pool/checkpoint_cleanup_test.go index cd21b008d..d25514085 100644 --- a/sandboxd/pool/checkpoint_cleanup_test.go +++ b/sandboxd/pool/checkpoint_cleanup_test.go @@ -104,7 +104,7 @@ func TestCheckpointCleanupWaitsForTheClaimWrite(t *testing.T) { restoreWrites := failClaimWrites(t, m.store) tt.act(t, m, c, sb) m.retryArchiveDeletes(t.Context()) - if !ckExists(t, m, ck) || !m.deleteMarked(ck) { + if !ckExists(t, m, ck) || !m.ckMarks.marked(ck) { t.Fatal("cleanup dropped the checkpoint or its delete marker before the claim write landed") } restoreWrites() @@ -127,7 +127,7 @@ func TestCheckpointCleanupWaitsForTheClaimWrite(t *testing.T) { m.store.set(other) m.mu.Unlock() m.retryArchiveDeletes(t.Context()) - if ckExists(t, m, ck) || m.deleteMarked(ck) { + if ckExists(t, m, ck) || m.ckMarks.marked(ck) { t.Fatal("cleanup did not finish once the claim write landed") } for _, live := range checkpointRefs(sb) { @@ -212,7 +212,7 @@ func TestFailoverDisabledExitKeepsReplicaCheckpoint(t *testing.T) { if !ok || checkpointRefs(restored) != checkpointRefs(sb) { t.Fatalf("startup without a registry changed the claim: %+v", restored) } - if !ckExists(t, m, ck) || m.deleteMarked(ck) { + if !ckExists(t, m, ck) || m.ckMarks.marked(ck) { t.Fatalf("startup without a registry queued or deleted checkpoint %s", ck) } exit.run(t, m, restored) diff --git a/sandboxd/pool/claim.go b/sandboxd/pool/claim.go index 49fbf7402..b394a2bf8 100644 --- a/sandboxd/pool/claim.go +++ b/sandboxd/pool/claim.go @@ -288,7 +288,7 @@ func (m *Manager) releaseResolved(ctx context.Context, id string, sb *types.Sand break } m.mu.Unlock() - if markErr := m.markArchiveCk(ck); markErr != nil { + if markErr := m.ckMarks.mark(ck); markErr != nil { return fmt.Errorf("release %s: track archive checkpoint: %w", id, markErr) } marked = ck @@ -311,7 +311,7 @@ func (m *Manager) releaseResolved(ctx context.Context, id string, sb *types.Sand m.mu.Unlock() logger := log.WithFunc("pool.releaseResolved") if ck != "" { - if clearErr := m.clearArchiveCk(ck); clearErr != nil { + if clearErr := m.ckMarks.clear(ck); clearErr != nil { logger.Warnf(ctx, "clear archive ck %s: %v", ck, clearErr) } } diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index 957db6cb7..3c7fea174 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -409,7 +409,7 @@ func (m *Manager) forgetAdopted(ctx context.Context, sb *types.Sandbox) func() { ck, vm, snap := sb.ArchiveCk, sb.VMName, sb.HibernateSnap m.mu.Unlock() if ck != "" { - if err := m.clearArchiveCk(ck); err != nil { + if err := m.ckMarks.clear(ck); err != nil { logger.Warnf(ctx, "clear archive ck %s: %v", ck, err) } } @@ -597,7 +597,7 @@ func (m *Manager) fenceArchived(ctx context.Context, claims map[string]*types.Sa } if drop { if sb.ArchiveCk != "" { - if err := m.clearArchiveCk(sb.ArchiveCk); err != nil { + if err := m.ckMarks.clear(sb.ArchiveCk); err != nil { logger.Warnf(ctx, "clear archive ck %s: %v", sb.ArchiveCk, err) } } diff --git a/sandboxd/pool/failover_test.go b/sandboxd/pool/failover_test.go index 1d99b6999..43b65f94d 100644 --- a/sandboxd/pool/failover_test.go +++ b/sandboxd/pool/failover_test.go @@ -824,7 +824,7 @@ func TestFailoverALeftoverDeleteMarkerSparesTheAdoptersCheckpoint(t *testing.T) sb := mustClaim(t, a, testKey) mustArchive(t, a, sb) ck := sb.ArchiveCk - if err := a.markArchiveCk(ck); err != nil { + if err := a.ckMarks.mark(ck); err != nil { t.Fatalf("mark: %v", err) } c.expire("a") @@ -845,7 +845,7 @@ func TestFailoverRestartClearsTheMarkerOfAnAdoptedClaim(t *testing.T) { sb := mustClaim(t, a, testKey) mustArchive(t, a, sb) ck := sb.ArchiveCk - if err := a.markArchiveCk(ck); err != nil { + if err := a.ckMarks.mark(ck); err != nil { t.Fatalf("mark: %v", err) } if err := a.FlushClaims(); err != nil { @@ -1207,7 +1207,7 @@ func TestFailoverADropClearsTheMarkerBeforeTheCheckpointIsUnpinned(t *testing.T) sb := mustClaim(t, a, testKey) mustArchive(t, a, sb) ck := sb.ArchiveCk - if err := a.markArchiveCk(ck); err != nil { + if err := a.ckMarks.mark(ck); err != nil { t.Fatalf("mark: %v", err) } c.expire("a") diff --git a/sandboxd/pool/pool.go b/sandboxd/pool/pool.go index c94f0ea6c..d301b4c64 100644 --- a/sandboxd/pool/pool.go +++ b/sandboxd/pool/pool.go @@ -314,6 +314,8 @@ type Manager struct { recs recordLocks + ckMarks ckMarkers + // notifyTemplates is set before serving starts and fires after a promote or template delete. notifyTemplates func() @@ -366,6 +368,7 @@ func NewManager(ctx context.Context, cfg *config.Config, eng Engine, secrets *eg readers: &readerSet{live: map[string]*reader{}}, archiving: map[string]struct{}{}, pendingCks: map[string]struct{}{}, + ckMarks: ckMarkers{dir: filepath.Join(cfg.DataDir, archiveDeleteDir)}, recs: recordLocks{held: map[string]*sync.RWMutex{}, refs: map[string]int{}}, heals: healVetoes{pending: map[string]struct{}{}, aborted: map[string]struct{}{}}, refillSem: make(chan struct{}, refill), diff --git a/sandboxd/pool/reap.go b/sandboxd/pool/reap.go index cd81ebd25..da667282b 100644 --- a/sandboxd/pool/reap.go +++ b/sandboxd/pool/reap.go @@ -77,7 +77,7 @@ func (m *Manager) reapOnce(ctx context.Context) { } logger := log.WithFunc("pool.reapOnce") - failed := m.markArchiveCks(purges) + failed := m.ckMarks.markAll(purges) expired = slices.DeleteFunc(expired, func(v victim) bool { markErr, bad := failed[v.ck] if bad { @@ -126,7 +126,7 @@ func (m *Manager) reapOnce(ctx context.Context) { rb := m.store.set(restored...) m.mu.Unlock() for _, ck := range purgeCks { - if clearErr := m.clearArchiveCk(ck); clearErr != nil { + if clearErr := m.ckMarks.clear(ck); clearErr != nil { logger.Warnf(ctx, "clear archive ck %s: %v", ck, clearErr) } } diff --git a/sandboxd/pool/replica_test.go b/sandboxd/pool/replica_test.go index d6a14f3c2..553a26997 100644 --- a/sandboxd/pool/replica_test.go +++ b/sandboxd/pool/replica_test.go @@ -1093,7 +1093,7 @@ func TestReplicaALeftoverDeleteMarkerSparesTheLiveReplica(t *testing.T) { sb := mustClaim(t, a, testKey) replicateNow(t, a, sb) ck := a.replicaOf(sb) - if err := a.markArchiveCk(ck); err != nil { + if err := a.ckMarks.mark(ck); err != nil { t.Fatalf("mark: %v", err) } diff --git a/sandboxd/pool/stale_claim_test.go b/sandboxd/pool/stale_claim_test.go index 6559045df..ae5ea3edf 100644 --- a/sandboxd/pool/stale_claim_test.go +++ b/sandboxd/pool/stale_claim_test.go @@ -105,7 +105,7 @@ func TestStaleClaimOperationsPreserveTheCurrentClaim(t *testing.T) { } else if loaded[sb.ID] != nil { t.Errorf("stale operation resurrected released claim on disk: %+v", loaded[sb.ID]) } - if err = m.markArchiveCk(ck.ID); err != nil { + if err = m.ckMarks.mark(ck.ID); err != nil { t.Fatalf("mark the archive checkpoint: %v", err) } m.retryArchiveDeletes(t.Context()) From 427d1a2bb55d2a1a9725d4b0a69c6b7684832775 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 08:17:17 +0800 Subject: [PATCH 66/70] pool: an uncordon during an evacuation's drain stops it Evacuate waits for the warm VMs to go before it starts its run. A DELETE /v1/drain in that wait found nothing to stop, and the run then moved every claim off an uncordoned node. --- sandboxd/pool/drain.go | 2 +- sandboxd/pool/drain_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/sandboxd/pool/drain.go b/sandboxd/pool/drain.go index 1363c08d9..d4e5e9db9 100644 --- a/sandboxd/pool/drain.go +++ b/sandboxd/pool/drain.go @@ -37,7 +37,7 @@ func (m *Manager) Evacuate(ctx context.Context) { m.Drain(ctx) m.mu.Lock() defer m.mu.Unlock() - if m.evac != nil && len(m.evac.pending) > 0 { + if !m.draining || m.evac != nil && len(m.evac.pending) > 0 { return } ctx, stop := context.WithCancel(context.WithoutCancel(ctx)) diff --git a/sandboxd/pool/drain_test.go b/sandboxd/pool/drain_test.go index 26bd96aba..a44ec3f8a 100644 --- a/sandboxd/pool/drain_test.go +++ b/sandboxd/pool/drain_test.go @@ -249,6 +249,40 @@ func TestUncordonStopsAnEvacuationBeforeItsHandOff(t *testing.T) { }) } +func TestAnUncordonDuringAnEvacuationsDrainStopsIt(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + c := newFailoverCluster(t) + a := c.node(t, "a", t.TempDir(), keepAnHour) + sb := mustClaim(t, a, testKey) + seedGolden(t, a, testKey, "") + if err := a.SetPools(t.Context(), []config.PoolSpec{{PoolKey: testKey, Warm: 1}}); err != nil { + t.Fatalf("SetPools: %v", err) + } + waitFor(t, func() bool { + infos, _ := a.Info() + return len(infos) == 1 && infos[0].Warm == 1 + }) + release := holdMaint(a) + done := make(chan struct{}) + go func() { + defer close(done) + a.Evacuate(t.Context()) + }() + synctest.Wait() + + a.Uncordon(t.Context()) + release() + <-done + synctest.Wait() + + assertEvacuation(t, a, 0, 0) + held, ok := a.byID(sb.ID) + if !ok || held.HibernateSnap != "" || held.ArchiveCk != "" { + t.Errorf("held %v, want the claim still running after the uncordon", ok) + } + }) +} + func TestEvacuateHoldsTheWakeOfAQueuedClaimUntilUncordon(t *testing.T) { tests := []struct { name string From a2e86562780f5d18eb299ad905349fcad977d50f Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 08:17:35 +0800 Subject: [PATCH 67/70] pool: a tier claim woken past its lease before the export gets a fresh lease The reaper archives a hibernated claim of a tier pool at its lease end, and the same call a moment after the export restores it with a fresh lease. A call in between woke it with the past deadline, so the next reap tick destroyed it. --- docs/deploy.md | 2 +- sandboxd/pool/expiry_test.go | 23 +++++++++++++++++++++++ sandboxd/pool/hibernate.go | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/deploy.md b/docs/deploy.md index a52e0dc15..2aafca6b5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -163,7 +163,7 @@ the intersection of the two. | `audit_log` | false | append every relayed frame that opens an RPC — its op + addressing fields, never payloads — to `/audit.jsonl`; continuation `data`, `data_end`, `stdin`, and `stdin_close` frames pass through unrecorded. The file is size-rotated with one `.1` backup. Records are `{t, id, op}` plus whichever addressing fields the op carries (`argv`, `path`, `dest`, `from`, `to`, `url`, `session`, `port`), plus `method` (`GET`, `CONNECT`, `SOCKS5`, …), `decision` (`allow`, `deny`, `rate_limited`), `secret` (the ref name, never its value), `upstream` (the [upstream proxy](egress.md#upstream-proxies)'s `host:port`, when one carried it), `tenant`, and on an allowed connection `src_addr` and `dst_addr` (the node-side and destination `ip:port` of the upstream TCP connection, the tunnel target through an upstream proxy) on `egress` records, which a relayed frame cannot set; preview accesses record as op `preview`, one per request; a tenant change records as op `tenants` with the `added`, `changed` and `removed` names, never a token; a config reload records as op `config_reload` with `changed`, and a runtime volume change as op `volumes` with `added` or `removed`. An opening frame whose first line exceeds 4 KiB records as op `oversized` with no addressing fields | | `disk_reserve` | unset | `{"free": "100G", "dirs": ["/srv/sbx/run"]}`: while the filesystem of any listed directory (default `data_dir`) has less than `free` available, checked every 2 s, the node refuses new claims, forks, checkpoint claims, wakes of hibernated or archived sandboxes, hibernates, checkpoint captures and promotes with 429, and parks refill; the idle sweep and a lease-end archive leave their sandboxes running and retry on the next tick; `/v1/info` reports it as at capacity and the mesh sees no warm VMs on it. Running sandboxes keep running. On an XFS project quota the free space is the project's. List cocoon's `run_dir` when it sits on another filesystem, since the copy-on-write disks grow there. Size `free` to at least two sandboxes' worst-case growth (copy-on-write disk plus memory file each) and keep the refusal above your disk alert's warning level, so the warning comes first: on a 640G project with 40G disks and 8G memory files, `100G` refuses at 84% used | | `idle_hibernate_seconds` | 0 (off) | node-wide idle policy for unpooled claims (template/checkpoint claims): a none-lane claim is hibernated once it has had no open data-plane connection (relay, buffered exec, preview) and no egress request in flight for this long; the clock restarts when the last connection closes, so a long command is never cut short. The next call that reaches the guest wakes it transparently. Per-pool `idle_hibernate_seconds` does the same for that pool's claims; pooled keys ignore the node-wide value, and egress pools reject it because they cannot resume safely. Opt in deliberately: a wake costs latency and the snapshot, so callers with their own idle logic must not pay twice | -| `archive_after_seconds` | 0 (off) | tier below hibernation: a hibernated claim idle this long is checkpointed to the store and its local VM dropped, freeing the node entirely; the next call that reaches the guest restores it transparently (a checkpoint restore's latency) with a fresh lease of the length the claim was granted (the server default of 5m when it asked for none); a lease moves only when a client calls [`/renew`](sandboxd-api.md#post-v1sandboxesidrenew). Keep `idle_hibernate_seconds` under the leases your claims use, or a woken sandbox that idles again is destroyed by the reap before it can hibernate, its archive already consumed by the wake; one still in use when its lease ends is destroyed the same way, unless it claimed `on_expire: archive`, which archives at the lease end whatever this setting. Requires `idle_hibernate_seconds > 0` and must exceed it. Node-wide for unpooled keys; per-pool overrides for that pool | +| `archive_after_seconds` | 0 (off) | tier below hibernation: a hibernated claim idle this long is checkpointed to the store and its local VM dropped, freeing the node entirely; the next call that reaches the guest restores it transparently (a checkpoint restore's latency) with a fresh lease of the length the claim was granted (the server default of 5m when it asked for none); a lease moves only when a client calls [`/renew`](sandboxd-api.md#post-v1sandboxesidrenew). Keep `idle_hibernate_seconds` under the leases your claims use, or a woken sandbox that idles again is destroyed by the reap before it can hibernate, its archive already consumed by the wake; one still in use when its lease ends is destroyed the same way, unless it claimed `on_expire: archive`, which archives at the lease end whatever this setting. A call that wakes a hibernated claim past its lease, before the export, starts that fresh lease too. Requires `idle_hibernate_seconds > 0` and must exceed it. Node-wide for unpooled keys; per-pool overrides for that pool | | `archive_delete_after_seconds` | 0 (keep) | purge an archived claim's store checkpoint this long after it was archived; the claim is then gone for good. On S3, chunks and manifests remain until [offline reclamation](#reclaiming-s3-storage). On archive this retention window replaces the live claim deadline; 0 clears the deadline so the archive is kept forever. An [evacuation](cluster.md#evacuating-a-node) keeps a longer live deadline, and a claim of a pool without an archive tier, whose `on_expire` is not `archive`, keeps its deadline. Same node-wide/per-pool split | | `archive_failover` | false | let another node adopt this node's archived claims once its node lease expires, and the running claims of pools with `replicate_seconds`: each archive and replication round also writes a row (token SHA-256, owner, epoch, checkpoint id, retention deadline, and the claim record with its env, `guest: false` values included, in plaintext) to the shared PostgreSQL, and a `Lookup` that reaches any node takes the claim over there (see [archived claims across node loss](cluster.md#archived-claims-across-node-loss)). Requires `meta_store` with a `cell` (rows are scoped by it, so meshes sharing one database never adopt from each other), a `checkpoint_store` of kind `s3` and a `mesh`, all checked at config load. The node also renews a lease row every 5 s (see [node lease](cluster.md#node-lease)). With the meta store down, archives, wakes, releases and env writes of archived claims, and env writes and renews of hibernated replicated claims, fail and retry, and replicated claims stop being served once the lease is overdue (see [running claims](cluster.md#running-claims-across-node-loss)); other running claims never touch it. Restart only | | `mesh` | unset | join a cluster ([Clusters](cluster.md)); unset = single node | diff --git a/sandboxd/pool/expiry_test.go b/sandboxd/pool/expiry_test.go index bbac80d7e..da73900b3 100644 --- a/sandboxd/pool/expiry_test.go +++ b/sandboxd/pool/expiry_test.go @@ -189,6 +189,29 @@ func TestExpiryArchiveWakeBeforeTheExportGrantsAFreshLease(t *testing.T) { } } +func TestATierClaimWokenPastItsLeaseBeforeTheExportGetsAFreshLease(t *testing.T) { + m := newTestManager(t, newFakeEngine(), config.PoolSpec{PoolKey: testKey, ArchiveAfterSeconds: 3600}) + sb, err := m.ClaimProvision(t.Context(), testKey, ClaimOptions{TTL: time.Hour}) + if err != nil { + t.Fatalf("claim: %v", err) + } + hibernateClaim(t, m, sb) + expire(m, sb) + if _, _, _, err := m.WakeAgentSocket(t.Context(), sb.ID, sb.Token); err != nil { + t.Fatalf("wake: %v", err) + } + m.mu.Lock() + lease := time.Until(sb.Deadline) + m.mu.Unlock() + if lease < 59*time.Minute { + t.Fatalf("lease after a wake past the lease = %v, want the claim's hour again", lease) + } + m.reapOnce(t.Context()) + if _, ok := m.byID(sb.ID); !ok { + t.Error("the reaper destroyed the woken claim") + } +} + func TestRenewSwitchesTheExpireAction(t *testing.T) { m := newTestManager(t, newFakeEngine()) sb := mustClaim(t, m, testKey) diff --git a/sandboxd/pool/hibernate.go b/sandboxd/pool/hibernate.go index 7e767a3ff..3e8092c81 100644 --- a/sandboxd/pool/hibernate.go +++ b/sandboxd/pool/hibernate.go @@ -300,7 +300,7 @@ func (m *Manager) commitTransition(ctx context.Context, sb *types.Sandbox, snap, sb.VsockSocket = sock sb.PendingSnap = "" sb.ReaderSafe = sb.ReaderSafe && snap != "" - if now := time.Now(); snap == "" && sb.OnExpire == types.ExpireArchive && !m.tenantGone(sb.Tenant) && now.After(sb.Deadline) { + if now := time.Now(); snap == "" && (sb.OnExpire == types.ExpireArchive || m.archiveAfterFor(sb.Key) > 0) && !m.tenantGone(sb.Tenant) && now.After(sb.Deadline) { sb.Deadline = now.Add(clampTTL(types.Seconds(sb.LeaseSeconds))) } }) From def8bdc262fe8b39f36af8090b9a03af30087ce7 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 08:17:46 +0800 Subject: [PATCH 68/70] pool: a running claim that moved to a peer closes its egress doors forgetAdopted removes the VM with no sandbox id, so the removal never disarms. The doors, their listeners and their goroutines stayed until the next restart, one set per moved claim, unlogged. --- sandboxd/pool/failover.go | 1 + sandboxd/pool/failover_test.go | 6 ++++++ sandboxd/pool/replica_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/sandboxd/pool/failover.go b/sandboxd/pool/failover.go index 3c7fea174..d1b17436b 100644 --- a/sandboxd/pool/failover.go +++ b/sandboxd/pool/failover.go @@ -418,6 +418,7 @@ func (m *Manager) forgetAdopted(ctx context.Context, sb *types.Sandbox) func() { return nil } return func() { + m.out.Disarm(sb.ID, true) if vm != "" { m.removeOrRetry(ctx, vm, "", volumeTeardown{holds: holds}) } diff --git a/sandboxd/pool/failover_test.go b/sandboxd/pool/failover_test.go index 43b65f94d..c436de56b 100644 --- a/sandboxd/pool/failover_test.go +++ b/sandboxd/pool/failover_test.go @@ -16,6 +16,7 @@ import ( "github.com/cocoonstack/sandbox/sandboxd/archives" "github.com/cocoonstack/sandbox/sandboxd/archives/archivestest" "github.com/cocoonstack/sandbox/sandboxd/config" + "github.com/cocoonstack/sandbox/sandboxd/egress" "github.com/cocoonstack/sandbox/sandboxd/store" "github.com/cocoonstack/sandbox/sandboxd/tenants" "github.com/cocoonstack/sandbox/sandboxd/types" @@ -1863,6 +1864,7 @@ type failoverCluster struct { replicate int volumes []config.VolumeSpec nodes []*Manager + egressPol *egress.Policy } func newFailoverCluster(t *testing.T) *failoverCluster { @@ -1920,10 +1922,14 @@ func (c *failoverCluster) nodeOn(t *testing.T, eng *fakeEngine, name, dataDir st t.Helper() spec := archivePool(deleteAfter) spec.ReplicateSeconds = c.replicate + spec.Egress = c.egressPol cfg := &config.Config{ APIToken: "root", DataDir: dataDir, CheckpointDir: c.ckDir, Pools: []config.PoolSpec{spec, {PoolKey: plainKey, Warm: 1}}, Mesh: &config.MeshConfig{NodeID: name}, Volumes: c.volumes, } + if c.egressPol != nil { + cfg.EgressCA = writeTestEgressCA(t) + } b := testBackends(t, cfg) b.Archives = c.reg m, err := NewManager(t.Context(), cfg, eng, testSecrets(t), b) diff --git a/sandboxd/pool/replica_test.go b/sandboxd/pool/replica_test.go index 553a26997..54b0b9c21 100644 --- a/sandboxd/pool/replica_test.go +++ b/sandboxd/pool/replica_test.go @@ -10,6 +10,9 @@ import ( "github.com/cocoonstack/sandbox/sandboxd/archives" "github.com/cocoonstack/sandbox/sandboxd/config" + "github.com/cocoonstack/sandbox/sandboxd/egress" + "github.com/cocoonstack/sandbox/sandboxd/engine" + "github.com/cocoonstack/sandbox/sandboxd/outbound/outboundtest" "github.com/cocoonstack/sandbox/sandboxd/store" "github.com/cocoonstack/sandbox/sandboxd/tenants" "github.com/cocoonstack/sandbox/sandboxd/types" @@ -1337,6 +1340,35 @@ func TestReplicaRestartMidArchiveKeepsTheClaim(t *testing.T) { } } +func TestARunningClaimThatMovedClosesItsDoors(t *testing.T) { + c := newReplicaCluster(t) + c.egressPol = &egress.Policy{Allow: []egress.Rule{{Host: "example.com"}}} + ea, eb := newFakeEngine(), newFakeEngine() + ea.sockRoot, eb.sockRoot = outboundtest.SockRoot(t), outboundtest.SockRoot(t) + a := c.nodeOn(t, ea, "a", t.TempDir(), keepAnHour) + a.liftFence() + b := c.nodeOn(t, eb, "b", t.TempDir(), keepAnHour) + b.liftFence() + sb := mustClaim(t, a, testKey) + if got := a.out.Route(sb); got != types.NetRouteRelay { + t.Fatalf("route after claim %q, want relay", got) + } + replicateNow(t, a, sb) + c.expire("a") + mustAdopt(t, b, sb, "adoption after the lease expired") + c.renew(t, "a") + a.trip(t.Context()) + a.unfence(t.Context(), time.Now()) + waitFor(t, func() bool { return ea.removed(sb.VMName) }) + if got := a.out.Route(sb); got != types.NetRouteNone { + t.Errorf("route after the claim moved %q, want none", got) + } + if conn, err := net.Dial("unix", engine.EgressSocketPath(sb.VsockSocket)); err == nil { + _ = conn.Close() + t.Error("the door of the removed VM still accepts") + } +} + func restartWithoutVM(t *testing.T, c *failoverCluster, a *Manager, sb *types.Sandbox, deleteAfter int) *Manager { t.Helper() if err := a.FlushClaims(); err != nil { From 118accf5c8d2f2ce0346db39fbeee3398d5f065c Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 08:18:35 +0800 Subject: [PATCH 69/70] docs: a moved claim's token, its first wake, and the 503s an evacuation and the tenant store answer --- docs/cluster.md | 2 +- docs/deploy.md | 2 +- docs/sandboxd-api.md | 14 +++++++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index 1c3023588..bd006d80d 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -292,7 +292,7 @@ released, so the node asks a peer to adopt it and never takes it back; a row ano the claim moved already, and the node drops it; a row this node still owns at another epoch keeps the claim, which is skipped. A crash between the release and a peer's answer leaves a row no node lists: a restart drops the claim, and the next `Lookup` with its token adopts it, -so a holder that recovers the token from a root listing loses that claim. A +so a holder that reads the token back with root's `GET /v1/sandboxes/{id}` loses that claim. A released row past its retention deadline is purged by any node's hourly sweep, like the row of a departed owner. diff --git a/docs/deploy.md b/docs/deploy.md index 2aafca6b5..53d01cc73 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -164,7 +164,7 @@ the intersection of the two. | `disk_reserve` | unset | `{"free": "100G", "dirs": ["/srv/sbx/run"]}`: while the filesystem of any listed directory (default `data_dir`) has less than `free` available, checked every 2 s, the node refuses new claims, forks, checkpoint claims, wakes of hibernated or archived sandboxes, hibernates, checkpoint captures and promotes with 429, and parks refill; the idle sweep and a lease-end archive leave their sandboxes running and retry on the next tick; `/v1/info` reports it as at capacity and the mesh sees no warm VMs on it. Running sandboxes keep running. On an XFS project quota the free space is the project's. List cocoon's `run_dir` when it sits on another filesystem, since the copy-on-write disks grow there. Size `free` to at least two sandboxes' worst-case growth (copy-on-write disk plus memory file each) and keep the refusal above your disk alert's warning level, so the warning comes first: on a 640G project with 40G disks and 8G memory files, `100G` refuses at 84% used | | `idle_hibernate_seconds` | 0 (off) | node-wide idle policy for unpooled claims (template/checkpoint claims): a none-lane claim is hibernated once it has had no open data-plane connection (relay, buffered exec, preview) and no egress request in flight for this long; the clock restarts when the last connection closes, so a long command is never cut short. The next call that reaches the guest wakes it transparently. Per-pool `idle_hibernate_seconds` does the same for that pool's claims; pooled keys ignore the node-wide value, and egress pools reject it because they cannot resume safely. Opt in deliberately: a wake costs latency and the snapshot, so callers with their own idle logic must not pay twice | | `archive_after_seconds` | 0 (off) | tier below hibernation: a hibernated claim idle this long is checkpointed to the store and its local VM dropped, freeing the node entirely; the next call that reaches the guest restores it transparently (a checkpoint restore's latency) with a fresh lease of the length the claim was granted (the server default of 5m when it asked for none); a lease moves only when a client calls [`/renew`](sandboxd-api.md#post-v1sandboxesidrenew). Keep `idle_hibernate_seconds` under the leases your claims use, or a woken sandbox that idles again is destroyed by the reap before it can hibernate, its archive already consumed by the wake; one still in use when its lease ends is destroyed the same way, unless it claimed `on_expire: archive`, which archives at the lease end whatever this setting. A call that wakes a hibernated claim past its lease, before the export, starts that fresh lease too. Requires `idle_hibernate_seconds > 0` and must exceed it. Node-wide for unpooled keys; per-pool overrides for that pool | -| `archive_delete_after_seconds` | 0 (keep) | purge an archived claim's store checkpoint this long after it was archived; the claim is then gone for good. On S3, chunks and manifests remain until [offline reclamation](#reclaiming-s3-storage). On archive this retention window replaces the live claim deadline; 0 clears the deadline so the archive is kept forever. An [evacuation](cluster.md#evacuating-a-node) keeps a longer live deadline, and a claim of a pool without an archive tier, whose `on_expire` is not `archive`, keeps its deadline. Same node-wide/per-pool split | +| `archive_delete_after_seconds` | 0 (keep) | purge an archived claim's store checkpoint this long after it was archived; the claim is then gone for good. On S3, chunks and manifests remain until [offline reclamation](#reclaiming-s3-storage). On archive this retention window replaces the live claim deadline; 0 clears the deadline so the archive is kept forever. An [evacuation](cluster.md#evacuating-a-node) keeps a longer live deadline, and a claim of a pool without an archive tier, whose `on_expire` is not `archive`, keeps its deadline until its first wake. Same node-wide/per-pool split | | `archive_failover` | false | let another node adopt this node's archived claims once its node lease expires, and the running claims of pools with `replicate_seconds`: each archive and replication round also writes a row (token SHA-256, owner, epoch, checkpoint id, retention deadline, and the claim record with its env, `guest: false` values included, in plaintext) to the shared PostgreSQL, and a `Lookup` that reaches any node takes the claim over there (see [archived claims across node loss](cluster.md#archived-claims-across-node-loss)). Requires `meta_store` with a `cell` (rows are scoped by it, so meshes sharing one database never adopt from each other), a `checkpoint_store` of kind `s3` and a `mesh`, all checked at config load. The node also renews a lease row every 5 s (see [node lease](cluster.md#node-lease)). With the meta store down, archives, wakes, releases and env writes of archived claims, and env writes and renews of hibernated replicated claims, fail and retry, and replicated claims stop being served once the lease is overdue (see [running claims](cluster.md#running-claims-across-node-loss)); other running claims never touch it. Restart only | | `mesh` | unset | join a cluster ([Clusters](cluster.md)); unset = single node | | `pools[]` | — | warm pools, keyed by `(template, net, size)`. `warm` defaults to 4 only when both `warm` and `warm_max` are omitted; an explicit `warm: 0` is kept, so `warm: 0` under a `warm_max` starts the pool empty and grows it on demand, and `warm: 0` alone registers the key with no warm VMs (`PUT /v1/pools` never defaults `warm`); `net` is `none` (the default) or `egress`; `size` is a tier, below (default `small`). Retune online without a restart via [`PUT /v1/pools`](sandboxd-api.md#put-v1pools) — omitted pools drain, and their goldens are deleted once they are idle (`warm: 0` keeps the golden). This is the **first-boot seed**: once a node takes a `PUT /v1/pools`, the applied set persists to `/pools.json` and overrides this section on every later boot (a startup log notes it); delete `pools.json` to return to config-owned pools. With a `meta_store` `cell`, the cell's set overrides it instead (see [shared pool set](#shared-pool-set)). Egress stays config-owned either way. A template re-pulled or re-imported under the same ref is picked up within a minute: the pool rebuilds its golden and replaces its warm VMs. See [state ownership](cluster.md#state-ownership) | diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index b2db9e51d..5bc4987f2 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -36,6 +36,8 @@ they reach its guest, so they share these answers: [evacuation](#post-v1drain) has yet to move it - 503 `archive registry down; retry` for an archived claim under `archive_failover` while the meta store is down +- 503 `tenant store down; retry` for an archived claim whose tenant the node has + not cached while the meta store is down ## POST /v1/claim @@ -565,7 +567,8 @@ token gets 403, the root token still renews. 400 a malformed body; 401 missing bearer token; 403 the claim's tenant was removed; 404 unknown id or wrong token; 409 archived, or being archived right now; 503 the archive registry is down, for a hibernated claim with a replica under `archive_failover`: the node -already holds the new lease, and a resend replaces the row. +already holds the new lease, and a resend replaces the row; 503 `tenant store +down; retry` for a tenant the node has not cached while the meta store is down. ## POST /v1/sandboxes/{id}/fork @@ -606,7 +609,7 @@ an unknown `on_expire`; 401 bad api token; 403 the parent's tenant was removed parent (neither forks, checkpoints, or promotes; see [egress](egress.md)), a failed parent, or an archived parent (`POST /wake` or an exec wakes it first); 429 node or the parent's tenant at `max_claims`, the node at `max_running`, the node draining, or the disk -under `disk_reserve`; 503 `lease lost; retry` for a replicated parent while its node's lease is overdue. +under `disk_reserve`; 503 `lease lost; retry` for a replicated parent while its node's lease is overdue, `tenant store down; retry` for a sandbox token whose tenant the node has not cached while the meta store is down. ## POST /v1/sandboxes/{id}/promote @@ -814,7 +817,7 @@ run out (see [evacuating a node](cluster.md#evacuating-a-node)). It needs background, with bounded concurrency, each claim is hibernated if it runs, archived to the checkpoint store if it is not archived, its registry row is released, and a peer is asked to adopt it through `GET /v1/sandboxes/{id}/owner`, -so the root listing on that peer shows it with its token. Open streams to a +so that peer lists it, and root's `GET /v1/sandboxes/{id}` there returns its token. Open streams to a claim end when it hibernates. Until a peer holds it, a call that would wake the claim answers 503 `sandbox moving to another node; retry` with `Retry-After`; after that this node answers 404 and the client looks the claim up again. With @@ -826,7 +829,8 @@ whose step failed, and every claim of a node without `archive_failover`. The answer and `GET /v1/info` count the claims left in `evacuating`; once it is gone, `claimed` counts the ones that stayed, and the log names each and why. A claim of a pool without an archive tier, whose `on_expire` is not `archive`, -keeps its lease deadline across the move; any other keeps the later of its +keeps its lease deadline across the move until its first wake, which starts a +fresh lease of the claim's length; any other keeps the later of its lease and `archive_delete_after_seconds`, or no deadline when that is 0. A call while an evacuation runs changes nothing; a call after one finished starts a new one over the claims still here. @@ -855,7 +859,7 @@ has no `preview_listen`. The signed token embeds the sandbox id, port, and owner `advertise_addr`, so any node's preview listener can serve it (forwarding to the owner's main listener) and a released sandbox's URL simply stops resolving — no revocation list. A browser request to the minted URL answers 503 -`lease lost; retry` for a replicated claim while its node's lease is overdue, and 429 when it has to wake a sandbox while the disk reserve is low or the node is at `max_running`. See [deploy](deploy.md#preview-urls). +`lease lost; retry` for a replicated claim while its node's lease is overdue, 503 `sandbox moving to another node; retry` while an [evacuation](#post-v1drain) has yet to move it, and 429 when it has to wake a sandbox while the disk reserve is low or the node is at `max_running`. See [deploy](deploy.md#preview-urls). ## POST /v1/sandboxes/{id}/checkpoint From ac73905350a3abc50467ca1b650e492a6cb214ca Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 4 Nov 2026 08:28:26 +0800 Subject: [PATCH 70/70] sdk: Close follows a sandbox that moved off a retired node Close looked the sandbox up only on a 404 from its old owner. Once that node is retired, the first release fails to connect, so the claim stayed on the adopter until its deadline, and every retry hit the same dead address. A connection failure now looks it up too, and keeps its error when no node answers. --- docs/cluster.md | 8 +++-- docs/sdk-python.md | 7 ++-- docs/sdk.md | 4 +-- sdk/go/client_test.go | 47 +++++++++++++++++++++++++++ sdk/go/sandbox.go | 9 ++--- sdk/python/cocoonsandbox/sandbox.py | 10 +++++- sdk/python/tests/test_fault_matrix.py | 30 +++++++++++++++++ 7 files changed, 103 insertions(+), 12 deletions(-) diff --git a/docs/cluster.md b/docs/cluster.md index bd006d80d..aab2b16ad 100644 --- a/docs/cluster.md +++ b/docs/cluster.md @@ -300,10 +300,12 @@ An archive the sweeps already run for a claim is waited out and the claim retried every 5 seconds. Claims that cannot move are skipped and keep serving here; once `evacuating` is gone `claimed` counts them, and the log names each one with its reason. A client that holds a handle gets 404 from this node after -the move, the same answer as for a released claim, and only a `Lookup` tells -the two apart: the SDKs' `Close` does this itself. A reader-served connection +the move, the same answer as for a released claim, or a connection error once +the node is retired, and only a `Lookup` tells these apart: the SDKs' `Close` +does this itself. A reader-served connection gets the retry as an error frame, not a 503, and a preview URL minted before -the move stops working until it is minted again. `DELETE /v1/drain` stops the evacuation: claims not yet started +the move stops working until it is minted again, except behind a shared +`preview_advertise`, where it answers when it reaches the new owner. `DELETE /v1/drain` stops the evacuation: claims not yet started stay as they are, and one mid-archive finishes its archive but keeps its row. ### Node lease diff --git a/docs/sdk-python.md b/docs/sdk-python.md index 1e6835234..2ecd8cb60 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -121,8 +121,11 @@ exposes `sb.id`, `sb.token`, `sb.owner`, `sb.deadline`, and `sb.from_checkpoint` (the lineage edge when branched). `sb.template_digest` is the exact content identity when the claim cloned a promoted template; it is empty for other sources. `Sandbox` is a -context manager; `sb.close()` releases it (releasing one already gone is not -an error — double-release and reap races stay silent). +context manager; `sb.close()` releases it (it follows a sandbox that moved to +another node with a lookup, also when that node is gone; releasing one already +gone is not an error — double-release and reap races stay silent). Any other +call on a handle raises a 404 `APIError` once its sandbox has moved; run +`client.lookup` to tell that from a released sandbox. Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane sandboxes cannot either; each answers 409. Checkpoint branches do not accept volumes of either mode in this version. diff --git a/docs/sdk.md b/docs/sdk.md index 850ed2af6..e33a6aff9 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -228,8 +228,8 @@ are exported. promoted template; it is empty for other sources. `Owner()` names the owning node, and `Token()` returns the per-sandbox bearer to persist with `ID` for a later `Lookup`; `Close()` releases the sandbox (it follows a sandbox that -moved to another node with a `Lookup`, releasing one already gone is not an -error, and `Close` is bounded internally so it stays defer-friendly). Any other +moved to another node with a `Lookup`, also when that node is gone, releasing +one already gone is not an error, and `Close` is bounded internally so it stays defer-friendly). Any other call on a handle answers 404 once its sandbox has moved; run `Lookup` to tell that from a released sandbox. Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index c836fd02b..9e07cde42 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -4,6 +4,7 @@ import ( "encoding/json/v2" "errors" "io" + "net" "net/http" "net/http/httptest" "reflect" @@ -159,6 +160,41 @@ func TestCloseReleasesASandboxThatMoved(t *testing.T) { } } +func TestCloseFollowsAMoveOffARetiredNode(t *testing.T) { + var released atomic.Bool + var adopter *httptest.Server + adopter = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/sandboxes/sb_9/owner": + _ = json.MarshalWrite(w, map[string]string{"owner_addr": strings.TrimPrefix(adopter.URL, "http://")}) + case "/v1/sandboxes/sb_9/release": + released.Store(true) + w.WriteHeader(http.StatusNoContent) + } + })) + t.Cleanup(adopter.Close) + + sb := &Sandbox{ID: "sb_9", c: testClient(t, adopter), token: "tok9", owner: retiredAddr(t)} + if err := sb.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !released.Load() { + t.Error("Close did not follow the move off a retired node") + } +} + +func TestCloseReportsARetiredNodeWhenNoNodeHoldsTheSandbox(t *testing.T) { + entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(entry.Close) + + sb := &Sandbox{ID: "sb_9", c: testClient(t, entry), token: "tok9", owner: retiredAddr(t)} + if err := sb.Close(); err == nil { + t.Error("Close hid the transport error with no owner found") + } +} + func TestNewFollowsRedirect(t *testing.T) { var nodeB *httptest.Server nodeB = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -564,6 +600,17 @@ func TestAPIErrorDrainsOversizedBody(t *testing.T) { } } +func retiredAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + _ = l.Close() + return addr +} + func testClient(t testing.TB, ts *httptest.Server, opts ...ClientOption) *Client { t.Helper() c, err := Connect(strings.TrimPrefix(ts.URL, "http://"), opts...) diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go index 17bd29e7d..181e4e687 100644 --- a/sdk/go/sandbox.go +++ b/sdk/go/sandbox.go @@ -3,6 +3,7 @@ package sandbox import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -225,12 +226,12 @@ func (s *Sandbox) Close() error { ctx, cancel := context.WithTimeout(context.Background(), releaseTimeout) defer cancel() found, err := s.releaseAt(ctx, s.owner) - if err != nil || found { + if _, refused := errors.AsType[*APIError](err); refused || found { return err } - moved, err := s.c.Lookup(ctx, s.ID, s.token) - if err != nil { - return nil + moved, lookupErr := s.c.Lookup(ctx, s.ID, s.token) + if lookupErr != nil { + return err } _, err = s.releaseAt(ctx, moved.owner) return err diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 82933f8a5..ec6c928dd 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -413,10 +413,18 @@ def close(self) -> None: self._release_at(self.owner) return except APIError as exc: - if exc.status != 404: + if exc.status not in (0, 404): raise + unreachable = exc if exc.status == 0 else None try: moved = self._client.lookup(self.id, self.token) + except APIError as exc: + if unreachable is not None: + raise unreachable from None + if exc.status != 404: + raise + return + try: self._release_at(moved.owner) except APIError as exc: if exc.status != 404: diff --git a/sdk/python/tests/test_fault_matrix.py b/sdk/python/tests/test_fault_matrix.py index cb1875614..0825e36ba 100644 --- a/sdk/python/tests/test_fault_matrix.py +++ b/sdk/python/tests/test_fault_matrix.py @@ -221,6 +221,36 @@ def release(body, path): assert released +def test_close_follows_a_move_off_a_retired_node(spawn_node, dead_addr): + released = [] + + def release(body, path): + released.append(path) + return (204, {}) + + adopter = spawn_node( + { + ("GET", "/v1/sandboxes/sb_1/owner"): lambda body, path: (200, {}), + ("POST", "/v1/sandboxes/sb_1/release"): release, + ("GET", "/v1/peers"): lambda body, path: (200, {"peers": []}), + } + ) + Client(adopter).attach(dead_addr, "sb_1", "tok").close() + assert released + + +def test_close_reports_a_retired_node_when_no_node_holds_the_sandbox(spawn_node, dead_addr): + entry = spawn_node( + { + ("GET", "/v1/sandboxes/sb_1/owner"): lambda body, path: (404, {"error": "not here"}), + ("GET", "/v1/peers"): lambda body, path: (200, {"peers": []}), + } + ) + with pytest.raises(APIError) as exc: + Client(entry).attach(dead_addr, "sb_1", "tok").close() + assert exc.value.status == 0, exc.value + + def test_close_of_a_sandbox_no_node_holds_is_not_an_error(spawn_node): gone = spawn_node( {