diff --git a/Cargo.lock b/Cargo.lock index f658447b2..05ca3101b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1514,7 +1514,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1573,7 +1573,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "fabro-checkpoint" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1612,7 +1612,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1705,7 +1705,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1734,7 +1734,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1760,7 +1760,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1775,7 +1775,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1793,7 +1793,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "fabro-http", "fabro-static", @@ -1810,7 +1810,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "base64", "chrono", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1845,7 +1845,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "fabro-static", "http", @@ -1879,7 +1879,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1894,7 +1894,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -1908,7 +1908,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1940,7 +1940,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -1951,7 +1951,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -1967,7 +1967,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "fabro-static", "insta", @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "axum", "base64", @@ -1999,7 +1999,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2007,7 +2007,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "cc", "libc", @@ -2016,7 +2016,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2032,7 +2032,7 @@ dependencies = [ [[package]] name = "fabro-retro" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2050,7 +2050,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2163,7 +2163,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2183,18 +2183,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" [[package]] name = "fabro-store" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2220,7 +2220,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2246,7 +2246,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2281,7 +2281,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "async-trait", "fabro-github", @@ -2294,7 +2294,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2314,7 +2314,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2334,7 +2334,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2344,7 +2344,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "chrono", "serde", @@ -2355,7 +2355,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -7086,7 +7086,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "axum", "base64", @@ -7105,7 +7105,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 172afb8b7..2386a8aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.213.0-nightly.2" +version = "0.214.0-nightly.0" license = "MIT" [workspace.dependencies] diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 563c5c7f9..7575f49a6 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -78,6 +78,8 @@ static INSTA_FILTERS: &[(&str, &str)] = &[ const MANAGED_STORAGE_MARKER: &str = "# fabro-test managed storage_dir"; const SESSION_LOCK_TIMEOUT: Duration = Duration::from_secs(20); +const STALE_TMP_DAEMON_THRESHOLD: Duration = Duration::from_mins(30); +const TMP_DAEMON_REAPER_COOLDOWN: Duration = Duration::from_mins(5); const TEST_SESSION_SECRET: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const TEST_DEV_TOKEN: &str = @@ -310,6 +312,7 @@ fn session_refs() -> &'static Mutex> { // the existing call structure without introducing cross-mode coupling. static NEXTEST_REAPED: OnceLock<()> = OnceLock::new(); static PROCESS_REAPED: OnceLock<()> = OnceLock::new(); +static TMP_DAEMONS_REAPED: OnceLock<()> = OnceLock::new(); // Advisory-lock-based peer-presence marker. Each test process opens // `/clients/` once, holds a shared (LOCK_SH) flock @@ -1083,6 +1086,113 @@ fn reap_stale_session_roots(mode: SessionMode) { } } +fn maybe_reap_stale_tmp_daemons() { + let (lock_path, stamp_path) = tmp_daemon_reaper_paths(); + if stamp_is_recent(&stamp_path, TMP_DAEMON_REAPER_COOLDOWN) { + return; + } + + let Some(parent) = lock_path.parent() else { + return; + }; + let _ = std::fs::create_dir_all(parent); + let Ok(lock_file) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + else { + return; + }; + let Ok(true) = fabro_proc::try_flock_exclusive(&lock_file) else { + return; + }; + + if !stamp_is_recent(&stamp_path, TMP_DAEMON_REAPER_COOLDOWN) { + reap_stale_tmp_daemons(); + let _ = File::create(&stamp_path); + } + let _ = fabro_proc::flock_unlock(&lock_file); +} + +fn reap_stale_tmp_daemons() { + let Ok(output) = std::process::Command::new("ps") + .args(["-ww", "-axo", "pid=,etime=,command="]) + .output() + else { + return; + }; + if !output.status.success() { + return; + } + + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some((pid, elapsed_secs, command)) = parse_tmp_daemon_ps_line(line) else { + continue; + }; + if Duration::from_secs(elapsed_secs) <= STALE_TMP_DAEMON_THRESHOLD { + continue; + } + if tmp_daemon_socket_re().is_match(command) { + fabro_proc::sigkill(pid); + } + } +} + +fn tmp_daemon_reaper_paths() -> (PathBuf, PathBuf) { + let base = short_session_base_dir(); + ( + base.join("tmp-daemon-reaper.lock"), + base.join("tmp-daemon-reaper.stamp"), + ) +} + +fn stamp_is_recent(path: &Path, cooldown: Duration) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + let Ok(modified) = metadata.modified() else { + return false; + }; + modified.elapsed().is_ok_and(|elapsed| elapsed < cooldown) +} + +fn parse_tmp_daemon_ps_line(line: &str) -> Option<(u32, u64, &str)> { + static PS_ROW_RE: OnceLock = OnceLock::new(); + let captures = PS_ROW_RE + .get_or_init(|| Regex::new(r"^\s*(\d+)\s+(\S+)\s+(.*)$").expect("static regex")) + .captures(line)?; + let pid = captures.get(1)?.as_str().parse::().ok()?; + let elapsed_secs = parse_etime(captures.get(2)?.as_str())?; + let command = captures.get(3)?.as_str(); + Some((pid, elapsed_secs, command)) +} + +fn tmp_daemon_socket_re() -> &'static Regex { + static TMP_DAEMON_SOCKET_RE: OnceLock = OnceLock::new(); + TMP_DAEMON_SOCKET_RE.get_or_init(|| { + Regex::new(r"^fabro server unix:/tmp/\.tmp[^/]+/[^/]+\.sock\s*$").expect("static regex") + }) +} + +fn parse_etime(s: &str) -> Option { + let (days, rest) = match s.split_once('-') { + Some((days, rest)) => (days.parse::().ok()?, rest), + None => (0, s), + }; + let nums = rest + .split(':') + .map(|part| part.parse::().ok()) + .collect::>>()?; + let (hours, minutes, seconds) = match nums.as_slice() { + [minutes, seconds] => (0, *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return None, + }; + Some(days * 86_400 + hours * 3_600 + minutes * 60 + seconds) +} + impl TestContext { /// Create a new isolated test context. /// @@ -1108,6 +1218,7 @@ impl TestContext { let (_, test_run_id, session_paths) = session_paths(); NEXTEST_REAPED.get_or_init(|| reap_stale_session_roots(SessionMode::Nextest)); PROCESS_REAPED.get_or_init(|| reap_stale_session_roots(SessionMode::Process)); + TMP_DAEMONS_REAPED.get_or_init(maybe_reap_stale_tmp_daemons); with_session_lock(&session_paths.root, || { std::fs::create_dir_all(session_clients_dir(&session_paths.root)).unwrap_or_else( |err| { @@ -2375,6 +2486,54 @@ mod tests { ); } + #[test] + fn parse_etime_accepts_bsd_elapsed_time_formats() { + assert_eq!(parse_etime("00:42"), Some(42)); + assert_eq!(parse_etime("01:23:45"), Some(5_025)); + assert_eq!(parse_etime("2-03:04:05"), Some(183_845)); + } + + #[test] + fn parse_etime_rejects_malformed_elapsed_time() { + assert_eq!(parse_etime(""), None); + assert_eq!(parse_etime("abc"), None); + assert_eq!(parse_etime("01:02:03:04"), None); + assert_eq!(parse_etime("2-not-time"), None); + } + + #[test] + fn parse_tmp_daemon_ps_line_handles_padded_rows() { + let line = "43640 12:14 fabro server unix:/tmp/.tmppL8cKh/fabro.sock"; + + assert_eq!( + parse_tmp_daemon_ps_line(line), + Some((43_640, 734, "fabro server unix:/tmp/.tmppL8cKh/fabro.sock")) + ); + } + + #[test] + fn tmp_daemon_socket_regex_matches_only_test_tmp_unix_daemons() { + let re = tmp_daemon_socket_re(); + + assert!(re.is_match("fabro server unix:/tmp/.tmpAbC/fabro.sock")); + assert!(!re.is_match("fabro server unix:/tmp/.ft-foo-XYZ/test.sock")); + assert!(!re.is_match("sh -c fabro server unix:/tmp/.tmpAbC/fabro.sock")); + assert!(!re.is_match("fabro server unix:/Users/me/.fabro/fabro.sock")); + assert!(!re.is_match("fabro server unix:/tmp/notmatching/fabro.sock")); + assert!(!re.is_match("fabro server tcp:127.0.0.1:32276")); + } + + #[test] + fn stamp_is_recent_checks_missing_and_fresh_stamps() { + let temp = tempfile::tempdir().expect("temp dir should be created"); + let missing = temp.path().join("missing.stamp"); + assert!(!stamp_is_recent(&missing, Duration::from_mins(1))); + + let fresh = temp.path().join("fresh.stamp"); + File::create(&fresh).expect("stamp should be created"); + assert!(stamp_is_recent(&fresh, Duration::from_mins(1))); + } + #[test] fn run_and_create_commands_include_test_labels() { let context_root = tempfile::tempdir().expect("failed to create temp dir");