Skip to content

Commit d75b35e

Browse files
author
Roy Lin
committed
test(sdk): real-KVM integration + soak suites; add crash-orphan sweep + infra retry
Backs the parallel pipeline with real-microVM coverage and hardens it for sustained, highly-concurrent churn: - sweep_orphans(): reclaim ci-base-* boxes/snapshots left by a SIGKILL/OOM'd pipeline process (its RAII never ran), matched by the dead owner pid embedded in the resource name; never touches a live peer's resources. - WarmBase::infra_retries (default 2): retry a fork that hits a TRANSIENT infra failure (restore/start/boot); the step's command never ran, so re-forking is idempotent. Keeps sustained high-concurrency churn green. - tests/integration_kvm.rs (5 #[ignore] tests): warm+fork+exec, cache hit, parallel order/metrics, fork isolation, leak-freeness, sweep crash-recovery. - tests/soak_kvm.rs: sustained fork-eval churn stays leak-free and RSS-stable; leak gates are process-scoped (robust to a concurrent pipeline on the host). - ci.yml: run both under the integration-kvm (real /dev/kvm) gate. Validated on a real KVM host (a3s-box 2.5.1): integration 5/5; soak 1500 fork-evals across 75 generations leak-free, RSS +512 KiB; 0 orphans after.
1 parent f6b2000 commit d75b35e

5 files changed

Lines changed: 573 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,27 @@ jobs:
244244
unset A3S_DEPS_STUB
245245
chmod +x bench/bench.sh
246246
bench/bench.sh race
247+
# SDK programmable-CI pipeline against REAL microVMs: warm + CoW fork-per-step,
248+
# parallel collect-all, `::metric` extraction, fork isolation, leak-freeness,
249+
# and crash-orphan recovery (sweep_orphans). The unit suite only drives a fake
250+
# CLI, so this is the only gate that the real fork/snapshot path actually works.
251+
- name: SDK pipeline integration — real fork-per-step + sweep
252+
env:
253+
A3S_BOX: ${{ github.workspace }}/src/target/release/a3s-box
254+
A3S_SDK_TEST_IMAGE: ${{ vars.KVM_CI_AGENT_IMAGE || 'docker.m.daocloud.io/library/alpine:latest' }}
255+
run: |
256+
unset A3S_DEPS_STUB
257+
cd src
258+
cargo test --release -p a3s-box-sdk --test integration_kvm -- --ignored --nocapture --test-threads=1
259+
# Soak the fork-eval loop: sustained churn must stay leak-free (no orphan
260+
# boxes/snapshots, snapshot count flat) and memory-stable. Light here (120
261+
# fork-evals); crank A3S_SDK_SOAK_FORKS for a manual long soak.
262+
- name: SDK pipeline soak — leak-free under churn
263+
env:
264+
A3S_BOX: ${{ github.workspace }}/src/target/release/a3s-box
265+
A3S_SDK_TEST_IMAGE: ${{ vars.KVM_CI_AGENT_IMAGE || 'docker.m.daocloud.io/library/alpine:latest' }}
266+
A3S_SDK_SOAK_FORKS: "120"
267+
run: |
268+
unset A3S_DEPS_STUB
269+
cd src
270+
cargo test --release -p a3s-box-sdk --test soak_kvm -- --ignored --nocapture --test-threads=1

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,18 @@ All notable changes to A3S Box will be documented in this file.
1616
threads. The base auto-removes its snapshot on `Drop` (`--force`), and each fork is
1717
removed on every path (including panic). Box/snapshot names now carry per-process +
1818
per-instance entropy, so concurrent pipelines from the same image+setup can no longer
19-
collide and tear down each other's boxes. Validated end-to-end on a real `/dev/kvm` host.
19+
collide and tear down each other's boxes. A fork that hits a *transient*
20+
infrastructure failure (restore/start/boot) is retried — `WarmBase::infra_retries`,
21+
default 2 — since its command never ran, which keeps sustained high-concurrency
22+
churn green. Validated end-to-end on a real `/dev/kvm` host.
23+
- **Crash-orphan recovery + real-VM integration & soak tests.** `sweep_orphans()`
24+
reclaims `ci-base-*` boxes/snapshots left behind when a pipeline process is
25+
`SIGKILL`ed / OOM-killed (its RAII cleanup never runs), by matching the dead owner
26+
pid embedded in the resource name — and it never touches a live peer's resources.
27+
Added `#[ignore]`'d real-microVM integration tests (`tests/integration_kvm.rs`:
28+
warm + fork-per-step, cache, parallel order/metrics, fork isolation, leak-freeness,
29+
sweep) and a soak test (`tests/soak_kvm.rs`: sustained fork-eval churn stays
30+
leak-free and RSS-stable), both wired into the KVM CI gate.
2031

