#![cfg(unix)]
use std::fs;
use std::net::{TcpListener, TcpStream};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
#[test]
fn restarts_only_backend_and_cleans_children_on_sigterm() {
let root = std::env::temp_dir().join(format!("arcature-dev-lifecycle-{}", std::process::id()));
let backend_port = free_port();
create_fixture(&root, backend_port);
let path = format!(
"{}:{}",
root.join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
let mut arc = Command::new(env!("CARGO_BIN_EXE_arc"))
.arg("dev")
.current_dir(&root)
.env("PATH", path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("arc dev should start");
let ipc = ipc_path(arc.id());
wait_ipc(&ipc, &mut arc);
wait_port(backend_port, &mut arc);
thread::sleep(Duration::from_millis(500));
fs::write(root.join("src/main.rs"), "// changed\n").expect("source should change");
wait_for_build_count(&root.join("cargo.log"), 2, &mut arc);
assert_eq!(
fs::read_to_string(root.join("node.log"))
.expect("node log should exist")
.lines()
.count(),
1
);
assert!(
Command::new("kill")
.args(["-TERM", &arc.id().to_string()])
.status()
.expect("kill should run")
.success()
);
assert!(arc.wait().expect("arc should exit").success());
wait_closed(backend_port);
fs::remove_dir_all(root).expect("fixture should be removed");
}
#[test]
fn vite_ipc_start_failure_exits_without_backend() {
let root = std::env::temp_dir().join(format!("arcature-dev-failure-{}", std::process::id()));
let backend_port = free_port();
create_fixture(&root, backend_port);
let path = format!(
"{}:{}",
root.join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
let status = Command::new(env!("CARGO_BIN_EXE_arc"))
.arg("dev")
.current_dir(&root)
.env("PATH", path)
.env("ARC_FIXTURE_FRONTEND_FAIL", "1")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("arc dev should run");
assert!(!status.success());
wait_closed(backend_port);
fs::remove_dir_all(root).expect("fixture should be removed");
}
#[test]
fn sigterm_cancels_initial_backend_build_before_starting_frontend() {
let root = std::env::temp_dir().join(format!(
"arcature-dev-build-shutdown-{}",
std::process::id()
));
create_fixture(&root, free_port());
let path = format!(
"{}:{}",
root.join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
let mut arc = Command::new(env!("CARGO_BIN_EXE_arc"))
.arg("dev")
.current_dir(&root)
.env("PATH", path)
.env("ARC_FIXTURE_SLOW_BUILD", "1")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("arc dev should start");
wait_for_file(&root.join("cargo.log"), &mut arc);
assert!(
Command::new("kill")
.args(["-TERM", &arc.id().to_string()])
.status()
.expect("kill should run")
.success()
);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = arc.try_wait().expect("arc should be inspectable") {
assert!(status.success());
break;
}
assert!(
Instant::now() < deadline,
"arc did not cancel Cargo promptly"
);
thread::sleep(Duration::from_millis(25));
}
assert!(!root.join("node.log").exists());
fs::remove_dir_all(root).expect("fixture should be removed");
}
fn ipc_path(pid: u32) -> PathBuf {
std::env::temp_dir().join(format!("arcature-vite-{pid}.sock"))
}
fn create_fixture(root: &Path, backend_port: u16) {
fs::create_dir_all(root.join("src")).expect("src should be created");
fs::create_dir_all(root.join("frontend/node_modules/vite/bin"))
.expect("Vite marker should be created");
fs::create_dir_all(root.join("bin")).expect("bin should be created");
fs::write(root.join("src/main.rs"), "// initial\n").expect("source should be written");
fs::write(root.join("frontend/node_modules/vite/bin/vite.js"), "")
.expect("Vite marker should be written");
fs::write(root.join("arcature.toml"), format!("frontend = \"react\"\nfrontend_dir = \"frontend\"\nbackend_package = \"demo\"\nbackend_binary = \"demo\"\nbackend_port = {backend_port}\n")).expect("config should be written");
write_executable(
&root.join("bin/cargo"),
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'cargo 1.97.1'; exit 0; fi\n# `cargo run --bin arcature-metadata` (the UAG manifest refresh from\n# `arc dev`, AP2.1-4) is a no-op in this fixture: the demo app has no\n# `arcature-metadata` binary, so the fake cargo exits 0 without appending to\n# `cargo.log` (which counts real `cargo build` invocations only) and without\n# overwriting `target/debug/demo` (which is about to be started by the real\n# `start_backend`).\nif [ \"$1\" = \"run\" ]; then exit 0; fi\necho build >> cargo.log\nif [ \"$ARC_FIXTURE_SLOW_BUILD\" = \"1\" ]; then exec python3 -c 'import time; time.sleep(30)'; fi\nmkdir -p target/debug\nprintf '#!/bin/sh\\nexec python3 -m http.server \"$ARCATURE_BACKEND_PORT\" --bind 127.0.0.1\\n' > target/debug/demo\nchmod +x target/debug/demo\n",
);
write_executable(
&root.join("bin/node"),
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'v24.19.0'; exit 0; fi\nif [ \"$ARC_FIXTURE_FRONTEND_FAIL\" = \"1\" ]; then exit 4; fi\necho start >> ../node.log\nexec python3 -c \"\nimport socket, os, signal, sys, time\npath = os.environ['ARCATURE_VITE_IPC']\nif os.path.exists(path): os.unlink(path)\ns = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\ns.bind(path)\ns.listen(1)\nsignal.signal(signal.SIGTERM, lambda *a: sys.exit(0))\nwhile True: time.sleep(1)\n\"\n",
);
write_executable(&root.join("bin/pnpm"), "#!/bin/sh\necho '11.20.0'\n");
}
fn write_executable(path: &Path, contents: &str) {
fs::write(path, contents).expect("fixture executable should be written");
let mut permissions = fs::metadata(path)
.expect("fixture metadata should exist")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).expect("fixture should be executable");
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.expect("ephemeral port should bind")
.local_addr()
.expect("address should exist")
.port()
}
fn wait_ipc(path: &Path, child: &mut Child) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
assert!(
child
.try_wait()
.expect("child should be inspectable")
.is_none(),
"arc dev exited before ipc ready"
);
if path.exists() {
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("ipc endpoint {} did not become ready", path.display());
}
fn wait_port(port: u16, child: &mut Child) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
assert!(
child
.try_wait()
.expect("child should be inspectable")
.is_none(),
"arc dev exited before readiness"
);
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("port {port} did not become ready");
}
fn wait_for_build_count(path: &Path, count: usize, child: &mut Child) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
assert!(
child
.try_wait()
.expect("child should be inspectable")
.is_none(),
"arc dev exited before restart"
);
if fs::read_to_string(path).map_or(0, |text| text.lines().count()) >= count {
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("backend was not rebuilt");
}
fn wait_for_file(path: &Path, child: &mut Child) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
assert!(
child
.try_wait()
.expect("child should be inspectable")
.is_none(),
"arc dev exited before the build started"
);
if path.is_file() {
return;
}
thread::sleep(Duration::from_millis(25));
}
panic!("backend build did not start");
}
fn wait_closed(port: u16) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_err() {
return;
}
thread::sleep(Duration::from_millis(50));
}
panic!("port {port} remained open after shutdown");
}