Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: bound the read-only container listing and systemctl status calls

priv_client::list_containers now waits at most 30s (LIST_WAIT) for
hive-priv's `nixos-container list`, via the existing `within` helper. Its
periodic callers (crash_watch, agent_sockets, swarm_agent_status, wanted,
mcp_sockets, swarm_status via container_view) already treat an Err as a
skipped cycle, so a hung listing no longer stops their loops for good.

lifecycle::is_running / unit_state run `systemctl is-active` with
kill_on_drop and a 10s timeout (IS_ACTIVE_TIMEOUT); a timeout warns and
reads as not-running / UnitState::Other, the same as a systemctl that
could not be spawned. The new lifecycle::running_state returns None for
that case, and crash_watch keeps the agent's previous observation on None
so a hung systemctl is neither reported as a crash nor as a recovery.

`call` itself and every mutating op (start/stop/destroy/send-snapshot/
daemon-reload) stay unbounded.

Closes #5061
This commit is contained in:
atlas 2026-10-09 15:30:32 +02:00
commit 3cb7be29cf
5 changed files with 106 additions and 19 deletions

View file

@ -372,8 +372,11 @@ swarm lifecycle notices instead (`swarm_notices::notify`). Variants
tombstone, three `POLL_INTERVAL`s — closes the race where a tombstone, three `POLL_INTERVAL`s — closes the race where a
lifecycle op finishes between two crash-watch polls and the lifecycle op finishes between two crash-watch polls and the
container shows briefly as "stopped without transient" before container shows briefly as "stopped without transient" before
the next start). The root agent escalates to the operator, who the next start). When `systemctl is-active` fails or runs past its
starts it again from the dashboard. 10s bound on a poll, the container keeps its previous state for that
poll, so an unreadable unit never counts as a crash.
The root agent escalates to the operator, who starts it again from
the dashboard.
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. The operator rebuilds it from the dashboard. stale. The operator rebuilds it from the dashboard.

View file