2132
### Changed
2233

src/sdk/src/pipeline.rs

Lines changed: 148 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,18 @@ use std::path::PathBuf;
5252
use std::process::{Command, Output};
5353
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
5454
use std::sync::Mutex;
55-
use std::time::Instant;
55+
use std::time::{Duration, Instant};
5656

5757
/// Default per-step cap on captured stdout/stderr — bounds in-memory [`Report`]
5858
/// size when fanning hundreds of forks out. Override via [`WarmBase::max_output`].
5959
const DEFAULT_MAX_OUTPUT: usize = 1 << 20; // 1 MiB
6060

61+
/// Default number of times to retry a fork that hits an *infrastructure* failure
62+
/// (restore/start/boot/exec-spawn). The step's command never ran, so re-forking is
63+
/// safe and idempotent; this absorbs the transient boot hiccups that surface under
64+
/// sustained, highly-concurrent churn. Override via [`WarmBase::infra_retries`].
65+
const DEFAULT_INFRA_RETRIES: usize = 2;
66+
6167
/// `exec_step` returns this exit code when the step never ran because of an
6268
/// *infrastructure* failure (restore/start/exec), distinct from any real exit
6369
/// code (a host process killed by a signal surfaces as -1, a guest `exit -1` as
@@ -319,6 +325,7 @@ pub struct WarmBase<'a> {
319325
env: BTreeMap<String, String>,
320326
cache: Option<&'a FileCache>,
321327
max_output: usize,
328+
infra_retries: usize,
322329
}
323330

324331
impl<'a> WarmBase<'a> {
@@ -329,6 +336,7 @@ impl<'a> WarmBase<'a> {
329336
env: BTreeMap::new(),
330337
cache: None,
331338
max_output: DEFAULT_MAX_OUTPUT,
339+
infra_retries: DEFAULT_INFRA_RETRIES,
332340
}
333341
}
334342
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
@@ -344,6 +352,12 @@ impl<'a> WarmBase<'a> {
344352
self.max_output = bytes;
345353
self
346354
}
355+
/// Retries for a fork that hits an infrastructure failure (default 2). The
356+
/// step's command never ran on such a failure, so re-forking is idempotent.
357+
pub fn infra_retries(mut self, n: usize) -> Self {
358+
self.infra_retries = n;
359+
self
360+
}
347361
}
348362

349363
/// A pipeline step: a command run in its own kernel forked from the base.
@@ -468,6 +482,7 @@ pub struct Base<'a> {
468482
snapshot_id: String,
469483
cache: Option<&'a FileCache>,
470484
max_output: usize,
485+
infra_retries: usize,
471486
n: AtomicU32,
472487
disposed: AtomicBool,
473488
}
@@ -543,10 +558,29 @@ impl Base<'_> {
543558
})
544559
}
545560

