use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use autofork_core::protocol::{encode, Request, RequestBody, Response, ResponseBody};
use autofork_core::PROTO_VERSION;
struct Env {
_tmp: tempfile::TempDir,
home: PathBuf,
socket: PathBuf,
project: PathBuf,
}
impl Env {
fn new(idle: &str) -> Self {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().to_path_buf();
let home = base.join("fsan");
let project = base.join("proj");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(project.join(".autofork/forks")).unwrap();
std::fs::write(
home.join("config.toml"),
format!("default_idle_deadline = \"{idle}\"\nquiet_period = \"1h\"\nwake_debounce = \"0\"\n"),
)
.unwrap();
Self {
socket: base.join("d.sock"),
_tmp: tmp,
home,
project,
}
}
fn hook_input(&self, session: &str) -> serde_json::Value {
serde_json::json!({
"session_id": session,
"transcript_path": self.project.join("t.jsonl"),
"cwd": self.project,
"hook_event_name": "whatever",
})
}
fn hook(&self, event: &str, stdin_json: &serde_json::Value) -> (Option<i32>, String, String) {
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", event])
.env("AUTOFORK_HOME", &self.home)
.env("AUTOFORK_SOCKET", &self.socket)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(stdin_json.to_string().as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).to_string(),
String::from_utf8_lossy(&out.stderr).to_string(),
)
}
fn kill_daemon(&self) {
if let Ok(mut s) = UnixStream::connect(&self.socket) {
let _ = s.write_all(b"{\"proto\":1,\"id\":1,\"type\":\"shutdown\",\"drain\":false}\n");
std::thread::sleep(Duration::from_millis(200));
}
}
}
impl Drop for Env {
fn drop(&mut self) {
self.kill_daemon();
}
}
fn mock_daemon(socket: PathBuf, stop_wait_response: ResponseBody) -> thread::JoinHandle<()> {
thread::spawn(move || {
let listener = UnixListener::bind(&socket).unwrap();
for conn in listener.incoming() {
let Ok(stream) = conn else { continue };
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
let mut done = false;
while reader.read_line(&mut line).unwrap_or(0) > 0 {
let req: Request = match serde_json::from_str(line.trim()) {
Ok(r) => r,
Err(_) => break,
};
let body = match req.body {
RequestBody::Hello { .. } => ResponseBody::HelloInfo {
version: "999.0.0".into(),
},
RequestBody::StopWait(_) => {
done = true;
stop_wait_response.clone()
}
_ => ResponseBody::Ack,
};
let resp = Response {
proto: PROTO_VERSION,
id: req.id,
body,
};
let _ = writer.write_all(encode(&resp).unwrap().as_bytes());
line.clear();
if done {
break;
}
}
break;
}
})
}
fn wait_for_socket(path: &std::path::Path) {
let start = Instant::now();
while !path.exists() {
assert!(start.elapsed() < Duration::from_secs(5), "mock never bound");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn stop_wait_wake_exits_2_with_payload_on_stderr() {
let env = Env::new("1h");
let handle = mock_daemon(
env.socket.clone(),
ResponseBody::Wake {
payload: "WAKE_PAYLOAD_MARKER".into(),
forks: None,
},
);
wait_for_socket(&env.socket);
let (code, stdout, stderr) = env.hook("stop-wait", &env.hook_input("s1"));
assert_eq!(code, Some(2), "wake must exit 2");
assert!(
stderr.contains("WAKE_PAYLOAD_MARKER"),
"payload not on stderr: {stderr}"
);
assert!(stdout.trim().is_empty(), "unexpected stdout: {stdout}");
let _ = handle.join();
}
#[test]
fn stop_wait_waited_exits_0() {
let env = Env::new("1h");
let handle = mock_daemon(env.socket.clone(), ResponseBody::Waited);
wait_for_socket(&env.socket);
let (code, _stdout, _stderr) = env.hook("stop-wait", &env.hook_input("s1"));
assert_eq!(code, Some(0), "a cancelled wait must exit 0 silently");
let _ = handle.join();
}
#[test]
fn stop_wait_closed_socket_exits_0() {
let env = Env::new("1h");
let socket = env.socket.clone();
let handle = thread::spawn(move || {
let listener = UnixListener::bind(&socket).unwrap();
if let Some(Ok(stream)) = listener.incoming().next() {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
let req: Request = serde_json::from_str(line.trim()).unwrap();
match req.body {
RequestBody::Hello { .. } => {
let resp = Response {
proto: PROTO_VERSION,
id: req.id,
body: ResponseBody::HelloInfo {
version: "999.0.0".into(),
},
};
let _ = writer.write_all(encode(&resp).unwrap().as_bytes());
}
RequestBody::StopWait(_) => break, _ => {}
}
line.clear();
}
}
});
wait_for_socket(&env.socket);
let (code, _o, _e) = env.hook("stop-wait", &env.hook_input("s1"));
assert_eq!(code, Some(0), "closed socket mid-poll must exit 0");
let _ = handle.join();
}
#[test]
fn real_daemon_stop_wait_wakes_on_idle() {
let env = Env::new("1s");
std::fs::write(
env.project.join(".autofork/forks/journal.md"),
"---\nfork: true\nrun_on: [idle]\n---\nJOURNAL BODY",
)
.unwrap();
let (code, _o, _e) = env.hook("session-start", &env.hook_input("s1"));
assert_eq!(code, Some(0));
let (code, _stdout, stderr) = env.hook("stop-wait", &env.hook_input("s1"));
assert_eq!(code, Some(2), "real daemon should wake at idle");
assert!(stderr.contains("source: autofork"));
assert!(stderr.contains("due: journal"));
assert!(stderr.contains("subagent_type \"fork\""));
}
#[test]
fn disable_tags_env_filters_fork() {
let env = Env::new("1s");
std::fs::write(
env.project.join(".autofork/forks/tagged.md"),
"---\nfork: true\nrun_on: [idle]\ntags: [ci]\n---\nTAGGED BODY",
)
.unwrap();
let run = |event: &str| {
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", event])
.env("AUTOFORK_HOME", &env.home)
.env("AUTOFORK_SOCKET", &env.socket)
.env("AUTOFORK_DISABLE_TAGS", "ci")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(env.hook_input("s1").to_string().as_bytes())
.unwrap();
child.wait_with_output().unwrap()
};
assert_eq!(run("session-start").status.code(), Some(0));
let home = env.home.clone();
let socket = env.socket.clone();
let stdin = env.hook_input("s1").to_string();
let handle = thread::spawn(move || {
std::thread::sleep(Duration::from_millis(1800));
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", "user-prompt-submit"])
.env("AUTOFORK_HOME", &home)
.env("AUTOFORK_SOCKET", &socket)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(stdin.as_bytes())
.unwrap();
let _ = child.wait();
});
let out = run("stop-wait");
assert_eq!(out.status.code(), Some(0), "disabled fork woke the session");
let _ = handle.join();
}
#[test]
fn fork_env_guard_short_circuits_hook() {
let env = Env::new("1h");
std::fs::write(
env.project.join(".autofork/forks/journal.md"),
"---\nfork: true\nrun_on: [idle]\n---\nBODY",
)
.unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", "stop-wait"])
.env("AUTOFORK_HOME", &env.home)
.env("AUTOFORK_SOCKET", &env.socket)
.env("AUTOFORK_FORK", "1")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(env.hook_input("s1").to_string().as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "guarded hook must exit 0");
assert!(out.stdout.is_empty(), "guarded hook produced stdout");
assert!(out.stderr.is_empty(), "guarded hook produced stderr");
std::thread::sleep(Duration::from_millis(300));
assert!(
!env.socket.exists(),
"a daemon was spawned despite the fork guard"
);
}
#[test]
fn concurrent_session_starts_race_to_one_daemon() {
let env = Env::new("1h");
let mut children = Vec::new();
for i in 0..5 {
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", "session-start"])
.env("AUTOFORK_HOME", &env.home)
.env("AUTOFORK_SOCKET", &env.socket)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(env.hook_input(&format!("s{i}")).to_string().as_bytes())
.unwrap();
children.push(child);
}
for mut c in children {
assert!(c.wait().unwrap().success());
}
let out = Command::new(env!("CARGO_BIN_EXE_autofork"))
.arg("status")
.env("AUTOFORK_HOME", &env.home)
.env("AUTOFORK_SOCKET", &env.socket)
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("sessions: 5"), "status was: {stdout}");
}
#[test]
fn hook_never_fails_on_garbage_stdin() {
let env = Env::new("1h");
for event in [
"session-start",
"user-prompt-submit",
"stop-wait",
"session-end",
] {
let mut child = Command::new(env!("CARGO_BIN_EXE_autofork"))
.args(["hook", event])
.env("AUTOFORK_HOME", &env.home)
.env("AUTOFORK_SOCKET", &env.socket)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"not json").unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(
out.status.code(),
Some(0),
"hook {event} broke on garbage stdin"
);
}
}