116 lines
3.6 KiB
Rust
116 lines
3.6 KiB
Rust
//! [`TurnStop`]: the handle that stops a turn from outside the call driving it.
|
|
|
|
use hive_claude::Cancel;
|
|
|
|
use crate::Canceller;
|
|
|
|
/// Stops the turn a claude process or an ACP agent has in flight. Cheap to
|
|
/// clone.
|
|
///
|
|
/// Every stop is SIGINT, directly (claude) or after `session/cancel` went
|
|
/// unanswered (ACP): claude shuts down the commands it started when it gets
|
|
/// SIGINT, and nothing here escalates past it or waits for the turn to end.
|
|
#[derive(Clone)]
|
|
pub enum TurnStop {
|
|
/// Signals the turn's `claude` process group.
|
|
Claude(Cancel),
|
|
/// Sends the ACP agent `session/cancel`; the runtime sends SIGINT to one
|
|
/// that ignores it for its own grace period.
|
|
Acp(Canceller),
|
|
}
|
|
|
|
impl From<Cancel> for TurnStop {
|
|
fn from(cancel: Cancel) -> Self {
|
|
Self::Claude(cancel)
|
|
}
|
|
}
|
|
|
|
impl From<Canceller> for TurnStop {
|
|
fn from(canceller: Canceller) -> Self {
|
|
Self::Acp(canceller)
|
|
}
|
|
}
|
|
|
|
impl TurnStop {
|
|
/// Ask the turn to stop, without waiting for it to. Returns `false` when
|
|
/// no turn was in flight and nothing was signalled. A claude handle exists
|
|
/// only for a spawned process, so it always returns `true`.
|
|
#[must_use]
|
|
pub fn stop(&self) -> bool {
|
|
match self {
|
|
Self::Claude(cancel) => {
|
|
cancel.cancel(false);
|
|
true
|
|
}
|
|
Self::Acp(canceller) => canceller.cancel(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use hive_claude::{Attach, Claude, Config};
|
|
|
|
use super::TurnStop;
|
|
|
|
/// A `claude` that records its SIGINT in `$MARK` and exits 0, and says it
|
|
/// is listening for one in `$READY`.
|
|
const FAKE_CLAUDE: &str = "#!/bin/sh\n\
|
|
trap ': > \"$MARK\"; exit 0' INT\n\
|
|
: > \"$READY\"\n\
|
|
sleep 30 >/dev/null 2>&1 &\n\
|
|
wait\n";
|
|
|
|
/// Write the script `body` to `path` as an executable, from a child process
|
|
/// rather than from this one: a write fd on it held here could be inherited
|
|
/// by a child another test thread forks, and exec'ing a file some process
|
|
/// still holds open for writing fails with `ETXTBSY`.
|
|
fn write_executable(path: &Path, body: &str) {
|
|
let status = std::process::Command::new("/bin/sh")
|
|
.args([
|
|
"-c",
|
|
r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#,
|
|
"sh",
|
|
body,
|
|
])
|
|
.arg(path)
|
|
.status()
|
|
.expect("run /bin/sh");
|
|
assert!(status.success(), "writing {} failed", path.display());
|
|
}
|
|
|
|
async fn exists(path: &Path) {
|
|
for _ in 0..250 {
|
|
if path.exists() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
panic!("{} never appeared", path.display());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_claude_stop_is_a_sigint_to_the_process() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let program = dir.path().join("fake-claude");
|
|
write_executable(&program, FAKE_CLAUDE);
|
|
let (ready, mark) = (dir.path().join("ready"), dir.path().join("int"));
|
|
let config = Config {
|
|
program: Some(program),
|
|
env: vec![
|
|
("READY".to_owned(), ready.display().to_string()),
|
|
("MARK".to_owned(), mark.display().to_string()),
|
|
],
|
|
..Config::default()
|
|
};
|
|
let running = Claude::spawn(&config, &Attach::Create("n".to_owned())).unwrap();
|
|
let stop = TurnStop::from(running.cancel_handle());
|
|
exists(&ready).await;
|
|
|
|
assert!(stop.stop());
|
|
exists(&mark).await;
|
|
}
|
|
}
|