561+
/// `exec_step` with bounded retry on *infrastructure* failure (the step's
562+
/// command never ran, so re-forking is idempotent). A non-zero step exit is
563+
/// returned as `Ok` and never retried — only a failed fork is.
564+
fn exec_step_retrying(&self, step: &Step) -> Result<StepResult> {
565+
let mut last: Option<PipelineError> = None;
566+
for attempt in 0..=self.infra_retries {
567+
match self.exec_step(step) {
568+
Ok(r) => return Ok(r),
569+
Err(e) => {
570+
last = Some(e);
571+
if attempt < self.infra_retries {
572+
std::thread::sleep(Duration::from_millis(150 * (attempt as u64 + 1)));
573+
}
574+
}
575+
}
576+
}
577+
Err(last.expect("loop runs at least once"))
578+
}
579+
546580
/// Run one step, **fail-fast**: a non-zero exit returns `Err(StepFailed)`
547581
/// unless `Step::allow_failure` was set. Use for a sequential pipeline.
548582
pub fn step(&self, step: Step) -> Result<StepResult> {
549-
let r = self.exec_step(&step)?;
583+
let r = self.exec_step_retrying(&step)?;
550584
if r.exit_code != 0 && !step.allow_failure {
551585
return Err(PipelineError::StepFailed {
552586
name: r.name.clone(),
@@ -561,7 +595,7 @@ impl Base<'_> {
561595
/// `StepResult{ exit_code: `[`INFRA_FAILURE`]`, stderr: <error> }` so one bad
562596
/// fork can't abort the batch.
563597
fn run_one(&self, step: Step) -> StepResult {
564-
match self.exec_step(&step) {
598+
match self.exec_step_retrying(&step) {
565599
Ok(r) => r,
566600
Err(e) => StepResult {
567601
name: step.name.clone(),
@@ -701,11 +735,75 @@ pub fn warm_base(spec: WarmBase<'_>) -> Result<Base<'_>> {
701735
snapshot_id: prepared?,
702736
cache: spec.cache,
703737
max_output: spec.max_output,
738+
infra_retries: spec.infra_retries,
704739
n: AtomicU32::new(0),
705740
disposed: AtomicBool::new(false),
706741
})
707742
}
708743

744+
/// The owner pid embedded in a `ci-base-<key>-<pid>-<seq>...` resource name.
745+
fn parse_owner_pid(name: &str) -> Option<u32> {
746+
let rest = name.strip_prefix("ci-base-")?;
747+
let mut it = rest.split('-');
748+
it.next()?; // key hash
749+
it.next()?.parse().ok()
750+
}
751+
752+
/// `Some(true/false)` where pid-liveness is knowable (Linux `/proc`); `None`
753+
/// otherwise — so a sweep never reclaims resources whose owner it can't confirm dead.
754+
fn pid_alive(pid: u32) -> Option<bool> {
755+
if !std::path::Path::new("/proc").is_dir() {
756+
return None;
757+
}
758+
Some(std::path::Path::new(&format!("/proc/{pid}")).exists())
759+
}
760+
761+
/// If `name` is a ci-base resource owned by a confirmed-DEAD pid other than `me`,
762+
/// return that pid (it is an orphan safe to reclaim).
763+
fn orphan_pid(name: &str, me: u32) -> Option<u32> {
764+
let pid = parse_owner_pid(name)?;
765+
if pid == me {
766+
return None; // ours — never sweep a live self
767+
}
768+
match pid_alive(pid) {
769+
Some(false) => Some(pid), // confirmed dead
770+
_ => None, // alive or unknowable — leave it
771+
}
772+
}
773+
774+
/// Reclaim `ci-base-*` boxes and snapshots leaked by a **crashed** pipeline
775+
/// process. `Drop`/guards clean up graceful exits, but `SIGKILL`/OOM/power-loss
776+
/// skip them; resource names embed the owner pid, so a resource whose pid is no
777+
/// longer alive (and isn't this process) is removed. Returns the names reclaimed.
778+
/// Safe to call concurrently with live pipelines: it only touches dead-pid orphans.
779+
pub fn sweep_orphans() -> Vec<String> {
780+
let me = std::process::id();
781+
let mut removed = Vec::new();
782+
783+
if let Ok(out) = box_run(&["ps", "-a", "--format", "{{.Names}}"]) {
784+
for name in String::from_utf8_lossy(&out.stdout).lines() {
785+
let name = name.trim();
786+
if orphan_pid(name, me).is_some() {
787+
box_cleanup(&["rm", "-f", name]);
788+
removed.push(name.to_string());
789+
}
790+
}
791+
}
792+
if let Ok(out) = box_run(&["snapshot", "ls"]) {
793+
let text = String::from_utf8_lossy(&out.stdout);
794+
for line in text.lines() {
795+
let mut cols = line.split_whitespace();
796+
if let (Some(id), Some(name)) = (cols.next(), cols.next()) {
797+
if orphan_pid(name, me).is_some() {
798+
box_cleanup(&["snapshot", "rm", "--force", id]);
799+
removed.push(name.to_string());
800+
}
801+
}
802+
}
803+
}
804+
removed
805+
}
806+
709807
#[cfg(test)]
710808
mod tests {
711809
use super::*;
@@ -866,6 +964,31 @@ mod tests {
866964
);
867965
}
868966

967+
#[test]
968+
fn parse_owner_pid_and_orphan_detection() {
969+
assert_eq!(parse_owner_pid("ci-base-deadbeef-4242-0-snap"), Some(4242));
970+
assert_eq!(
971+
parse_owner_pid("ci-base-deadbeef-4242-7-snap-job3-make"),
972+
Some(4242)
973+
);
974+
assert_eq!(parse_owner_pid("other-box"), None);
975+
assert_eq!(parse_owner_pid("ci-base-onlykey"), None);
976+
// this process's own resources are never orphans, regardless of liveness.
977+
let me = std::process::id();
978+
let mine = format!("ci-base-deadbeef-{me}-0-snap");
979+
assert_eq!(orphan_pid(&mine, me), None);
980+
// a confirmed-dead pid is an orphan — but only where liveness is knowable
981+
// (Linux /proc); on hosts where it isn't, sweep must leave it untouched.
982+
if pid_alive(4_000_000_000).is_some() {
983+
assert_eq!(
984+
orphan_pid("ci-base-deadbeef-4000000000-0-snap", me),
985+
Some(4_000_000_000)
986+
);
987+
} else {
988+
assert_eq!(orphan_pid("ci-base-deadbeef-4000000000-0-snap", me), None);
989+
}
990+
}
991+
869992
#[test]
870993
fn ci_error_display_covers_each_variant() {
871994
let io = PipelineError::from(std::io::Error::other("boom"));
@@ -906,7 +1029,14 @@ case "$1" in
9061029
case "$2" in
9071030
create) name=""; while [ "$#" -gt 0 ]; do [ "$1" = "--name" ] && name="$2"; shift; done
9081031
printf '%s %s\n' "snap-fake1" "$name" >> "$state"; printf 'snap-fake1\n'; exit 0 ;;
909-
restore) for a in "$@"; do case "$a" in *infrafail*) exit 1 ;; esac; done; exit 0 ;;
1032+
restore)
1033+
for a in "$@"; do
1034+
case "$a" in
1035+
*infrafail*) exit 1 ;;
1036+
*flaky*) c="$(dirname "$0")/.flaky"; n=$(cat "$c" 2>/dev/null || echo 0); n=$((n+1)); echo "$n" > "$c"; [ "$n" -le 2 ] && exit 1 || exit 0 ;;
1037+
esac
1038+
done
1039+
exit 0 ;;
9101040
ls) printf 'ID NAME SRC\n'; cat "$state" 2>/dev/null; exit 0 ;;
9111041
*) exit 0 ;;
9121042
esac ;;
@@ -972,12 +1102,23 @@ esac
9721102
);
9731103
assert!(rep2.passed, "allow_failure step must not fail the batch");
9741104

