Merge forge/main into atlas/5034-s4-remove-hive-model
This commit is contained in:
commit
baefb32bd5
22 changed files with 921 additions and 248 deletions
|
|
@ -132,7 +132,37 @@ pub fn claude_dir() -> PathBuf {
|
|||
PathBuf::from("/root/.claude")
|
||||
}
|
||||
|
||||
/// Held by every test that points `HYPERHIVE_HARNESS_DIR` at a temp dir:
|
||||
/// cargo runs a crate's tests on parallel threads of one process.
|
||||
/// Run `f` with `HYPERHIVE_HARNESS_DIR` pointing at a fresh temp dir, then
|
||||
/// restore the previous value. Tests that build a `Bus` or read the harness
|
||||
/// state files go through this: the variable is process-wide, so the calls
|
||||
/// are serialised on a lock.
|
||||
#[cfg(test)]
|
||||
pub(crate) static HARNESS_DIR_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
pub fn with_harness_dir<T>(f: impl FnOnce() -> T) -> T {
|
||||
use std::ffi::OsString;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct Restore(Option<OsString>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: still holding `LOCK`, the only writer of this variable.
|
||||
unsafe {
|
||||
match self.0.take() {
|
||||
Some(v) => std::env::set_var("HYPERHIVE_HARNESS_DIR", v),
|
||||
None => std::env::remove_var("HYPERHIVE_HARNESS_DIR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _lock = LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Declared after `_lock` so the variable is restored before the lock drops.
|
||||
let _restore = Restore(std::env::var_os("HYPERHIVE_HARNESS_DIR"));
|
||||
// SAFETY: serialised by `LOCK`.
|
||||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
f()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,37 +320,30 @@ mod tests {
|
|||
/// persisted files, so clearing it, or a restart, finds the local value.
|
||||
#[test]
|
||||
fn the_swarm_value_is_not_persisted_over_the_local_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env = crate::paths::HARNESS_DIR_ENV
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
|
||||
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
|
||||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
let bus = Bus::new();
|
||||
std::fs::write(dir.path().join("hyperhive-model"), "sonnet\n").unwrap();
|
||||
std::fs::write(dir.path().join("hyperhive-effort"), "low\n").unwrap();
|
||||
let target = Harness {
|
||||
bus: bus.clone(),
|
||||
nix_model: None,
|
||||
nix_effort: None,
|
||||
};
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
|
||||
let swarm = (bus.model(), bus.effort());
|
||||
let swarm_sources = (bus.model_source(), bus.effort_source());
|
||||
let files = (
|
||||
crate::harness_state::load_model(),
|
||||
crate::harness_state::load_effort(),
|
||||
);
|
||||
o.apply(&AgentInference::default(), &target);
|
||||
let cleared = (bus.model(), bus.effort());
|
||||
let cleared_sources = (bus.model_source(), bus.effort_source());
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
|
||||
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
|
||||
}
|
||||
let (swarm, swarm_sources, files, cleared, cleared_sources) =
|
||||
crate::paths::with_harness_dir(|| {
|
||||
let bus = Bus::new();
|
||||
let dir = crate::paths::harness_dir();
|
||||
std::fs::write(dir.join("hyperhive-model"), "sonnet\n").unwrap();
|
||||
std::fs::write(dir.join("hyperhive-effort"), "low\n").unwrap();
|
||||
let target = Harness {
|
||||
bus: bus.clone(),
|
||||
nix_model: None,
|
||||
nix_effort: None,
|
||||
};
|
||||
let mut o = Overridden::default();
|
||||
o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
|
||||
let swarm = (bus.model(), bus.effort());
|
||||
let swarm_sources = (bus.model_source(), bus.effort_source());
|
||||
let files = (
|
||||
crate::harness_state::load_model(),
|
||||
crate::harness_state::load_effort(),
|
||||
);
|
||||
o.apply(&AgentInference::default(), &target);
|
||||
let cleared = (bus.model(), bus.effort());
|
||||
let cleared_sources = (bus.model_source(), bus.effort_source());
|
||||
(swarm, swarm_sources, files, cleared, cleared_sources)
|
||||
});
|
||||
assert_eq!(swarm, pair("opus", "max"));
|
||||
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
|
||||
assert_eq!(cleared, pair("sonnet", "low"));
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ const DEFAULT_SESSION_TITLE: &str = "hive-session";
|
|||
/// capacity limits.
|
||||
const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300;
|
||||
|
||||
/// Idle watchdog window: kill claude and surface `ApiStall` if it produces no
|
||||
/// stdout for this long. The timer resets on every stdout line, so a large but
|
||||
/// Idle watchdog window: send claude SIGINT and surface `ApiStall` once it
|
||||
/// exits, if it produces no stdout for this long. The timer resets on every stdout line, so a large but
|
||||
/// still-streaming turn is never cut — only a complete silence trips it.
|
||||
/// Default is 10 minutes: long enough for legitimate big-context turns, short
|
||||
/// enough to cut a multi-retry Anthropic connection storm (atlas telemetry:
|
||||
|
|
@ -155,7 +155,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
|
|||
/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`]
|
||||
/// the serve loop must act on. The `compacted` bool is `true` when a
|
||||
/// compaction ran this turn (reactively on overflow, or proactively per the
|
||||
/// policy — or an operator `/compact` at turn end); it's recorded as
|
||||
/// policy — or a successful operator `/compact` at turn end); it's recorded as
|
||||
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
||||
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
||||
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
||||
|
|
@ -189,14 +189,14 @@ pub enum TurnError {
|
|||
/// the wake message, the serve loop requeues it so the next turn retries;
|
||||
/// no status park.
|
||||
SessionNotFound,
|
||||
/// The harness-side idle watchdog killed claude after `turn_idle_secs()` of
|
||||
/// The harness-side idle watchdog stopped claude after `turn_idle_secs()` of
|
||||
/// output silence — indicative of an Anthropic API stall (e.g. a multi-retry
|
||||
/// connection storm burning minutes of wall-clock with no stream progress).
|
||||
/// The serve loop parks for `stall_sleep_secs()` and requeues, like the
|
||||
/// rate-limit path — NOT a crash.
|
||||
ApiStall,
|
||||
/// The ACP backend's idle watchdog stopped a turn that sent nothing for
|
||||
/// `turn_idle_secs()`: the agent was sent `session/cancel`, and killed if
|
||||
/// `turn_idle_secs()`: the agent was sent `session/cancel`, and SIGINT if
|
||||
/// it ignored that. Carries what happened. An ACP agent retrying a failing
|
||||
/// provider (e.g. HTTP 429) without reporting it ends up here. Handled
|
||||
/// like [`Self::ApiStall`].
|
||||
|
|
@ -461,7 +461,7 @@ pub async fn drive_turn(
|
|||
// Reflect `Compacting` in the UI like the idle path (`run_pending_compact`)
|
||||
// does; the serve loop resets to `Idle` once this turn returns.
|
||||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let _ = session.compact(&config, &sink).await;
|
||||
let compact_ok = report_compact(bus, session.compact(&config, &sink).await);
|
||||
// If the compact call asked to be woken (the agent's own `compact`
|
||||
// tool with a `wake_prompt`), stash it — the serve loop reads it
|
||||
// back after this turn returns and drives a synthetic follow-up
|
||||
|
|
@ -470,11 +470,38 @@ pub async fn drive_turn(
|
|||
if let Some(prompt) = request.wake_prompt {
|
||||
bus.set_post_compact_wake(prompt);
|
||||
}
|
||||
return Ok(true);
|
||||
return with_turn_end_compact(outcome, compact_ok);
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Emit the `/compact done` / `/compact failed: …` note for a finished
|
||||
/// operator or agent `/compact` and return whether it succeeded. Shared by the
|
||||
/// turn-end path in [`drive_turn`] and the idle path in [`run_pending_compact`].
|
||||
fn report_compact<E: std::fmt::Display>(bus: &Bus, result: Result<(), E>) -> bool {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: "/compact done".into(),
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
bus.emit(LiveEvent::Note {
|
||||
text: format!("/compact failed: {e}"),
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a turn-end `/compact` into the turn's own outcome. A compaction that
|
||||
/// ran during the turn (`Ok(true)`) stays recorded even when the turn-end
|
||||
/// `/compact` failed; a failed `/compact` never turns it into `Ok(false)`.
|
||||
fn with_turn_end_compact(outcome: TurnOutcome, compact_ok: bool) -> TurnOutcome {
|
||||
outcome.map(|turn_compacted| turn_compacted || compact_ok)
|
||||
}
|
||||
|
||||
/// Pre-turn auto-reset check. If context is large AND the prompt cache has
|
||||
/// gone cold (idle time >= cache TTL), archive the current session so the
|
||||
/// next wake-up turn's `--resume <title>` misses and self-heals into a fresh
|
||||
|
|
@ -558,11 +585,11 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) {
|
|||
bus.emit(LiveEvent::TurnEnd {
|
||||
ok: false,
|
||||
note: Some(format!(
|
||||
"claude killed after {}s of output silence — API stall suspected, parking + requeueing",
|
||||
"claude stopped with SIGINT after {}s of output silence — API stall suspected, parking + requeueing",
|
||||
turn_idle_secs()
|
||||
)),
|
||||
});
|
||||
tracing::warn!("turn killed: API stall (idle watchdog)");
|
||||
tracing::warn!("turn stopped: API stall (idle watchdog)");
|
||||
}
|
||||
Err(TurnError::AgentStall(note)) => {
|
||||
bus.emit(LiveEvent::TurnEnd {
|
||||
|
|
@ -600,14 +627,7 @@ pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSe
|
|||
bus.set_state(crate::events::TurnState::Compacting);
|
||||
let config = claude_config(bus, files);
|
||||
let sink = BusSink::new(bus);
|
||||
match session.compact(&config, &sink).await {
|
||||
Ok(()) => bus.emit(LiveEvent::Note {
|
||||
text: "/compact done".into(),
|
||||
}),
|
||||
Err(e) => bus.emit(LiveEvent::Note {
|
||||
text: format!("/compact failed: {e}"),
|
||||
}),
|
||||
}
|
||||
report_compact(bus, session.compact(&config, &sink).await);
|
||||
bus.set_state(crate::events::TurnState::Idle);
|
||||
if let Some(prompt) = request.wake_prompt {
|
||||
bus.set_post_compact_wake(prompt);
|
||||
|
|
@ -821,8 +841,10 @@ fn archive_session(bus: &Bus, session: &AgentSession) {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PermissionAsk, TurnError, acp_permits, nonzero_or, parse_u64, runtime_error_to_turn,
|
||||
PermissionAsk, TurnError, acp_permits, nonzero_or, parse_u64, report_compact,
|
||||
runtime_error_to_turn, with_turn_end_compact,
|
||||
};
|
||||
use crate::events::{Bus, LiveEvent};
|
||||
use hive_runtime::{AcpError, Error};
|
||||
|
||||
#[test]
|
||||
|
|
@ -959,4 +981,49 @@ mod tests {
|
|||
Err(TurnError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn compact_report(result: Result<(), &str>) -> (bool, Vec<String>) {
|
||||
crate::paths::with_harness_dir(|| {
|
||||
let bus = Bus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let ok = report_compact(&bus, result);
|
||||
let mut notes = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let LiveEvent::Note { text } = ev.event {
|
||||
notes.push(text);
|
||||
}
|
||||
}
|
||||
(ok, notes)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_compact_is_reported_and_not_counted_as_compacted() {
|
||||
let (compacted, notes) = compact_report(Err("spawn failed"));
|
||||
assert!(!compacted);
|
||||
assert_eq!(notes, ["/compact failed: spawn failed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_compact_is_reported_and_counted_as_compacted() {
|
||||
let (compacted, notes) = compact_report(Ok(()));
|
||||
assert!(compacted);
|
||||
assert_eq!(notes, ["/compact done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_turn_end_compact_keeps_the_turns_own_compaction() {
|
||||
assert!(matches!(with_turn_end_compact(Ok(true), false), Ok(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_turn_end_compact_leaves_a_plain_turn_uncompacted() {
|
||||
assert!(matches!(with_turn_end_compact(Ok(false), false), Ok(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_turn_end_compact_marks_the_turn_compacted() {
|
||||
assert!(matches!(with_turn_end_compact(Ok(false), true), Ok(true)));
|
||||
assert!(matches!(with_turn_end_compact(Ok(true), true), Ok(true)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response
|
|||
.emit(crate::events::LiveEvent::Note { text: note.into() });
|
||||
return (axum::http::StatusCode::OK, "ok").into_response();
|
||||
}
|
||||
let out = super::sigint_claude().await;
|
||||
let out = super::sigint_claude();
|
||||
let note = match out {
|
||||
SigintOutcome::Signalled => {
|
||||
// A process actually got signalled — the *next* turn's wake
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response
|
|||
/// preservation invariants.
|
||||
pub(super) async fn post_logout(State(state): State<AppState>) -> Response {
|
||||
// Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`).
|
||||
let _ = super::sigint_claude().await;
|
||||
let _ = super::sigint_claude();
|
||||
// Step 2: delete OAuth credential files only — login::clear_session owns
|
||||
// the file set and preserves session-history files alongside them.
|
||||
let dir = crate::paths::claude_dir();
|
||||
|
|
|
|||
|
|
@ -343,21 +343,40 @@ fn find_claude_child() -> Option<u32> {
|
|||
/// (best-effort). See [`find_claude_child`] for how it's identified —
|
||||
/// **not** a name-based `pkill claude`, which would also hit an unrelated
|
||||
/// `claude` process someone is running interactively in the container.
|
||||
async fn sigint_claude() -> SigintOutcome {
|
||||
fn sigint_claude() -> SigintOutcome {
|
||||
let Some(pid) = find_claude_child() else {
|
||||
return SigintOutcome::NoProcess;
|
||||
};
|
||||
match tokio::process::Command::new("kill")
|
||||
.args(["-INT", &pid.to_string()])
|
||||
.status()
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.success() => SigintOutcome::Signalled,
|
||||
// Non-zero from `kill` means "no such process" (ESRCH) — it already
|
||||
// exited between the check above and the signal. Same as never
|
||||
// having found it.
|
||||
Ok(_) => SigintOutcome::NoProcess,
|
||||
Err(e) => SigintOutcome::Failed(e),
|
||||
sigint_turn(pid)
|
||||
}
|
||||
|
||||
/// SIGINT `pid`, and its whole process group when it leads one, so the
|
||||
/// commands a turn's claude started stop with it. `hive-claude` spawns the
|
||||
/// turn's claude as the leader of its own group. A `claude` child that
|
||||
/// leads none (`plugins.rs`'s `claude plugin` calls) shares the harness's
|
||||
/// group, so it is signalled alone.
|
||||
fn sigint_turn(pid: u32) -> SigintOutcome {
|
||||
// A child is never pid 0 or 1, and `-0` / `-1` would reach the
|
||||
// harness's own group or every process it may signal.
|
||||
let Some(pid) = i32::try_from(pid).ok().filter(|&pid| pid > 1) else {
|
||||
return SigintOutcome::NoProcess;
|
||||
};
|
||||
// SAFETY: `getpgid(2)` only reads; a gone pid returns -1, never `pid`.
|
||||
let target = if unsafe { libc::getpgid(pid) } == pid {
|
||||
-pid
|
||||
} else {
|
||||
pid
|
||||
};
|
||||
// SAFETY: plain `kill(2)` on the child or the group it leads.
|
||||
if unsafe { libc::kill(target, libc::SIGINT) } == 0 {
|
||||
return SigintOutcome::Signalled;
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
// It exited between the scan and the signal: same as never found.
|
||||
SigintOutcome::NoProcess
|
||||
} else {
|
||||
SigintOutcome::Failed(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -425,14 +444,18 @@ fn broker_error_response(err: &BrokerError, action: &str) -> Response {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{AppState, routes};
|
||||
use super::{AppState, SigintOutcome, routes, sigint_turn};
|
||||
use crate::events::Bus;
|
||||
use crate::login::LoginState;
|
||||
|
||||
|
|
@ -447,45 +470,127 @@ mod tests {
|
|||
/// either.
|
||||
#[test]
|
||||
fn neither_model_nor_effort_has_a_route() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let _env = crate::paths::HARNESS_DIR_ENV
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner);
|
||||
let prev = std::env::var_os("HYPERHIVE_HARNESS_DIR");
|
||||
// SAFETY: every test that sets this variable holds `HARNESS_DIR_ENV`.
|
||||
unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", dir.path()) };
|
||||
let state = AppState {
|
||||
label: "test".to_owned(),
|
||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus: Bus::new(),
|
||||
socket: dir.path().join("mcp.sock"),
|
||||
gui_vnc_port: None,
|
||||
interrupted: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
let (model, effort, compact) = tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let model = app.clone().oneshot(form("/api/model", "model=opus"));
|
||||
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
||||
// A route the router does serve, so the 404s above are not
|
||||
// the router refusing everything.
|
||||
let compact = app.oneshot(form("/api/compact", ""));
|
||||
(
|
||||
model.await.unwrap(),
|
||||
effort.await.unwrap(),
|
||||
compact.await.unwrap(),
|
||||
)
|
||||
});
|
||||
match prev {
|
||||
// SAFETY: as above.
|
||||
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) },
|
||||
None => unsafe { std::env::remove_var("HYPERHIVE_HARNESS_DIR") },
|
||||
}
|
||||
let (model, effort, compact) = crate::paths::with_harness_dir(|| {
|
||||
let state = AppState {
|
||||
label: "test".to_owned(),
|
||||
login: Arc::new(Mutex::new(LoginState::Online)),
|
||||
session: Arc::new(Mutex::new(None)),
|
||||
bus: Bus::new(),
|
||||
socket: crate::paths::harness_dir().join("mcp.sock"),
|
||||
gui_vnc_port: None,
|
||||
interrupted: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
let model = app.clone().oneshot(form("/api/model", "model=opus"));
|
||||
let effort = app.clone().oneshot(form("/api/effort", "effort=high"));
|
||||
// A route the router does serve, so the 404s above are not
|
||||
// the router refusing everything.
|
||||
let compact = app.oneshot(form("/api/compact", ""));
|
||||
(
|
||||
model.await.unwrap(),
|
||||
effort.await.unwrap(),
|
||||
compact.await.unwrap(),
|
||||
)
|
||||
})
|
||||
});
|
||||
assert_eq!(model.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(effort.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(compact.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Poll `path` until it holds a complete, newline-terminated pid.
|
||||
fn read_pid(path: &Path) -> i32 {
|
||||
for _ in 0..250 {
|
||||
if let Ok(text) = std::fs::read_to_string(path)
|
||||
&& text.ends_with('\n')
|
||||
{
|
||||
return text.trim().parse().expect("pid file holds a pid");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
unreachable!("no pid in {} within 5s", path.display());
|
||||
}
|
||||
|
||||
/// Whether `pid` is still running. A zombie counts as gone: it has
|
||||
/// exited, and `kill(pid, 0)` only keeps finding it until it is reaped.
|
||||
fn is_running(pid: i32) -> bool {
|
||||
// SAFETY: signal 0 delivers nothing; it only checks that `pid` exists.
|
||||
if unsafe { libc::kill(pid, 0) } != 0 {
|
||||
return false;
|
||||
}
|
||||
!std::fs::read_to_string(format!("/proc/{pid}/stat")).is_ok_and(|stat| {
|
||||
stat.rsplit_once(") ")
|
||||
.is_some_and(|(_, rest)| rest.starts_with('Z'))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_stops_the_commands_a_turn_started() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let pidfile = dir.path().join("grandchild.pid");
|
||||
// A turn's claude as hive-claude spawns it: leading its own group,
|
||||
// with a command of its own running in the foreground (a
|
||||
// non-interactive shell starts `&` jobs with SIGINT ignored). The
|
||||
// trailing `exit` keeps `sh -c` from exec'ing into that command.
|
||||
let mut turn = Command::new("/bin/sh")
|
||||
.args([
|
||||
"-c",
|
||||
r#"sh -c 'echo $$ >"$1"; exec sleep 300' sh "$1"; exit"#,
|
||||
"sh",
|
||||
])
|
||||
.arg(&pidfile)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.expect("spawn the stand-in turn");
|
||||
let grandchild = read_pid(&pidfile);
|
||||
assert_ne!(
|
||||
i32::try_from(turn.id()).ok(),
|
||||
Some(grandchild),
|
||||
"the command must be a child of the turn, not the turn itself"
|
||||
);
|
||||
|
||||
assert!(matches!(sigint_turn(turn.id()), SigintOutcome::Signalled));
|
||||
|
||||
let mut alive = is_running(grandchild);
|
||||
for _ in 0..250 {
|
||||
if !alive {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
alive = is_running(grandchild);
|
||||
}
|
||||
if alive {
|
||||
// SAFETY: `kill(2)` on a pid this test started; keeps a failed
|
||||
// run from leaving a 300s `sleep` behind.
|
||||
unsafe {
|
||||
libc::kill(grandchild, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = turn.wait();
|
||||
assert!(!alive, "grandchild {grandchild} outlived the cancel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claude_sharing_the_harness_group_is_signalled_alone() {
|
||||
// In this test process's own group, as `claude plugin` runs. A group
|
||||
// signal here would SIGINT the test binary itself.
|
||||
let mut child = Command::new("sleep")
|
||||
.arg("300")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
|
||||
assert!(matches!(sigint_turn(child.id()), SigintOutcome::Signalled));
|
||||
|
||||
let status = child.wait().expect("reap sleep");
|
||||
assert_eq!(
|
||||
std::os::unix::process::ExitStatusExt::signal(&status),
|
||||
Some(libc::SIGINT)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ async fn proxy_handler(
|
|||
HeaderName::from_bytes(name.as_str().as_bytes()),
|
||||
HeaderValue::from_bytes(value.as_bytes()),
|
||||
) {
|
||||
resp_headers.insert(n, v);
|
||||
resp_headers.append(n, v);
|
||||
}
|
||||
}
|
||||
match upstream_resp.bytes().await {
|
||||
|
|
@ -269,8 +269,75 @@ async fn forward_over_unix_socket(
|
|||
if HOP_BY_HOP.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
resp_headers.insert(name.clone(), value.clone());
|
||||
resp_headers.append(name.clone(), value.clone());
|
||||
}
|
||||
let body_bytes = upstream_resp.into_body().collect().await?.to_bytes();
|
||||
Ok((status, resp_headers, body_bytes).into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// Raw HTTP/1.1 answer with two `Set-Cookie` headers and a body.
|
||||
const TWO_COOKIES: &[u8] = b"HTTP/1.1 200 OK\r\n\
|
||||
Set-Cookie: session=abc\r\n\
|
||||
Set-Cookie: csrf=def\r\n\
|
||||
Content-Length: 2\r\n\
|
||||
Connection: close\r\n\r\nok";
|
||||
|
||||
/// Read the request head, then answer with [`TWO_COOKIES`].
|
||||
async fn answer_once<S: AsyncReadExt + AsyncWriteExt + Unpin>(mut stream: S) {
|
||||
let mut buf = [0u8; 1024];
|
||||
let _ = stream.read(&mut buf).await;
|
||||
stream.write_all(TWO_COOKIES).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
}
|
||||
|
||||
fn cookies(resp: &Response) -> Vec<&str> {
|
||||
resp.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.map(|v| v.to_str().unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unix_upstream_keeps_repeated_response_headers() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sock = dir.path().join("up.sock");
|
||||
let listener = tokio::net::UnixListener::bind(&sock).unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
answer_once(stream).await;
|
||||
});
|
||||
|
||||
let resp =
|
||||
forward_over_unix_socket(&sock, Method::GET, "/", HeaderMap::new(), Bytes::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_upstream_keeps_repeated_response_headers() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
answer_once(stream).await;
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder().no_proxy().build().unwrap();
|
||||
let resp = proxy_handler(
|
||||
State((Arc::new(client), Arc::new(base))),
|
||||
Method::GET,
|
||||
Uri::from_static("/"),
|
||||
HeaderMap::new(),
|
||||
Bytes::new(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cookies(&resp), ["session=abc", "csrf=def"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue