From 8be90ae8f2aebe5fdda98667d53fbd3c1942f454 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 16 Oct 2026 09:28:09 +0200 Subject: [PATCH] hive-agent: bound each claude plugin call with a deadline The claude plugin marketplace add/update and install calls fetch over the network and are awaited before the web UI is spawned. One hung fetch stopped the agent from booting at all, against the module's own promise that plugin failures never block boot. Each call now runs under a 2 minute deadline; on expiry the child is killed, a warning is logged and boot continues, as for any other plugin failure. --- hive-agent/src/plugins.rs | 75 +++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/hive-agent/src/plugins.rs b/hive-agent/src/plugins.rs index 5a1a3b09..20e015c2 100644 --- a/hive-agent/src/plugins.rs +++ b/hive-agent/src/plugins.rs @@ -11,12 +11,41 @@ //! plugin specs resolve against current index data. Marketplace update //! failures are non-fatal — stale index is better than no install attempt. +use std::io; +use std::process::Output; +use std::time::Duration; + use tokio::process::Command; const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json"; const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json"; +/// Per-call deadline for `claude plugin …`; a git clone over a slow link fits, a hung fetch does not. +const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_mins(2); + +/// Run `claude plugin ` under [`PLUGIN_CALL_TIMEOUT`]. On timeout the +/// child is killed (`kill_on_drop`) and a `TimedOut` error is returned. +async fn run_claude_plugin(args: &[&str]) -> io::Result { + let mut cmd = Command::new("claude"); + cmd.arg("plugin").args(args); + run_bounded(cmd, PLUGIN_CALL_TIMEOUT).await +} + +/// Run `cmd` to completion within `deadline`; the child is killed when the +/// deadline passes. +async fn run_bounded(mut cmd: Command, deadline: Duration) -> io::Result { + let call = cmd.kill_on_drop(true).output(); + tokio::time::timeout(deadline, call) + .await + .unwrap_or_else(|_| { + Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("no exit within {}s", deadline.as_secs_f32()), + )) + }) +} + /// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json` /// via `claude plugin marketplace add `. Idempotent: re-add of /// an existing marketplace is treated as success (claude prints an @@ -34,11 +63,7 @@ async fn add_marketplaces() { } }; for source in sources { - match Command::new("claude") - .args(["plugin", "marketplace", "add", &source]) - .output() - .await - { + match run_claude_plugin(&["marketplace", "add", &source]).await { Ok(out) if out.status.success() => { tracing::info!(source = %source, "claude plugin marketplace add ok"); } @@ -56,7 +81,7 @@ async fn add_marketplaces() { } } Err(e) => { - tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed"); + tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed or timed out"); } } } @@ -74,11 +99,7 @@ async fn auto_update_enabled() -> bool { /// Update all configured plugin marketplaces. Non-fatal — logs a warning /// on failure but does not abort the install sequence. async fn update_marketplaces() { - match Command::new("claude") - .args(["plugin", "marketplace", "update"]) - .output() - .await - { + match run_claude_plugin(&["marketplace", "update"]).await { Ok(out) if out.status.success() => { tracing::info!("claude plugin marketplace update ok"); } @@ -90,7 +111,7 @@ async fn update_marketplaces() { ); } Err(e) => { - tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)"); + tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed or timed out (non-fatal)"); } } } @@ -118,11 +139,7 @@ pub async fn install_configured() { tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update"); } for spec in specs { - match Command::new("claude") - .args(["plugin", "install", &spec]) - .output() - .await - { + match run_claude_plugin(&["install", &spec]).await { Ok(out) if out.status.success() => { tracing::info!(spec = %spec, "claude plugin install ok"); } @@ -136,8 +153,30 @@ pub async fn install_configured() { ); } Err(e) => { - tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed"); + tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed or timed out"); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn run_bounded_times_out_a_hung_child() { + let mut cmd = Command::new("sleep"); + cmd.arg("30"); + let err = run_bounded(cmd, Duration::from_millis(100)) + .await + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::TimedOut); + } + + #[tokio::test] + async fn run_bounded_returns_output_of_a_prompt_child() { + let cmd = Command::new("true"); + let out = run_bounded(cmd, Duration::from_secs(5)).await; + assert!(out.unwrap().status.success()); + } +}