#![cfg(target_os = "linux")]
use std::{
fs,
path::{Path, PathBuf},
thread::sleep,
time::{Duration, Instant},
};
use serde_json::{Value, json};
use tempfile::TempDir;
use super::{heddle, setup_repo};
fn fuse_supported_or_skip(test_name: &str) -> bool {
if std::path::Path::new("/dev/fuse").exists() {
return true;
}
eprintln!(
"[daemon_lifecycle::{test_name}] skipping: /dev/fuse not present \
on this host"
);
false
}
fn endpoint_path(repo_root: &Path) -> PathBuf {
repo_root
.join(".heddle")
.join("state")
.join("heddled.endpoint.json")
}
fn registry_path(repo_root: &Path) -> PathBuf {
repo_root.join(".heddle").join("state").join("mounts.json")
}
fn wait_until(deadline: Duration, mut predicate: impl FnMut() -> bool) -> bool {
let start = Instant::now();
while start.elapsed() < deadline {
if predicate() {
return true;
}
sleep(Duration::from_millis(50));
}
predicate()
}
fn endpoint_pid(repo_root: &Path) -> Option<u32> {
let raw = fs::read_to_string(endpoint_path(repo_root)).ok()?;
let v: Value = serde_json::from_str(&raw).ok()?;
v.get("pid").and_then(Value::as_u64).map(|pid| pid as u32)
}
fn pid_alive(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as i32, 0) };
if result == 0 {
return true;
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
errno != 3
}
fn kill_pid(pid: u32, signal: i32) {
let result = unsafe { libc::kill(pid as i32, signal) };
if result != 0 {
let errno = std::io::Error::last_os_error();
eprintln!("[daemon_lifecycle] kill({pid}, {signal}) failed: {errno}");
}
}
fn mount_path_from_start(raw: &str) -> String {
let out: Value = serde_json::from_str(raw).expect("start --output json output");
out.get("thread")
.and_then(|t| t.get("path"))
.and_then(Value::as_str)
.expect("virtualized thread output should include thread.path")
.to_string()
}
fn capture_short(cwd: &Path, msg: &str) -> String {
let out =
heddle(&["--output", "json", "capture", "-m", msg], Some(cwd)).expect("snapshot succeeded");
let v: Value = serde_json::from_str(&out).expect("snapshot --output json is valid JSON");
v.get("change_id")
.and_then(Value::as_str)
.expect("snapshot output exposes change_id")
.to_string()
}
fn try_stop_daemon(repo_root: &Path) {
let _ = heddle(&["daemon", "stop"], Some(repo_root));
}
fn parse_mount_count(status_output: &str) -> Option<u32> {
for token in status_output.split_whitespace() {
if let Some(rest) = token.strip_prefix("mount_count=") {
return rest.parse().ok();
}
}
None
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn daemon_mount_survives_cli_exit() {
if !fuse_supported_or_skip("daemon_mount_survives_cli_exit") {
return;
}
let main = setup_repo("greet.txt", "hello from daemon");
let raw = heddle(
&[
"--output",
"json",
"start",
"feature/daemon-survives",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("--daemon start succeeded");
let mount_path = mount_path_from_start(&raw);
let observed = fs::read_to_string(format!("{mount_path}/greet.txt"))
.expect("read through daemon-owned mount after CLI exit");
assert_eq!(observed, "hello from daemon");
heddle(
&["thread", "drop", "feature/daemon-survives"],
Some(main.path()),
)
.expect("thread drop after daemon mount");
assert!(
fs::read_to_string(format!("{mount_path}/greet.txt")).is_err(),
"after drop, mount must be inaccessible"
);
try_stop_daemon(main.path());
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn daemon_spawns_on_demand_and_status_reports_healthy() {
if !fuse_supported_or_skip("daemon_spawns_on_demand_and_status_reports_healthy") {
return;
}
let main = setup_repo("greet.txt", "alive");
assert!(
!endpoint_path(main.path()).exists(),
"endpoint must not exist before any --daemon use"
);
heddle(
&[
"--output",
"json",
"start",
"spawn-test",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("--daemon start spawned the daemon");
assert!(
endpoint_path(main.path()).exists(),
"endpoint file must be present after `--daemon` use"
);
let status = heddle(&["daemon", "status"], Some(main.path())).expect("daemon status RPC");
assert!(
status.contains("ok=true"),
"daemon status should report ok=true; got: {status:?}"
);
assert_eq!(
parse_mount_count(&status),
Some(1),
"exactly one mount expected after a single --daemon start; got: {status:?}"
);
heddle(&["thread", "drop", "spawn-test"], Some(main.path())).expect("drop spawn-test thread");
try_stop_daemon(main.path());
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn idempotent_mount_does_not_double_register() {
if !fuse_supported_or_skip("idempotent_mount_does_not_double_register") {
return;
}
let main = setup_repo("greet.txt", "once");
let first = heddle(
&[
"--output",
"json",
"start",
"idem-thread",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("first --daemon start");
let first_path = mount_path_from_start(&first);
let second = heddle(
&[
"--output",
"json",
"start",
"idem-thread",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("second --daemon start must succeed");
let second_path = mount_path_from_start(&second);
assert_eq!(
first_path, second_path,
"second start must resolve to the same mount path"
);
let status = heddle(&["daemon", "status"], Some(main.path())).expect("daemon status RPC");
assert_eq!(
parse_mount_count(&status),
Some(1),
"mount count must remain 1 after idempotent re-mount; got: {status:?}"
);
let observed = fs::read_to_string(format!("{first_path}/greet.txt"))
.expect("read after idempotent re-mount");
assert_eq!(observed, "once");
heddle(&["thread", "drop", "idem-thread"], Some(main.path())).expect("drop idem-thread");
try_stop_daemon(main.path());
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn daemon_stop_drains_mounts_and_exits() {
if !fuse_supported_or_skip("daemon_stop_drains_mounts_and_exits") {
return;
}
let main = setup_repo("greet.txt", "stoppable");
let raw = heddle(
&[
"--output",
"json",
"start",
"stop-test",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("--daemon start");
let mount_path = mount_path_from_start(&raw);
let pid = endpoint_pid(main.path()).expect("endpoint file must record the daemon PID");
assert!(pid_alive(pid), "daemon must be alive before stop");
heddle(&["daemon", "stop"], Some(main.path())).expect("daemon stop RPC");
assert!(
wait_until(Duration::from_secs(5), || {
!endpoint_path(main.path()).exists()
}),
"endpoint file must be removed after daemon stop"
);
assert!(
wait_until(Duration::from_secs(5), || !pid_alive(pid)),
"daemon PID {pid} must exit after `daemon stop`"
);
assert!(
fs::metadata(format!("{mount_path}/greet.txt")).is_err(),
"mount point must be inaccessible after daemon stop"
);
assert!(
!registry_path(main.path()).exists(),
"mounts.json must be removed after daemon stop (daemon's \
shutdown_all sequences before remove_endpoint, and the CLI \
waits for daemon PID death before returning)"
);
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn stale_endpoint_is_swept_and_daemon_respawns() {
if !fuse_supported_or_skip("stale_endpoint_is_swept_and_daemon_respawns") {
return;
}
let main = setup_repo("greet.txt", "after stale sweep");
let endpoint = endpoint_path(main.path());
let registry = registry_path(main.path());
fs::create_dir_all(endpoint.parent().unwrap()).unwrap();
let stale_pid: u32 = 0x7fff_fffe;
let stale_endpoint = json!({
"version": 2,
"host": "127.0.0.1",
"port": 1u16,
"pid": stale_pid,
});
fs::write(
&endpoint,
serde_json::to_vec_pretty(&stale_endpoint).unwrap(),
)
.unwrap();
let phantom_path = main.path().join("__phantom_mount__");
let registry_payload = json!({
"mounts": [{
"thread_id": "ghost",
"mount_path": phantom_path.to_str().unwrap(),
"pid": stale_pid,
"since_ms": 0u64,
}]
});
fs::write(
®istry,
serde_json::to_vec_pretty(®istry_payload).unwrap(),
)
.unwrap();
let raw = heddle(
&[
"--output",
"json",
"start",
"post-sweep",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("post-sweep start must succeed after stale endpoint cleanup");
let mount_path = mount_path_from_start(&raw);
let new_pid = endpoint_pid(main.path()).expect("respawned daemon wrote endpoint");
assert_ne!(
new_pid, stale_pid,
"endpoint file must record the respawned daemon's PID, not the sentinel"
);
assert!(pid_alive(new_pid), "respawned daemon PID must be live");
let observed = fs::read_to_string(format!("{mount_path}/greet.txt"))
.expect("read through respawned daemon's mount");
assert_eq!(observed, "after stale sweep");
heddle(&["thread", "drop", "post-sweep"], Some(main.path())).expect("drop post-sweep");
try_stop_daemon(main.path());
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn daemon_killed_mid_mount_recovers_on_next_invocation() {
if !fuse_supported_or_skip("daemon_killed_mid_mount_recovers_on_next_invocation") {
return;
}
let main = setup_repo("greet.txt", "before kill");
let raw = heddle(
&[
"--output",
"json",
"start",
"kill-test",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("--daemon start before kill");
let mount_path = mount_path_from_start(&raw);
let pid = endpoint_pid(main.path()).expect("daemon endpoint must record PID after start");
assert_eq!(
fs::read_to_string(format!("{mount_path}/greet.txt")).unwrap(),
"before kill"
);
kill_pid(pid, libc::SIGKILL);
assert!(
wait_until(Duration::from_secs(5), || !pid_alive(pid)),
"daemon PID must die after SIGKILL"
);
let read_err = fs::read_to_string(format!("{mount_path}/greet.txt"));
assert!(
read_err.is_err(),
"read against wedged mount must error after daemon SIGKILL; got: {:?}",
read_err
);
let raw_recovery = heddle(
&[
"--output",
"json",
"start",
"after-kill",
"--workspace",
"virtualized",
"--daemon",
],
Some(main.path()),
)
.expect("post-kill start must respawn the daemon and mount cleanly");
let recovery_path = mount_path_from_start(&raw_recovery);
let recovered_pid = endpoint_pid(main.path()).expect("respawned daemon wrote endpoint");
assert_ne!(
recovered_pid, pid,
"respawned daemon must have a different PID than the SIGKILLed one"
);
assert!(pid_alive(recovered_pid), "respawned daemon must be alive");
let observed = fs::read_to_string(format!("{recovery_path}/greet.txt"))
.expect("read through respawned daemon's mount for the new thread");
assert_eq!(observed, "before kill");
heddle(&["thread", "drop", "after-kill"], Some(main.path())).expect("drop after-kill");
try_stop_daemon(main.path());
}
#[test]
#[ignore = "requires Linux + FUSE + heddle built with --features mount"]
fn daemon_mount_with_from_serves_resolved_state() {
if !fuse_supported_or_skip("daemon_mount_with_from_serves_resolved_state") {
return;
}
let main = setup_repo("greet.txt", "S1");
fs::write(main.path().join("greet.txt"), "S2").unwrap();
let _s2 = capture_short(main.path(), "S2 in main");
let raw = heddle(
&[
"--output",
"json",
"start",
"from-daemon",
"--workspace",
"virtualized",
"--daemon",
"--from",
"HEAD~1",
],
Some(main.path()),
)
.expect("--daemon --from HEAD~1 start");
let mount_path = mount_path_from_start(&raw);
let observed = fs::read_to_string(format!("{mount_path}/greet.txt"))
.expect("read through daemon-owned --from mount");
assert_eq!(
observed, "S1",
"--daemon --from HEAD~1 must serve S1, not S2"
);
heddle(&["thread", "drop", "from-daemon"], Some(main.path())).expect("drop from-daemon");
try_stop_daemon(main.path());
}
#[test]
fn daemon_status_is_noop_success_when_daemon_absent() {
let main = TempDir::new().unwrap();
heddle(&["init"], Some(main.path())).unwrap();
let status = heddle(&["--output", "json", "daemon", "status"], Some(main.path()))
.expect("daemon status must succeed even with no daemon running");
let status: serde_json::Value =
serde_json::from_str(&status).expect("captured daemon status should use the JSON contract");
assert_eq!(status["status"], "not_running");
assert_eq!(status["running"], false);
}