Skip to content

Commit 3f03315

Browse files
authored
Merge pull request #28 from AI45Lab/docs/native-snapshot-fork
release: v2.1.0 — native snapshot-fork, warm-pool CoW fill, prune
2 parents 71e2e40 + 8641799 commit 3f03315

20 files changed

Lines changed: 646 additions & 85 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ All notable changes to A3S Box will be documented in this file.
44

55
## [Unreleased]
66

7+
## [2.1.0] — 2026-06-13
8+
9+
### Added
10+
- **Native snapshot-fork (Copy-on-Write microVM cloning).** A booted template
11+
microVM can be snapshotted and many forks restored from it, instead of cold
12+
booting each one. The snapshot captures file-backed guest RAM plus KVM vCPU
13+
and virtio device state; each fork maps the RAM file `MAP_PRIVATE` so it pays
14+
only for the pages it dirties. Driven by `KRUN_SNAPSHOT_MEM_FILE` /
15+
`KRUN_SNAPSHOT_SOCK` (capture) and `KRUN_RESTORE_FROM` (restore), or
16+
per-VM via `BoxConfig`/`InstanceSpec`. Verified on `/dev/kvm`: a single fork
17+
is ~4× faster than a cold boot (~450 ms → ~110 ms), 100 forks complete in
18+
under ~1 s (~8 ms amortized per VM, ~13 MB RSS each), and `exec` runs real
19+
commands over virtio-fs inside the restored guest.
20+
- **Warm pool snapshot-fork fill** (`pool start --snapshot-fork`): the pool
21+
cold-boots one template, snapshots it, then restores the rest of the pool
22+
from that snapshot. Combined with concurrent (JoinSet) fill this cuts
23+
fill-to-8 from ~12.4 s to ~1.9 s. Off by default; opt in with the flag.
24+
- **`prune` command** (`a3s-box prune`, alias `container-prune`): removes every
25+
created, stopped, and dead box in one call, mirroring `docker container
26+
prune`. Running and paused boxes are never touched. Requires `--force`.
27+
- **Per-VM snapshot/restore config seam**: `BoxConfig` and `InstanceSpec` carry
28+
`snapshot_mem_file`, `snapshot_sock`, and `restore_from`, so snapshot/restore
29+
can be requested per box instead of only through process-global env vars
30+
(per-VM config takes precedence over the env).
31+
32+
### Fixed
33+
- **Concurrent box registration is now atomic.** `run` registered boxes by
34+
loading the full state, mutating, and saving under lock; concurrent launches
35+
could lose updates and the reconcile pass was O(N²). Registration is now an
36+
atomic, reconcile-free append, and the later rollback paths un-register
37+
correctly. Verified by launching 100 boxes concurrently with zero lost
38+
records.
39+
- **`pool status` no longer errors when no pool daemon is running** — it exits
40+
successfully and reports that nothing is running, matching Docker-style UX.
41+
- **Restore readiness is faster and OCI-free.** A restored fork skips the OCI
42+
pull (the template's cached rootfs is reused) and uses a short crash-detection
43+
grace (250 ms fixed → 40 ms) tuned for the restore path.
44+
745
## [2.0.7] — 2026-06-06
846

947
### Added

README.md

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ A3S Box is built toward production use, but it is not a full Docker, containerd,
2020
| OCI images | Pull, load, save, tag, inspect, history, remove, and local cache resolution are implemented. Push and cosign signing/verification paths exist and require registry access for end-to-end validation. |
2121
| Dockerfile build | Honest subset. `FROM`, metadata instructions, `COPY`/`ADD`, and shell-form `RUN` are implemented. `RUN` is isolated with Linux `chroot` and requires root-capable Linux; macOS fails by default unless explicitly unsafe host execution is enabled. |
2222
| Lifecycle and exec | `run`, `create`, `start`, `stop`, `restart`, `rm`, `wait`, foreground/detached runs, non-PTY exec, PTY exec, logs, stats, and inspect are implemented. |
23+
| Warm pool and snapshot-fork | A warm pool serves pre-booted sandboxes over a socket. Native snapshot-fork (Copy-on-Write microVM cloning) snapshots one booted template and restores many forks from it, each mapping the template RAM `MAP_PRIVATE`. Verified on `/dev/kvm`: ~4× faster than a cold boot per fork, 100 forks in under ~1 s (~8 ms amortized each). Requires `/dev/kvm`; opt in with `pool start --snapshot-fork` or the `KRUN_SNAPSHOT_*` / `KRUN_RESTORE_FROM` env. |
2324
| Networking | Default TSI networking, TCP `host:guest` publishing, user-defined bridge networks, network inspect/connect/disconnect/rm, and `/etc/hosts` peer discovery are implemented with documented platform boundaries. |
2425
| Compose | A useful local subset is implemented: image, command, entrypoint, env, env_file, ports, volumes, depends_on, networks, DNS, tmpfs, workdir, hostname, extra_hosts, labels, healthcheck, restart, CPU/memory, capabilities, and privileged mode. |
2526
| TEE | AMD SEV-SNP-oriented attestation, RA-TLS, sealing, and secret injection flows exist, plus simulation mode for development. Hardware-backed operation depends on SEV-SNP-capable hosts and libkrun support. TDX is not a productized path. |
@@ -48,7 +49,7 @@ The ignored `core_smoke` suite covers the core CLI path on a real MicroVM host:
4849
- TCP published ports with host loopback HTTP reachability;
4950
- bridge network endpoint allocation, peer `/etc/hosts`, connect/disconnect, and force removal cleanup;
5051
- named volumes, `cp`, `diff`, `export`, `commit`, `snapshot`, restart-policy monitor recovery, and Compose health/volume flow;
51-
- warm pool (`pool start`/`pool run`): pre-warmed sandboxes served over a socket, with backpressure and multi-image lazy pools; `--deferred` runs each command as the box's real main for full box semantics (real exit code + json-file console logs) with no cold boot.
52+
- warm pool (`pool start`/`pool run`): pre-warmed sandboxes served over a socket, with backpressure and multi-image lazy pools; `--deferred` runs each command as the box's real main for full box semantics (real exit code + json-file console logs) with no cold boot; `--snapshot-fork` fills the pool by Copy-on-Write restore from one booted template instead of cold booting each sandbox.
5253

5354
The most recent local record in this branch: all 14 ignored `core_smoke` tests
5455
passed on macOS HVF with an offline Alpine OCI archive, and the ignored
@@ -96,11 +97,11 @@ a3s-box rm web
9697

9798
## Command surface
9899

99-
A3S Box exposes 55 top-level commands. They are Docker-like, not Docker-identical.
100+
A3S Box exposes 56 top-level commands. They are Docker-like, not Docker-identical.
100101

101102
| Category | Commands |
102103
| --- | --- |
103-
| Lifecycle | `run`, `create`, `start`, `stop`, `restart`, `rm`, `kill`, `pause`, `unpause`, `wait`, `rename` |
104+
| Lifecycle | `run`, `create`, `start`, `stop`, `restart`, `rm`, `kill`, `pause`, `unpause`, `wait`, `rename`, `prune` |
104105
| Execution | `exec`, `attach`, `top`, `shell` |
105106
| Images | `pull`, `push`, `build`, `images`, `rmi`, `tag`, `image-inspect`, `history`, `image-prune`, `save`, `load`, `commit` |
106107
| Filesystem | `cp`, `export`, `diff` |
@@ -193,7 +194,49 @@ a3s-box snapshot create app checkpoint-1
193194
a3s-box snapshot restore checkpoint-1 --name restored-app
194195
```
195196

196-
Snapshots are configuration/filesystem-oriented Box snapshots, not a live RAM checkpoint facility.
197+
The `snapshot` command produces configuration/filesystem-oriented Box snapshots, not a live RAM checkpoint. The live RAM Copy-on-Write facility is a separate, lower-level mechanism described in [Warm pool and snapshot-fork](#warm-pool-and-snapshot-fork).
198+
199+
## Warm pool and snapshot-fork
200+
201+
A **warm pool** keeps a set of sandboxes pre-booted and serves them over a Unix
202+
socket, so a request is answered by an already-running microVM instead of a cold
203+
boot. It supports backpressure, multi-image lazy pools, and a `--deferred` mode
204+
that runs each request as the box's real main process (real exit code +
205+
json-file console logs).
206+
207+
```bash
208+
a3s-box pool start --image alpine:latest --size 8 # pre-warm 8 sandboxes
209+
a3s-box pool start --image alpine:latest --size 8 --snapshot-fork # CoW fill
210+
a3s-box pool run alpine:latest -- echo hi # served from the pool
211+
a3s-box pool status
212+
a3s-box pool stop
213+
```
214+
215+
**Snapshot-fork** (`--snapshot-fork`, Linux `/dev/kvm` only) is native
216+
Copy-on-Write microVM cloning. The pool cold-boots one template sandbox,
217+
snapshots its file-backed guest RAM together with KVM vCPU and virtio device
218+
state, and then restores the rest of the pool from that snapshot. Each fork maps
219+
the template RAM `MAP_PRIVATE`, so it pays only for the pages it dirties. On a
220+
`/dev/kvm` host this is ~4× faster than a cold boot per fork, completes 100
221+
forks in under ~1 s (~8 ms amortized each, ~13 MB RSS per fork), and `exec`
222+
runs real commands over virtio-fs inside the restored guest. It is off by
223+
default.
224+
225+
The same mechanism is available below the pool through environment variables:
226+
`KRUN_SNAPSHOT_MEM_FILE` and `KRUN_SNAPSHOT_SOCK` capture a snapshot from a
227+
booted template, and `KRUN_RESTORE_FROM` restores a fork from it. Per-VM
228+
`BoxConfig`/`InstanceSpec` fields (`snapshot_mem_file`, `snapshot_sock`,
229+
`restore_from`) take precedence over the env when set.
230+
231+
## Pruning stopped boxes
232+
233+
```bash
234+
a3s-box prune --force # remove every created/stopped/dead box
235+
a3s-box container-prune --force # alias
236+
```
237+
238+
`prune` is the box-only counterpart to `system-prune` (which also removes images
239+
and networks). Running and paused boxes are never touched.
197240

198241
## Networking
199242

@@ -365,6 +408,9 @@ matrix, and CRI smoke procedures.
365408
| `A3S_BOX_HOST_SMOKE_TIMEOUT_SECS` | Boot timeout override for ignored host smoke tests. |
366409
| `A3S_BOX_UNSAFE_HOST_RUN` | Opt into unsafe macOS host execution for Dockerfile `RUN` experiments. |
367410
| `A3S_BOX_BUILDCACHE_MAX_BYTES` | Cap on the total size of cached build layers at `~/.a3s/buildcache` (oldest evicted first). Default: 2 GiB. |
411+
| `KRUN_SNAPSHOT_MEM_FILE` | Path the booted template writes its file-backed guest RAM to when capturing a snapshot-fork template. |
412+
| `KRUN_SNAPSHOT_SOCK` | Control socket the template listens on for the `snapshot <path>` command (Linux `/dev/kvm` only). |
413+
| `KRUN_RESTORE_FROM` | Path to a snapshot the microVM restores from as a Copy-on-Write fork instead of cold booting. |
368414
| `RUST_LOG` | Rust tracing log level. |
369415

370416
## License

src/Cargo.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ resolver = "2"
2222
h2 = { path = "third_party/h2" }
2323

2424
[workspace.package]
25-
version = "2.0.7"
25+
version = "2.1.0"
2626
edition = "2021"
2727
authors = ["A3S Lab Team"]
2828
license = "MIT"

src/cli/src/commands/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pub(crate) mod network;
3434
mod pause;
3535
mod pool;
3636
mod port;
37+
mod prune;
3738
mod ps;
3839
mod pull;
3940
mod push;
@@ -175,6 +176,9 @@ pub enum Command {
175176
Volume(volume::VolumeArgs),
176177
/// Show disk usage
177178
Df(df::DfArgs),
179+
/// Remove all stopped boxes (Docker `container prune`)
180+
#[command(visible_alias = "container-prune")]
181+
Prune(prune::PruneArgs),
178182
/// Remove all unused data (stopped boxes and unused images)
179183
SystemPrune(system_prune::SystemPruneArgs),
180184
/// Show version information
@@ -337,6 +341,7 @@ pub async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
337341
Command::Network(args) => network::execute(args).await,
338342
Command::Volume(args) => volume::execute(args).await,
339343
Command::Df(args) => df::execute(args).await,
344+
Command::Prune(args) => prune::execute(args).await,
340345
Command::SystemPrune(args) => system_prune::execute(args).await,
341346
Command::Version(args) => version::execute(args).await,
342347
Command::Info(args) => info::execute(args).await,

src/cli/src/commands/pool.rs

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ pub struct PoolStartArgs {
8383
#[arg(long)]
8484
pub ksm: bool,
8585

86+
/// Fill the pool by snapshot-fork: boot one template VM, snapshot it, then
87+
/// restore every other slot (MAP_PRIVATE CoW) instead of cold-booting each.
88+
#[arg(long)]
89+
pub snapshot_fork: bool,
90+
8691
/// Output as JSON
8792
#[arg(long)]
8893
pub json: bool,
@@ -282,6 +287,8 @@ struct PoolRegistry {
282287
deferred: bool,
283288
/// Mark pooled VM memory KSM-mergeable (host page dedup across same-image VMs).
284289
ksm: bool,
290+
/// Fill the pool by snapshot-fork (one template, restore the rest).
291+
snapshot_fork: bool,
285292
}
286293

287294
impl PoolRegistry {
@@ -300,6 +307,7 @@ impl PoolRegistry {
300307
min_idle: size,
301308
max_size,
302309
idle_ttl_secs: self.ttl,
310+
snapshot_fork: self.snapshot_fork,
303311
..Default::default()
304312
};
305313
let box_config = BoxConfig {
@@ -379,6 +387,7 @@ async fn execute_start(args: PoolStartArgs) -> Result<(), Box<dyn std::error::Er
379387
ttl: args.ttl,
380388
deferred: args.deferred,
381389
ksm: args.ksm,
390+
snapshot_fork: args.snapshot_fork,
382391
});
383392

384393
// Pre-warm the default image, if one was given.
@@ -668,12 +677,19 @@ async fn execute_stop(_args: PoolStopArgs) -> Result<(), Box<dyn std::error::Err
668677
async fn execute_status(args: PoolStatusArgs) -> Result<(), Box<dyn std::error::Error>> {
669678
use tokio::net::UnixStream;
670679

671-
let mut stream = UnixStream::connect(&args.socket).await.map_err(|e| {
672-
format!(
673-
"Failed to connect to pool daemon at {} ({}). Is `a3s-box pool start` running?",
674-
args.socket, e
675-
)
676-
})?;
680+
// No daemon running is not an error for a status query — report "nothing" and
681+
// succeed, like `ps` with no boxes. (Only a connected daemon that misbehaves is.)
682+
let mut stream = match UnixStream::connect(&args.socket).await {
683+
Ok(stream) => stream,
684+
Err(_) => {
685+
if args.json {
686+
println!("[]");
687+
} else {
688+
println!("No pool daemon running (start one with `a3s-box pool start`).");
689+
}
690+
return Ok(());
691+
}
692+
};
677693

678694
write_frame(&mut stream, &serde_json::to_vec(&Request::Status)?).await?;
679695
let resp: StatusResponse = serde_json::from_slice(&read_frame(&mut stream).await?)?;
@@ -918,6 +934,7 @@ mod tests {
918934
warm: vec![],
919935
deferred: false,
920936
ksm: false,
937+
snapshot_fork: false,
921938
json: false,
922939
};
923940
let result = execute_start(args).await;
@@ -936,6 +953,7 @@ mod tests {
936953
warm: vec![],
937954
deferred: false,
938955
ksm: false,
956+
snapshot_fork: false,
939957
json: false,
940958
};
941959
let result = execute_start(args).await;
@@ -954,15 +972,15 @@ mod tests {
954972

955973
#[cfg(not(windows))]
956974
#[tokio::test]
957-
async fn test_execute_status_no_daemon_errors() {
958-
// With no daemon listening, status fails with a connect hint (not a panic).
975+
async fn test_execute_status_no_daemon_succeeds_empty() {
976+
// With no daemon listening, status reports "nothing running" and SUCCEEDS —
977+
// a status query shouldn't fail just because no pool is up (like `ps`).
959978
let result = execute_status(PoolStatusArgs {
960979
socket: "/tmp/a3s-box-pool-does-not-exist.sock".to_string(),
961980
json: false,
962981
})
963982
.await;
964-
assert!(result.is_err());
965-
assert!(result.unwrap_err().to_string().contains("pool daemon"));
983+
assert!(result.is_ok());
966984
}
967985

968986
#[test]

0 commit comments

Comments
 (0)