@ -616,34 +616,70 @@ impl UnitState {
pub async fn unit_state(name: &str) -> UnitState { pub async fn unit_state(name: &str) -> UnitState {
let container = container_name(name); let container = container_name(name);
let unit = format!("container@{container}.service"); let unit = format!("container@{container}.service");
match Command::new("systemctl") // Not "the unit is fine" and not "the unit failed" — we simply do not
.args(["is-active", &unit]) // know, and saying `Failed` here would report a gave-up agent every time
.output() // `systemctl` itself was unavailable or hung.
systemctl_is_active(&[&unit])
.await .await
{ .map_or(UnitState::Other, |out| {
Ok(out) => UnitState::from_is_active(&String::from_utf8_lossy(&out.stdout)), UnitState::from_is_active(&String::from_utf8_lossy(&out.stdout))
// Not "the unit is fine" and not "the unit failed" — we simply do })
// not know, and saying `Failed` here would report a gave-up agent
// every time `systemctl` itself was unavailable.
Err(_) => UnitState::Other,
}
} }
/// True when the container's systemd unit is active. Used by the dashboard /// True when the container's systemd unit is active. Used by the dashboard
/// to gate stop/restart buttons. /// to gate stop/restart buttons. A state that could not be read (see
/// [`running_state`]) is `false`.
/// ///
/// Kept boolean on purpose: nearly every caller is reconcile/power logic /// Kept boolean on purpose: nearly every caller is reconcile/power logic
/// asking "is it up? if not, start it", and that question has two answers. /// asking "is it up? if not, start it", and that question has two answers.
/// A caller that needs to tell *failed* from *stopped* wants /// A caller that needs to tell *failed* from *stopped* wants
/// [`unit_state`] instead. /// [`unit_state`] instead.
pub async fn is_running(name: &str) -> bool { pub async fn is_running(name: &str) -> bool {
running_state(name).await == Some(true)
}
/// Whether the container's systemd unit is active; `None` when `systemctl`
/// could not be run or did not answer within [`IS_ACTIVE_TIMEOUT`].
pub async fn running_state(name: &str) -> Option<bool> {
let container = container_name(name); let container = container_name(name);
let unit = format!("container@{container}.service"); let unit = format!("container@{container}.service");
Command::new("systemctl") systemctl_is_active(&["--quiet", &unit])
.args(["is-active", "--quiet", &unit])
.status()
.await .await
.is_ok_and(|s| s.success()) .map(|out| out.status.success())
}
/// How long one `systemctl is-active` may run before the unit's state counts
/// as unreadable. Periodic workers make this call per agent inline.
const IS_ACTIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run `systemctl is-active <args>`, killed after [`IS_ACTIVE_TIMEOUT`].
async fn systemctl_is_active(args: &[&str]) -> Option<std::process::Output> {
let output = Command::new("systemctl")
.arg("is-active")
.args(args)
.kill_on_drop(true)
.output();
output_within(IS_ACTIVE_TIMEOUT, output, args).await
}
/// Await a child's output for at most `limit`. `None` when it failed to run
/// or timed out; the timeout drops `fut`, which kills a child spawned with
/// `kill_on_drop(true)`.
async fn output_within<T>(
limit: std::time::Duration,
fut: impl std::future::Future<Output = std::io::Result<T>>,
args: &[&str],
) -> Option<T> {
if let Ok(result) = tokio::time::timeout(limit, fut).await {
result.ok()
} else {
tracing::warn!(
?args,
timeout_s = limit.as_secs(),
"systemctl is-active timed out"
);
None
}
} }
/// Fully tear down a sub-agent's container: stop + remove via `nixos-container /// Fully tear down a sub-agent's container: stop + remove via `nixos-container

View file

@ -449,3 +449,27 @@ fn is_actionable_nix_line_keeps_warnings_and_errors_demotes_chatter() {
assert!(!is_actionable_nix_line(line), "{line}"); assert!(!is_actionable_nix_line(line), "{line}");
} }
} }
#[tokio::test]
async fn a_hung_systemctl_reads_as_unknown_within_the_bound() {
let started = tokio::time::Instant::now();
let got = output_within(
std::time::Duration::from_millis(20),
std::future::pending::<std::io::Result<()>>(),
&["is-active"],
)
.await;
assert!(got.is_none());
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[tokio::test]
async fn a_systemctl_answer_inside_the_bound_passes_through() {
let got = output_within(
std::time::Duration::from_secs(5),
async { Ok(7_u8) },
&["is-active"],
)
.await;
assert_eq!(got, Some(7));
}

View file

@ -271,8 +271,16 @@ pub async fn destroy_container(name: &str) -> Result<()> {
.await?) .await?)
} }
/// How long hive-c0re waits for hive-priv's `nixos-container list`. Periodic
/// workers await the listing inline, so a hung one must reach them as an
/// error they skip a cycle on, not as a wait that stops their loop.
const LIST_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
/// # Errors
/// Returns an error if the hive-priv call fails or doesn't answer within
/// [`LIST_WAIT`], or `nixos-container list` exits non-zero.
pub async fn list_containers() -> Result<String> { pub async fn list_containers() -> Result<String> {
let (stdout, _) = check(call(&PrivRequest::ListContainers).await?)?; let (stdout, _) = check(within(LIST_WAIT, call(&PrivRequest::ListContainers)).await?)?;
Ok(stdout) Ok(stdout)
} }

View file

@ -45,7 +45,8 @@ pub fn spawn(coord: Arc<Coordinator>) {
}; };
let logical = logical.to_owned(); let logical = logical.to_owned();
sub_agents.push(logical.clone()); sub_agents.push(logical.clone());
if lifecycle::is_running(&logical).await { let state = lifecycle::running_state(&logical).await;
if counts_as_running(state, prev_running.contains(&logical)) {
current_running.insert(logical.clone()); current_running.insert(logical.clone());
} }
if hive_types::Ident::parse(&logical) if hive_types::Ident::parse(&logical)
@ -114,6 +115,12 @@ fn plan_cycle(list_result: anyhow::Result<Vec<String>>) -> CyclePlan {
} }
} }
/// An unreadable unit state (`None`) keeps the agent's last observed state,
/// so a hung or failed `systemctl` is neither a crash nor a recovery.
fn counts_as_running(state: Option<bool>, was_running: bool) -> bool {
state.unwrap_or(was_running)
}
fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current: &HashSet<String>) { fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet<String>, current: &HashSet<String>) {
let transients = coord.transient_snapshot(); let transients = coord.transient_snapshot();
// Operator actions whose RAII guard already cleared but only just; // Operator actions whose RAII guard already cleared but only just;
@ -234,6 +241,15 @@ mod tests {
} }
} }
#[test]
fn unreadable_unit_state_keeps_the_last_observation() {
assert!(counts_as_running(None, true), "no crash on a timeout");
assert!(!counts_as_running(None, false), "no recovery on a timeout");
// Control: a readable stop still drops out of the running set.
assert!(!counts_as_running(Some(false), true));
assert!(counts_as_running(Some(true), false));
}
#[test] #[test]
fn deliberate_when_either_source_says_so() { fn deliberate_when_either_source_says_so() {
// Active guard, and the race repro: a lifecycle action completes and // Active guard, and the race repro: a lifecycle action completes and