mod support;
use std::fs;
use std::path::Path;
use std::time::Instant;
use support::{FakeAgent, World};
struct Sample {
batch: usize,
merged: i64,
post_secs: f64,
drain_secs: f64,
runs_per_sec: f64,
rss_kb: i64,
fd_count: usize,
children: usize,
zombies: usize,
db_kb: u64,
wal_kb: u64,
worktrees: usize,
run_dirs: usize,
state_kb: u64,
git_kb: u64,
}
#[test]
#[ignore = "long-running soak; run explicitly with --ignored --nocapture"]
fn lifecycle_churn_keeps_resource_curves_flat() {
let batches: usize = env_or("SOAK_BATCHES", 10);
let batch_size: usize = env_or("SOAK_BATCH_SIZE", 30);
let parallel: usize = env_or("SOAK_PARALLEL", 8);
let world = World::configured();
world.configure_fake_agent_with_parallelism(
FakeAgent::new().commit("soak work").exit(0),
parallel,
);
world.commit_all("initial");
let daemon = world.start_daemon();
let daemon_pid = daemon["data"]["pid"].as_u64().expect("daemon pid") as u32;
println!(
"soak: {batches} batches x {batch_size} runs, parallelism {parallel}, daemon pid {daemon_pid}"
);
println!(
"{:>5} {:>7} {:>8} {:>9} {:>7} {:>9} {:>5} {:>6} {:>7} {:>8} {:>8} {:>9} {:>8} {:>9} {:>9}",
"batch",
"merged",
"post_s",
"drain_s",
"run/s",
"rss_mb",
"fds",
"child",
"zombie",
"db_kb",
"wal_kb",
"worktree",
"run_dir",
"state_mb",
"git_mb",
);
let mut samples: Vec<Sample> = Vec::new();
let mut posted_total = 0usize;
for batch in 1..=batches {
let post_started = Instant::now();
for index in 0..batch_size {
let ticket = world.write_ticket(
&format!("soak-{batch:03}-{index:03}.md"),
"Churn one full run lifecycle.\n",
);
let output = world.sloop(&["post", ticket.to_str().expect("utf-8 ticket path")]);
if !output.status.success() {
let log = fs::read_to_string(world.daemon_log()).unwrap_or_default();
let tail: Vec<&str> = log.lines().rev().take(40).collect();
panic!(
"post failed in batch {batch}: {}\ndaemon log tail:\n{}",
String::from_utf8_lossy(&output.stderr),
tail.into_iter().rev().collect::<Vec<_>>().join("\n"),
);
}
}
posted_total += batch_size;
let post_secs = post_started.elapsed().as_secs_f64();
let drain_started = Instant::now();
let target = posted_total as i64;
wait_for(
&format!("batch {batch} to drain to {target} merged tickets"),
600,
|| {
let status = status(&world);
status["tickets"]["merged"].as_i64() == Some(target)
&& status["gate"]["active_agents"].as_i64() == Some(0)
},
);
let drain_secs = drain_started.elapsed().as_secs_f64();
let sample = Sample {
batch,
merged: target,
post_secs,
drain_secs,
runs_per_sec: batch_size as f64 / drain_secs,
rss_kb: proc_status_kb(daemon_pid, "VmRSS:"),
fd_count: fs::read_dir(format!("/proc/{daemon_pid}/fd"))
.map(|entries| entries.count())
.unwrap_or(0),
children: child_pids(daemon_pid).len(),
zombies: zombie_children(daemon_pid),
db_kb: file_kb(&world.db_path()),
wal_kb: file_kb(&world.db_path().with_extension("db-wal")),
worktrees: dir_entry_count(&world.root().join(".worktrees")),
run_dirs: dir_entry_count(&world.state_dir().join("runs")),
state_kb: dir_size_kb(&world.state_dir()),
git_kb: dir_size_kb(&world.root().join(".git")),
};
println!(
"{:>5} {:>7} {:>8.2} {:>9.2} {:>7.2} {:>9.1} {:>5} {:>6} {:>7} {:>8} {:>8} {:>9} {:>8} {:>9.1} {:>9.1}",
sample.batch,
sample.merged,
sample.post_secs,
sample.drain_secs,
sample.runs_per_sec,
sample.rss_kb as f64 / 1024.0,
sample.fd_count,
sample.children,
sample.zombies,
sample.db_kb,
sample.wal_kb,
sample.worktrees,
sample.run_dirs,
sample.state_kb as f64 / 1024.0,
sample.git_kb as f64 / 1024.0,
);
samples.push(sample);
world.tick(std::time::Duration::from_secs(8 * 24 * 60 * 60));
}
let first = samples.first().expect("at least one batch");
let last = samples.last().expect("at least one batch");
assert_eq!(last.zombies, 0, "zombie children remain after the soak");
assert_eq!(
last.children, 0,
"child processes remain after all runs settled"
);
assert!(
last.fd_count <= first.fd_count + 16,
"file descriptors grew from {} to {} across the soak",
first.fd_count,
last.fd_count
);
let status = status(&world);
assert_eq!(
status["tickets"]["merged"].as_i64(),
Some(posted_total as i64),
"daemon lost track of merged tickets"
);
println!(
"soak complete: {posted_total} runs; rss {:.1} -> {:.1} MB; fds {} -> {}; throughput {:.2} -> {:.2} run/s",
first.rss_kb as f64 / 1024.0,
last.rss_kb as f64 / 1024.0,
first.fd_count,
last.fd_count,
first.runs_per_sec,
last.runs_per_sec,
);
}
#[test]
#[ignore = "long-running scaling probe; run explicitly with --ignored --nocapture"]
fn post_latency_with_a_paused_daemon_stays_flat() {
let tickets: usize = env_or("SOAK_POSTS", 1000);
let window = 100;
let world = World::configured();
world.configure_fake_agent_with_parallelism(FakeAgent::new().exit(0), 1);
world.commit_all("initial");
world.start_daemon();
assert!(world.sloop(&["pause"]).status.success());
println!("{:>8} {:>10}", "tickets", "ms/post");
let mut window_started = Instant::now();
for index in 1..=tickets {
let ticket = world.write_ticket(
&format!("probe-{index:05}.md"),
"Measure post latency at scale.\n",
);
let output = world.sloop(&["post", ticket.to_str().expect("utf-8 ticket path")]);
assert!(
output.status.success(),
"post {index} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
if index % window == 0 {
let elapsed = window_started.elapsed().as_secs_f64();
println!("{:>8} {:>10.2}", index, elapsed * 1000.0 / window as f64);
window_started = Instant::now();
}
}
}
fn status(world: &World) -> serde_json::Value {
let output = world.sloop(&["status"]);
assert!(
output.status.success(),
"status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
World::json_stdout(&output)["data"].clone()
}
fn env_or(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
}
fn wait_for(what: &str, deadline_secs: u64, mut condition: impl FnMut() -> bool) {
let deadline = Instant::now() + std::time::Duration::from_secs(deadline_secs);
while Instant::now() < deadline {
if condition() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
panic!("timed out waiting for {what}");
}
fn proc_status_kb(pid: u32, field: &str) -> i64 {
let Ok(contents) = fs::read_to_string(format!("/proc/{pid}/status")) else {
return -1;
};
contents
.lines()
.find(|line| line.starts_with(field))
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse().ok())
.unwrap_or(-1)
}
fn child_pids(pid: u32) -> Vec<u32> {
let mut pids = Vec::new();
if let Ok(tasks) = fs::read_dir(format!("/proc/{pid}/task")) {
for task in tasks.flatten() {
if let Ok(children) = fs::read_to_string(task.path().join("children")) {
pids.extend(
children
.split_whitespace()
.filter_map(|child| child.parse::<u32>().ok()),
);
}
}
}
pids
}
fn zombie_children(pid: u32) -> usize {
child_pids(pid)
.into_iter()
.filter(|child| {
fs::read_to_string(format!("/proc/{child}/stat"))
.ok()
.and_then(|stat| {
stat.rsplit_once(") ")
.map(|(_, rest)| rest.starts_with('Z'))
})
.unwrap_or(false)
})
.count()
}
fn file_kb(path: &Path) -> u64 {
fs::metadata(path)
.map(|meta| meta.len() / 1024)
.unwrap_or(0)
}
fn dir_entry_count(path: &Path) -> usize {
fs::read_dir(path)
.map(|entries| entries.count())
.unwrap_or(0)
}
fn dir_size_kb(path: &Path) -> u64 {
fn walk(path: &Path) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| match entry.metadata() {
Ok(meta) if meta.is_dir() => walk(&entry.path()),
Ok(meta) => meta.len(),
Err(_) => 0,
})
.sum()
}
walk(path) / 1024
}