From e188d5fe798783d6d41d6d7804bc7579bc6aa28c Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Thu, 21 May 2026 13:52:45 +0800 Subject: [PATCH 1/2] docs: feature \`forkd doctor\` / \`from-image\` / \`bench\` in Quick start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3 CLI commands shipped 2026-05-21 (#134, #135, #136) collapse the new-user setup from 4 hand-pasted commands to a single \`forkd doctor\` + \`forkd from-image\` flow. Surface this prominently in Quick start so visitors landing from a Twitter / blog link see the modern story. - README.md: new \"Confirm your host is ready\" subsection leads with \`forkd doctor\`. New \"From a Docker image (one command)\" subsection shows \`forkd from-image python:3.12-slim --tag py-numpy\`. New \"Probe your install's latency\" subsection shows \`forkd bench\` with example output. - README-zh.md: parallel sections in Chinese. The original Hub-pull, from-source, and multi-child-fork-out sections are unchanged — those audiences still need them. New subsections come first since they're the most-likely user path. No code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README-zh.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 43 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/README-zh.md b/README-zh.md index 9cd9699..ffcbe25 100644 --- a/README-zh.md +++ b/README-zh.md @@ -312,6 +312,51 @@ N=100 实测 CoW 开销是 **每个 child 0.12 MiB**(详见 [bench/](./bench/)), 要求:x86_64 Linux,带 KVM,Ubuntu 22.04 或更新。 +### 一. 确认主机环境就绪 + +```bash +pip install forkd +sudo bash scripts/setup-host.sh # KVM + tap 设备,一次性 +sudo bash scripts/netns-setup.sh 3 # 每子 VM 的网络 namespace +forkd doctor # 一键检查上面这些是否到位 +``` + +`forkd doctor` 跑 10 项检查(KVM、tap、netns、firecracker 二进制、 +内核镜像、daemon...),不通过的项目附带修复提示。任何东西觉得不对 +就先跑它。 + +### 二. 从 Docker 镜像起步(一条命令) + +`forkd from-image` 把 Docker pull → ext4 → 启动 + 暖启动 → pause → +注册 tag 串成一条命令,输出是一个你可以立刻 fork 的 tag: + +```bash +sudo -E forkd from-image python:3.12-slim \ + --tag py-numpy \ + --extra python3-numpy +# 第一次 2-3 分钟(Docker pull + ext4 + warmup),之后走缓存。 + +sudo -E forkd fork --tag py-numpy -n 5 --per-child-netns +``` + +### 三. 探测你装的 forkd 实际有多快 + +```bash +forkd bench --tag py-numpy --n 5 +# forkd bench against snapshot py-numpy +# spawn (n=1) 61 ms +# exec round-trip 22 ms +# branch (diff=true) 287 ms pause_ms=234 ... +# fanout (n=5) 65 ms 13ms/child +# cleanup 136 ms +# ----- +# total 571 ms +``` + +screenshot 友好,跑一遍能知道 v0.3 在你机器上是不是真有那个速度。 + +### 四. 从源码构建你自己的暖启动父 VM + ```bash # 1. 主机准备:KVM、Firecracker、Rust、KSM、大页、tap 设备。 sudo bash scripts/setup-host.sh diff --git a/README.md b/README.md index ea0ef47..eb64b2e 100644 --- a/README.md +++ b/README.md @@ -319,13 +319,22 @@ Measured CoW overhead at N=100 is **0.12 MiB / child** on top of the parent ([be Requires: x86_64 Linux with KVM, Ubuntu 22.04 or newer. -### Fastest path — pull a pre-built snapshot from the Hub +### Confirm your host is ready ```bash pip install forkd sudo bash scripts/setup-host.sh # KVM + tap device, one-time sudo bash scripts/netns-setup.sh 3 # per-child network namespaces +forkd doctor # green-lights everything above +``` + +`forkd doctor` runs 10 checks (KVM, tap, netns, firecracker binary, +kernel image, daemon, ...) and emits fix hints for each failure. +Run this first whenever something feels off. + +### Fastest path — pull a pre-built snapshot from the Hub +```bash # 14.5 MiB pack (a Python 3.12 + LangGraph-ready snapshot) → 15s download. forkd pull deeplethe/langgraph-react @@ -336,6 +345,38 @@ sudo -E forkd fork --tag langgraph -n 3 --per-child-netns See [`docs/HUB.md`](./docs/HUB.md) for the registry model + how to publish your own snapshot pack. +### From a Docker image (one command) + +`forkd from-image` wraps Docker pull → ext4 → boot + warmup → pause → +register tag into a single verb. The output is a tag you can +immediately fork from: + +```bash +sudo -E forkd from-image python:3.12-slim \ + --tag py-numpy \ + --extra python3-numpy +# 2-3 min the first time (Docker pull + ext4 + warmup); cached after that. + +sudo -E forkd fork --tag py-numpy -n 5 --per-child-netns +``` + +### Probe your install's latency + +```bash +forkd bench --tag py-numpy --n 5 +# forkd bench against snapshot py-numpy +# spawn (n=1) 61 ms sb-...-0027 +# exec round-trip 22 ms exit=0 +# branch (diff=true) 287 ms pause_ms=234 diff_physical_bytes=393216 +# fanout (n=5) 65 ms 13ms/child +# cleanup 136 ms +# ----- +# total 571 ms +``` + +Run this against any snapshot to see how forkd actually performs on +your hardware. Screenshot-friendly output. + ### From-source path — build your own warmed parent ```bash From c9048fbc052a405c00942b102feb5a0a4f7d2731 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Thu, 21 May 2026 13:56:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20\`forkd=20ls\`=20+=20\`forkd?= =?UTF-8?q?=20kill\`=20=E2=80=94=20direct=20sandbox=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small subcommands that wrap GET /v1/sandboxes and DELETE /v1/sandboxes/:id so users don't have to hand-write curl. Same ergonomics as \`docker ps\` / \`docker rm\`. forkd ls ID SNAPSHOT PID NETNS GUEST_ADDR sb-6a0e8d4f-0023 coding-agent-fork-prewarm-v1 123456 forkd-child-1 10.42.0.2:8888 sb-6a0e8d50-0024 speculative-1234 123457 forkd-child-2 10.42.0.2:8888 ... 2 sandboxes forkd kill sb-6a0e8d4f-0023 ✓ sb-6a0e8d4f-0023 forkd kill --all forkd kill --tag speculative-1234 Implementation in crates/forkd-cli/src/sandbox.rs (~170 LOC), wired in main.rs as Cmd::Ls / Cmd::Kill. Reads FORKD_URL / FORKD_TOKEN from env like the other daemon-talking commands. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/forkd-cli/src/main.rs | 44 +++++++++ crates/forkd-cli/src/sandbox.rs | 164 ++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 crates/forkd-cli/src/sandbox.rs diff --git a/crates/forkd-cli/src/main.rs b/crates/forkd-cli/src/main.rs index 7b8e289..453c953 100644 --- a/crates/forkd-cli/src/main.rs +++ b/crates/forkd-cli/src/main.rs @@ -13,6 +13,7 @@ mod bench; mod doctor; mod hub; +mod sandbox; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; @@ -242,6 +243,38 @@ enum Cmd { #[arg(long)] mem_size_mib: Option, }, + /// List live sandboxes (GET /v1/sandboxes). Table output. + Ls { + /// Controller daemon base URL. + #[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")] + daemon_url: String, + /// Bearer token (matches the daemon's --token-file). + #[arg(long, env = "FORKD_TOKEN")] + daemon_token: Option, + }, + /// Kill one or more sandboxes (DELETE /v1/sandboxes/:id). + /// + /// Examples: + /// forkd kill sb-abc-0000 + /// forkd kill sb-abc-0000 sb-abc-0001 + /// forkd kill --all + /// forkd kill --tag pyagent + Kill { + /// Sandbox IDs to kill. Repeatable; ignored if --all or --tag is set. + ids: Vec, + /// Kill every live sandbox the daemon knows about. + #[arg(long, conflicts_with = "tag")] + all: bool, + /// Kill every sandbox forked from this snapshot tag. + #[arg(long, conflicts_with = "all")] + tag: Option, + /// Controller daemon base URL. + #[arg(long, env = "FORKD_URL", default_value = "http://127.0.0.1:8889")] + daemon_url: String, + /// Bearer token (matches the daemon's --token-file). + #[arg(long, env = "FORKD_TOKEN")] + daemon_token: Option, + }, /// Show where snapshots are stored. Where, /// Pack a local snapshot into a portable `.forkd-snapshot.tar.zst` file. @@ -610,6 +643,17 @@ fn main() -> Result<()> { boot_wait_secs, mem_size_mib, ), + Cmd::Ls { + daemon_url, + daemon_token, + } => sandbox::ls(&daemon_url, daemon_token), + Cmd::Kill { + ids, + all, + tag, + daemon_url, + daemon_token, + } => sandbox::kill(&daemon_url, daemon_token, ids, all, tag), Cmd::Where => { println!("{}", data_dir().display()); Ok(()) diff --git a/crates/forkd-cli/src/sandbox.rs b/crates/forkd-cli/src/sandbox.rs new file mode 100644 index 0000000..b434c77 --- /dev/null +++ b/crates/forkd-cli/src/sandbox.rs @@ -0,0 +1,164 @@ +//! `forkd ls` + `forkd kill` — direct sandbox lifecycle without curl. +//! +//! Wraps the two endpoints (GET /v1/sandboxes, DELETE /v1/sandboxes/:id) +//! that previously required hand-written curl invocations. Output is +//! a formatted table for `ls` and a per-id status line for `kill`. + +use anyhow::{Context, Result}; +use std::time::Duration; + +/// `forkd ls` — list live sandboxes the daemon knows about. +pub fn ls(daemon_url: &str, token: Option) -> Result<()> { + let sandboxes = list_sandboxes(daemon_url, token.as_deref())?; + if sandboxes.is_empty() { + eprintln!("no live sandboxes"); + return Ok(()); + } + // Column widths. + let id_w = sandboxes + .iter() + .filter_map(|s| s.get("id").and_then(|v| v.as_str())) + .map(str::len) + .max() + .unwrap_or(8) + .max(8); + let tag_w = sandboxes + .iter() + .filter_map(|s| s.get("snapshot_tag").and_then(|v| v.as_str())) + .map(str::len) + .max() + .unwrap_or(8) + .max(8); + println!( + " {:, + ids: Vec, + all: bool, + tag: Option, +) -> Result<()> { + let targets: Vec = if all || tag.is_some() { + let sandboxes = list_sandboxes(daemon_url, token.as_deref())?; + sandboxes + .iter() + .filter(|s| match &tag { + Some(t) => s + .get("snapshot_tag") + .and_then(|v| v.as_str()) + .map(|x| x == t) + .unwrap_or(false), + None => true, + }) + .filter_map(|s| s.get("id").and_then(|v| v.as_str()).map(String::from)) + .collect() + } else { + if ids.is_empty() { + anyhow::bail!("no sandbox specified; pass ... or --all or --tag "); + } + ids + }; + + if targets.is_empty() { + eprintln!("no matching sandboxes"); + return Ok(()); + } + + let mut errs = 0; + for id in &targets { + match delete_sandbox(daemon_url, token.as_deref(), id) { + Ok(()) => println!(" ✓ {id}"), + Err(e) => { + println!(" ✗ {id} ({e})"); + errs += 1; + } + } + } + if errs > 0 { + anyhow::bail!("{errs} of {} kills failed", targets.len()); + } + Ok(()) +} + +// ---------------------------------------------------------------------- +// HTTP helpers +// ---------------------------------------------------------------------- + +fn list_sandboxes(daemon_url: &str, token: Option<&str>) -> Result> { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(10)) + .build(); + let url = format!("{}/v1/sandboxes", daemon_url.trim_end_matches('/')); + let mut req = agent.get(&url); + if let Some(t) = token { + req = req.set("Authorization", &format!("Bearer {t}")); + } + let resp = req.call().map_err(map_err)?; + let body = resp.into_string().context("read body")?; + let v: serde_json::Value = + serde_json::from_str(&body).with_context(|| format!("parse JSON: {body}"))?; + Ok(v.as_array().cloned().unwrap_or_default()) +} + +fn delete_sandbox(daemon_url: &str, token: Option<&str>, id: &str) -> Result<()> { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(30)) + .build(); + let url = format!("{}/v1/sandboxes/{}", daemon_url.trim_end_matches('/'), id); + let mut req = agent.delete(&url); + if let Some(t) = token { + req = req.set("Authorization", &format!("Bearer {t}")); + } + req.call().map_err(map_err)?; + Ok(()) +} + +fn map_err(e: ureq::Error) -> anyhow::Error { + match e { + ureq::Error::Status(code, r) => { + let body = r.into_string().unwrap_or_default(); + anyhow::anyhow!("HTTP {code}: {body}") + } + e => anyhow::anyhow!("transport: {e}"), + } +}