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()); + } +}