Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
atlas 2026-10-16 09:28:09 +02:00 • committed by mara
commit 8be90ae8f2

View file

@ -11,12 +11,41 @@
//! plugin specs resolve against current index data. Marketplace update //! plugin specs resolve against current index data. Marketplace update
//! failures are non-fatal — stale index is better than no install attempt. //! 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; use tokio::process::Command;
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json"; const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.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 <args>` 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<Output> {
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<Output> {
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` /// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of /// via `claude plugin marketplace add <source>`. Idempotent: re-add of
/// an existing marketplace is treated as success (claude prints an /// an existing marketplace is treated as success (claude prints an
@ -34,11 +63,7 @@ async fn add_marketplaces() {
} }
}; };
for source in sources { for source in sources {
match Command::new("claude") match run_claude_plugin(&["marketplace", "add", &source]).await {
.args(["plugin", "marketplace", "add", &source])
.output()
.await
{
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!(source = %source, "claude plugin marketplace add ok"); tracing::info!(source = %source, "claude plugin marketplace add ok");
} }
@ -56,7 +81,7 @@ async fn add_marketplaces() {
} }
} }
Err(e) => { 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 /// Update all configured plugin marketplaces. Non-fatal — logs a warning
/// on failure but does not abort the install sequence. /// on failure but does not abort the install sequence.
async fn update_marketplaces() { async fn update_marketplaces() {
match Command::new("claude") match run_claude_plugin(&["marketplace", "update"]).await {
.args(["plugin", "marketplace", "update"])
.output()
.await
{
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!("claude plugin marketplace update ok"); tracing::info!("claude plugin marketplace update ok");
} }
@ -90,7 +111,7 @@ async fn update_marketplaces() {
); );
} }
Err(e) => { 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"); tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
} }
for spec in specs { for spec in specs {
match Command::new("claude") match run_claude_plugin(&["install", &spec]).await {
.args(["plugin", "install", &spec])
.output()
.await
{
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!(spec = %spec, "claude plugin install ok"); tracing::info!(spec = %spec, "claude plugin install ok");
} }
@ -136,8 +153,30 @@ pub async fn install_configured() {
); );
} }
Err(e) => { 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());
}
}