mirror of
https://github.com/hanzoai/gen.git
synced 2026-10-30 15:05:53 +00:00
exe::command and exe::bare are now the only way to make a Command, and root_is_the_one_resolver fails the build on Command::new anywhere else. Each child is handed only the absolute PATH entries, so an agent whose `#!/usr/bin/env node` used to find a repository's ./node finds the real one; on Windows NoDefaultCurrentDirectoryInExePath=1 keeps cmd.exe out of the working directory. A version is asked in the program's own directory and only its first line is read, so a child left holding the output open no longer holds the session for 20 seconds. The relay is made before the agent starts, so a signal during startup no longer kills Gen and orphans the agent. SIGINT is passed on unless Gen is in its terminal's foreground group; a signal Gen was started ignoring stays ignored; and once the agent is reaped each signal goes back to how Gen started, so SIGTERM during finalize ends it at once. tests/signals.rs holds all three against the built binary. A run home is swept only when its gen.lock can be taken. Gen and the agent, which inherits the descriptor, hold it, so a session in another pid namespace, on another host, or whose agent outlived Gen keeps its home. The release smokes the linux binary in a job with no permissions; publish executes nothing it was handed, downloads each artifact into a directory of its own that must hold exactly its two files, and keeps the write token in the one step that uses it.
240 lines
9.3 KiB
Rust
240 lines
9.3 KiB
Rust
//! What a signal sent to `gen` does, end to end: the built binary, a stand-in
|
|
//! agent that writes down what it heard, a stand-in `hanzo`, and a home of its
|
|
//! own. `gen` runs in a process group of its own, so no terminal's Ctrl-C is in
|
|
//! play and every signal here is one a `kill`, a `timeout` or an IDE sends.
|
|
#![cfg(unix)]
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::net::TcpListener;
|
|
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// A session's world: `bin/` with the stand-ins, `home/`, and the agent's log.
|
|
struct World {
|
|
dir: tempfile::TempDir,
|
|
}
|
|
|
|
impl World {
|
|
/// `profile` is what `hanzo config profile` answers.
|
|
fn new(profile: &str) -> World {
|
|
let w = World { dir: tempfile::tempdir().unwrap() };
|
|
for d in ["bin", "home", "cwd"] {
|
|
std::fs::create_dir(w.at(d)).unwrap();
|
|
}
|
|
let log = w.at("log");
|
|
w.program(
|
|
"pi",
|
|
&format!(
|
|
r#"[ "$1" = --version ] && {{ echo "pi 0.0.1"; exit 0; }}
|
|
trap 'echo INT >> {log}; exit 130' INT
|
|
trap 'echo TERM >> {log}; exit 143' TERM
|
|
trap 'echo HUP >> {log}; exit 129' HUP
|
|
echo $$ > {log}.pid
|
|
echo start >> {log}
|
|
[ -n "$QUICK" ] && exit 0
|
|
i=0; while [ $i -lt 200 ]; do sleep 0.05; i=$((i+1)); done
|
|
echo end >> {log}
|
|
"#,
|
|
log = log.display()
|
|
),
|
|
);
|
|
w.program(
|
|
"hanzo",
|
|
&format!(
|
|
r#"case "$1 $2" in
|
|
"config profile") printf '%s' '{profile}' ;;
|
|
"auth token") printf '%s\n' 'eyJhbGciOiJub25lIn0.eyJvd25lciI6ICJ0ZXN0IiwgIm5hbWUiOiAib25lIn0.x' ;;
|
|
*) echo "Error: not here" >&2; exit 2 ;;
|
|
esac
|
|
"#
|
|
),
|
|
);
|
|
w
|
|
}
|
|
|
|
fn at(&self, p: &str) -> PathBuf {
|
|
self.dir.path().join(p)
|
|
}
|
|
|
|
fn program(&self, name: &str, body: &str) {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let p = self.at("bin").join(name);
|
|
std::fs::write(&p, format!("#!/bin/sh\n{body}")).unwrap();
|
|
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
}
|
|
|
|
/// `gen` on `args`, with `ignored` ignored from the start (`nohup`'s shape).
|
|
fn gen(&self, args: &[&str], ignored: Option<libc::c_int>, quick: bool) -> Child {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_gen"));
|
|
cmd.args(args)
|
|
.current_dir(self.at("cwd"))
|
|
.env_clear()
|
|
.env("HOME", self.at("home"))
|
|
.env("PATH", format!("{}:/usr/bin:/bin", self.at("bin").display()))
|
|
.env("TERM", "dumb")
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.process_group(0);
|
|
if let Some(t) = std::env::var_os("TMPDIR") {
|
|
cmd.env("TMPDIR", t);
|
|
}
|
|
if quick {
|
|
cmd.env("QUICK", "1");
|
|
}
|
|
// SAFETY: signal(2) is async-signal-safe, which is all pre_exec asks.
|
|
unsafe {
|
|
cmd.pre_exec(move || {
|
|
if let Some(sig) = ignored {
|
|
libc::signal(sig, libc::SIG_IGN);
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
let gen = cmd.spawn().unwrap();
|
|
self.until("start");
|
|
gen
|
|
}
|
|
|
|
fn heard(&self) -> String {
|
|
std::fs::read_to_string(self.at("log")).unwrap_or_default()
|
|
}
|
|
|
|
fn until(&self, word: &str) {
|
|
let by = Instant::now() + Duration::from_secs(20);
|
|
while !self.heard().lines().any(|l| l == word) {
|
|
assert!(Instant::now() < by, "the agent never wrote {word:?}: {:?}", self.heard());
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn kill(child: &Child, sig: libc::c_int) {
|
|
// SAFETY: kill(2) on a child this test started and has not reaped.
|
|
assert_eq!(unsafe { libc::kill(child.id() as i32, sig) }, 0);
|
|
}
|
|
|
|
/// How `child` ended, within `patience`; `None` if it is still running.
|
|
fn ended(child: &mut Child, patience: Duration) -> Option<ExitStatus> {
|
|
let by = Instant::now() + patience;
|
|
loop {
|
|
if let Some(s) = child.try_wait().unwrap() {
|
|
return Some(s);
|
|
}
|
|
if Instant::now() > by {
|
|
return None;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
}
|
|
|
|
const UNLINKED: &str = r#"{"api": "http://127.0.0.1:9", "local": true, "org": null, "provider": "hanzo", "identities": [], "link": false, "theme": "auto"}"#;
|
|
const TASK: [&str; 5] = ["--backend", "pi", "--no-link", "--no-mcp", "x"];
|
|
|
|
/// INT, TERM and HUP sent to `gen` alone each reach the agent, which ends the
|
|
/// run then and there, and a run its agent did not finish is a failed one.
|
|
#[test]
|
|
fn a_signal_to_gen_ends_its_agent() {
|
|
for (sig, word) in [(libc::SIGINT, "INT"), (libc::SIGTERM, "TERM"), (libc::SIGHUP, "HUP")] {
|
|
let w = World::new(UNLINKED);
|
|
let mut gen = w.gen(&TASK, None, false);
|
|
kill(&gen, sig);
|
|
let status = ended(&mut gen, Duration::from_secs(10)).unwrap_or_else(|| panic!("gen outlived SIG{word}"));
|
|
let heard = w.heard();
|
|
assert!(heard.lines().any(|l| l == word), "the agent never heard SIG{word}: {heard:?}");
|
|
assert!(!heard.contains("end"), "the agent ran to its end through SIG{word}: {heard:?}");
|
|
assert!(!status.success(), "a run cut short by SIG{word} said it succeeded");
|
|
}
|
|
}
|
|
|
|
/// A signal `gen` was started ignoring (`nohup`) stays ignored, for it and for
|
|
/// the agent, which carries on.
|
|
#[test]
|
|
fn a_signal_gen_was_started_ignoring_stays_ignored() {
|
|
let w = World::new(UNLINKED);
|
|
let mut gen = w.gen(&TASK, Some(libc::SIGHUP), false);
|
|
kill(&gen, libc::SIGHUP);
|
|
assert!(ended(&mut gen, Duration::from_millis(600)).is_none(), "an ignored SIGHUP ended gen");
|
|
kill(&gen, libc::SIGTERM);
|
|
ended(&mut gen, Duration::from_secs(10)).expect("gen outlived SIGTERM");
|
|
let heard = w.heard();
|
|
assert!(!heard.contains("HUP"), "the agent heard a SIGHUP gen was told to ignore: {heard:?}");
|
|
assert!(heard.contains("TERM"), "{heard:?}");
|
|
}
|
|
|
|
/// Once the agent has ended, a signal ends `gen` at once, even while it waits on
|
|
/// a cloud that does not answer: nothing is left for it to hold up for.
|
|
#[test]
|
|
fn a_signal_once_the_agent_has_ended_ends_gen() {
|
|
let cloud = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = cloud.local_addr().unwrap().port();
|
|
let profile = format!(
|
|
r#"{{"api": "http://127.0.0.1:{port}", "local": true, "org": null, "provider": "hanzo", "identities": [["test", "one"]], "link": true, "theme": "auto"}}"#
|
|
);
|
|
let w = World::new(&profile);
|
|
let log = w.at("log");
|
|
let (stalled, stall) = mpsc::channel();
|
|
std::thread::spawn(move || {
|
|
for conn in cloud.incoming().flatten() {
|
|
let (log, stalled) = (log.clone(), stalled.clone());
|
|
std::thread::spawn(move || answer(conn, &log, &stalled));
|
|
}
|
|
});
|
|
let mut gen = w.gen(&["--backend", "pi", "--link", "--no-mcp", "x"], None, true);
|
|
stall.recv_timeout(Duration::from_secs(20)).expect("gen never closed the session out");
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
let sent = Instant::now();
|
|
kill(&gen, libc::SIGTERM);
|
|
let status = ended(&mut gen, Duration::from_secs(5)).expect("gen held on through SIGTERM after its agent ended");
|
|
assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
|
|
assert!(sent.elapsed() < Duration::from_secs(3), "{:?}", sent.elapsed());
|
|
}
|
|
|
|
/// One connection to the stand-in cloud: every request is answered until gen has
|
|
/// reaped its agent, and from then on none is, as a cloud that has stalled.
|
|
fn answer(conn: std::net::TcpStream, log: &Path, stalled: &mpsc::Sender<()>) {
|
|
let mut out = conn.try_clone().unwrap();
|
|
let mut r = BufReader::new(conn);
|
|
loop {
|
|
let mut head = String::new();
|
|
let mut line = String::new();
|
|
while r.read_line(&mut line).unwrap_or(0) > 2 {
|
|
head.push_str(&line);
|
|
line.clear();
|
|
}
|
|
if head.is_empty() {
|
|
return;
|
|
}
|
|
let len = head
|
|
.lines()
|
|
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
|
|
.unwrap_or(0);
|
|
let mut body = vec![0; len];
|
|
r.read_exact(&mut body).unwrap();
|
|
if reaped(log) {
|
|
let _ = stalled.send(());
|
|
std::thread::sleep(Duration::from_secs(60));
|
|
return;
|
|
}
|
|
let first = head.lines().next().unwrap_or_default();
|
|
let reply = if first.starts_with("POST /v1/agents/sessions ") {
|
|
r#"{"id":"sess_t"}"#
|
|
} else if first.contains("/control") {
|
|
r#"{"commands":[],"cursor":0}"#
|
|
} else {
|
|
"{}"
|
|
};
|
|
let _ = write!(out, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{reply}", reply.len());
|
|
}
|
|
}
|
|
|
|
/// Whether the agent that wrote its pid beside `log` is gone, reaped and all.
|
|
fn reaped(log: &Path) -> bool {
|
|
let pid = std::fs::read_to_string(log.with_extension("pid")).ok().and_then(|p| p.trim().parse::<i32>().ok());
|
|
// SAFETY: signal 0 delivers nothing; it asks whether the pid exists.
|
|
pid.is_some_and(|p| unsafe { libc::kill(p, 0) } != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH))
|
|
}
|