975-
// an infra failure (restore refuses) surfaces as INFRA_FAILURE, not a panic.
1105+
// an infra failure (restore refuses) surfaces as INFRA_FAILURE, not a panic
1106+
// (it is retried infra_retries times first, then gives up).
9761107
let rep3 = base.run_parallel(vec![Step::new("infrafail", "make")], 1);
9771108
assert_eq!(rep3.steps[0].exit_code, INFRA_FAILURE);
9781109
assert!(!rep3.steps[0].stderr.is_empty());
9791110
assert!(!rep3.passed);
9801111

1112+
// a TRANSIENT infra failure is retried: "flaky" restore fails twice then
1113+
// succeeds, so with the default 2 retries (3 attempts) the step recovers.
1114+
let _ = std::fs::remove_file(dir.join(".flaky"));
1115+
let okr = base.run_parallel(vec![Step::new("flaky", "make")], 1);
1116+
assert_eq!(
1117+
okr.steps[0].exit_code, 0,
1118+
"transient infra failure should be retried to success"
1119+
);
1120+
assert!(okr.passed);
1121+
9811122
// empty batch is trivially passed.
9821123
assert!(base.run_parallel(Vec::new(), 4).passed);
9831124

@@ -1028,7 +1169,8 @@ esac
10281169
let _ = WarmBase::new("img", "setup")
10291170
.env("A", "1")
10301171
.cache(&c)
1031-
.max_output(4096);
1172+
.max_output(4096)
1173+
.infra_retries(1);
10321174
let _ = Step::new("n", "cmd")
10331175
.input("x")
10341176
.env("K", "V")

0 commit comments

Comments
 (0)