diff --git a/.github/hauler-ci.json b/.github/hauler-ci.json index 01c38e24e0..e108355be6 100644 --- a/.github/hauler-ci.json +++ b/.github/hauler-ci.json @@ -29,6 +29,7 @@ "prepare": [ "rm -rf target/nextest target/test-profile", "pnpm install --frozen-lockfile --store-dir /cache/pnpm", + "[ \"${CARGO_HOME:-/opt/cargo}\" = /opt/cargo ] || { mkdir -p \"$CARGO_HOME/bin\" && ln -sf /opt/cargo/bin/* \"$CARGO_HOME/bin/\"; }", "rustup show active-toolchain && rustup which rust-analyzer", "python3 scripts/test-linux-test-partitions.py && python3 scripts/linux-test-partitions.py check", "python3 -c 'import json,subprocess; actual={row[\"group\"] for row in json.loads(subprocess.check_output([\"python3\",\"scripts/linux-test-partitions.py\",\"linux-matrix\"]))[\"include\"]}; assert actual == {'\"'\"'core-contracts'\"'\"', '\"'\"'runtime'\"'\"', '\"'\"'root-lib-sessions'\"'\"', '\"'\"'root-journeys'\"'\"', '\"'\"'core-storage'\"'\"', '\"'\"'root-transport'\"'\"'}, \"Linux groups changed; update the trusted Hauler recipe before delegation\"'" diff --git a/crates/tracedecay-daemon-control/src/service.rs b/crates/tracedecay-daemon-control/src/service.rs index d4ed645b39..e3c9074738 100644 --- a/crates/tracedecay-daemon-control/src/service.rs +++ b/crates/tracedecay-daemon-control/src/service.rs @@ -213,7 +213,7 @@ impl QuiescedDaemonLifecycle { profile, operation, expected_version, - ServiceRunner::current(profile)?, + ServiceRunner::current_for_installed_unit(profile)?, timeout, ) } @@ -1164,7 +1164,7 @@ fn refresh_installed_service_with_state( expected_version: &str, ) -> Result> { refresh_installed_service_with_state_and_runner( - &ServiceRunner::current(&spec.profile)?, + &ServiceRunner::current_for_installed_unit(&spec.profile)?, spec, previous_state, expected_version, @@ -1217,7 +1217,7 @@ pub fn quiesce_installed_service_before_lease( ) -> Result { quiesce_installed_service_before_lease_with_runner( profile, - &ServiceRunner::current(profile)?, + &ServiceRunner::current_for_installed_unit(profile)?, expected_version, ) } @@ -1273,7 +1273,7 @@ pub fn verify_installed_service_quiesced_under_lease( ) -> Result { verify_installed_service_quiesced_under_lease_with_runner( profile, - &ServiceRunner::current(profile)?, + &ServiceRunner::current_for_installed_unit(profile)?, ) } @@ -1331,7 +1331,7 @@ pub fn restore_installed_service_after_update( ) -> Result<()> { restore_installed_service_after_update_with_runner( profile, - &ServiceRunner::current(profile)?, + &ServiceRunner::current_for_installed_unit(profile)?, previous_state, expected_version, ) diff --git a/crates/tracedecay-daemon-control/src/service/runner.rs b/crates/tracedecay-daemon-control/src/service/runner.rs index a66a611878..8f5baf8f57 100644 --- a/crates/tracedecay-daemon-control/src/service/runner.rs +++ b/crates/tracedecay-daemon-control/src/service/runner.rs @@ -13,8 +13,11 @@ use tracedecay_runtime_core::config::ProfileRoot; /// All variants exist on every platform so that dispatch stays exhaustive. #[derive(Clone, Debug)] pub(super) enum ServiceRunner { + /// `systemctl` is `None` only from + /// [`ServiceRunner::current_for_installed_unit`]: the missing program is + /// reported when a unit operation first needs it. Systemd { - systemctl: PathBuf, + systemctl: Option, unit: SystemdUnit, }, Launchd { @@ -108,13 +111,28 @@ impl ServiceRunner { } } + /// [`Self::current`] for paths that consult the service manager only once + /// a unit is installed. A Linux host without systemd (a container, say) + /// has no managed unit, so its maintenance must not require `systemctl`. + pub(super) fn current_for_installed_unit(profile: &ProfileRoot) -> Result { + match Self::current(profile) { + Err(TraceDecayError::HostCliUnavailable { program, .. }) if program == "systemctl" => { + Ok(Self::Systemd { + systemctl: None, + unit: SystemdUnit::of(profile)?, + }) + } + runner => runner, + } + } + pub(super) fn systemd(systemctl: impl AsRef, profile: &ProfileRoot) -> Result { Ok(Self::Systemd { - systemctl: required_service_program( + systemctl: Some(required_service_program( "systemctl", "systemd user service management", systemctl.as_ref(), - )?, + )?), unit: SystemdUnit::of(profile)?, }) } @@ -144,10 +162,10 @@ impl ServiceRunner { // even under `--no-start`; otherwise a later `start` launches // whatever stale definition systemd last loaded. Self::Systemd { systemctl, unit } => { - run_systemctl(systemctl, &["daemon-reload"])?; + run_systemctl(systemctl.as_deref(), &["daemon-reload"])?; if start { - let name = owned_systemd_unit(systemctl, unit)?; - run_systemctl(systemctl, &["enable", "--now", name])?; + let name = owned_systemd_unit(systemctl.as_deref(), unit)?; + run_systemctl(systemctl.as_deref(), &["enable", "--now", name])?; } Ok(()) } @@ -176,10 +194,10 @@ impl ServiceRunner { ) -> Result<()> { match self { Self::Systemd { systemctl, unit } => { - run_systemctl(systemctl, &["daemon-reload"])?; + run_systemctl(systemctl.as_deref(), &["daemon-reload"])?; if previous_state.is_running() { - let name = owned_systemd_unit(systemctl, unit)?; - run_systemctl(systemctl, &["restart", name])?; + let name = owned_systemd_unit(systemctl.as_deref(), unit)?; + run_systemctl(systemctl.as_deref(), &["restart", name])?; } Ok(()) } @@ -211,18 +229,18 @@ impl ServiceRunner { // A mask resolves the unit to `/dev/null` whoever placed it; // reporting it controls nothing, and every mutation still // refuses it. - let loaded = loaded_systemd_unit_file(systemctl, unit)?; + let loaded = loaded_systemd_unit_file(systemctl.as_deref(), unit)?; if loaded.as_deref() == Some(Path::new("/dev/null")) { return Ok(DaemonServiceState::Masked); } unit.require_owned(loaded)?; - let activity = systemctl_unit_query(systemctl, unit, "is-active")?; + let activity = systemctl_unit_query(systemctl.as_deref(), unit, "is-active")?; let running = match activity.as_str() { "active" | "reloading" | "refreshing" => true, "inactive" | "failed" | "activating" | "deactivating" | "maintenance" => false, _ => return Err(systemctl_unknown_state(unit, "is-active", &activity).into()), }; - let enablement = systemctl_unit_query(systemctl, unit, "is-enabled")?; + let enablement = systemctl_unit_query(systemctl.as_deref(), unit, "is-enabled")?; if enablement.starts_with("masked") { Ok(DaemonServiceState::Masked) } else if running && enablement.starts_with("enabled") { @@ -250,8 +268,8 @@ impl ServiceRunner { // Best effort: the unit file is removed either way, but a unit the // profile does not own is never disabled on its behalf. Self::Systemd { systemctl, unit } => { - if stop && let Ok(name) = owned_systemd_unit(systemctl, unit) { - let _ = run_systemctl(systemctl, &["disable", "--now", name]); + if stop && let Ok(name) = owned_systemd_unit(systemctl.as_deref(), unit) { + let _ = run_systemctl(systemctl.as_deref(), &["disable", "--now", name]); } Ok(()) } @@ -273,9 +291,9 @@ impl ServiceRunner { // install on this boot, so reload first; the reload is idempotent // when the unit on disk is unchanged. Self::Systemd { systemctl, unit } => { - run_systemctl(systemctl, &["daemon-reload"])?; - let name = owned_systemd_unit(systemctl, unit)?; - run_systemctl(systemctl, &["start", name]) + run_systemctl(systemctl.as_deref(), &["daemon-reload"])?; + let name = owned_systemd_unit(systemctl.as_deref(), unit)?; + run_systemctl(systemctl.as_deref(), &["start", name]) } Self::Launchd { launchctl, id } => { let target = launchd_service_target(id)?; @@ -295,8 +313,8 @@ impl ServiceRunner { pub(super) fn stop(&self, expected_version: &str) -> Result<()> { match self { Self::Systemd { systemctl, unit } => { - let name = owned_systemd_unit(systemctl, unit)?; - run_systemctl(systemctl, &["stop", name]) + let name = owned_systemd_unit(systemctl.as_deref(), unit)?; + run_systemctl(systemctl.as_deref(), &["stop", name]) } Self::Launchd { launchctl, id } => launchd_stop(launchctl, id), Self::WindowsTask => windows_task::stop(expected_version), @@ -320,14 +338,14 @@ impl ServiceRunner { } match self { Self::Systemd { systemctl, unit } => { - run_systemctl(systemctl, &["daemon-reload"])?; - let name = owned_systemd_unit(systemctl, unit)?; + run_systemctl(systemctl.as_deref(), &["daemon-reload"])?; + let name = owned_systemd_unit(systemctl.as_deref(), unit)?; if previous_state.is_enabled() { - run_systemctl(systemctl, &["enable", name])?; + run_systemctl(systemctl.as_deref(), &["enable", name])?; } else { - run_systemctl(systemctl, &["disable", name])?; + run_systemctl(systemctl.as_deref(), &["disable", name])?; } - run_systemctl(systemctl, &["start", name])?; + run_systemctl(systemctl.as_deref(), &["start", name])?; // `systemctl start` reports the fork, not a serving daemon. // Restore success must mean an authenticated daemon at the // expected version answering from the installed unit's socket. @@ -363,7 +381,7 @@ impl ServiceRunner { match self { Self::Systemd { systemctl, .. } => { if stop { - let _ = run_systemctl(systemctl, &["daemon-reload"]); + let _ = run_systemctl(systemctl.as_deref(), &["daemon-reload"]); } } Self::Launchd { .. } | Self::WindowsTask => {} @@ -417,8 +435,13 @@ fn run_launchctl(launchctl: &Path, args: &[&str]) -> Result Result<()> { - let output = Command::new(systemctl) +fn require_systemctl(systemctl: Option<&Path>) -> Result<&Path> { + systemctl + .ok_or_else(|| service_program_unavailable("systemctl", "systemd user service management")) +} + +fn run_systemctl(systemctl: Option<&Path>, args: &[&str]) -> Result<()> { + let output = Command::new(require_systemctl(systemctl)?) .arg("--user") .args(args) .output() @@ -580,11 +603,11 @@ impl From for TraceDecayError { /// or disabled unit and when the user manager is unreachable; only the printed /// state tells them apart, so an empty answer is an error, not "stopped". fn systemctl_unit_query( - systemctl: &Path, + systemctl: Option<&Path>, unit: &SystemdUnit, verb: &str, ) -> std::result::Result { - let output = Command::new(systemctl) + let output = Command::new(require_systemctl(systemctl)?) .args(["--user", verb, &unit.name]) .output() .map_err(|error| { @@ -621,7 +644,7 @@ fn systemctl_unknown_state(unit: &SystemdUnit, verb: &str, state: &str) -> Trace /// that controls or reports the unit first proves the manager's loaded file /// is the one this profile installed. fn owned_systemd_unit<'unit>( - systemctl: &Path, + systemctl: Option<&Path>, unit: &'unit SystemdUnit, ) -> std::result::Result<&'unit str, ServiceStateError> { let loaded = loaded_systemd_unit_file(systemctl, unit)?; @@ -631,10 +654,10 @@ fn owned_systemd_unit<'unit>( /// The unit file the user manager loads for `unit`, or `None` when it finds /// none on its search path. fn loaded_systemd_unit_file( - systemctl: &Path, + systemctl: Option<&Path>, unit: &SystemdUnit, ) -> std::result::Result, ServiceStateError> { - let output = Command::new(systemctl) + let output = Command::new(require_systemctl(systemctl)?) .args([ "--user", "show", diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index e1a5b5097b..dddf913dc9 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -1803,6 +1803,42 @@ fn post_update_rejects_reachable_unmanaged_daemon() { assert!(error.to_string().contains("stop")); } +/// A Linux host without systemd (a container) has no managed unit, so +/// maintenance that only quiesces an installed unit must not need `systemctl`; +/// a lifecycle command that drives the unit still reports it missing. +#[cfg(target_os = "linux")] +#[test] +fn maintenance_without_an_installed_unit_does_not_need_systemctl() { + let dir = TempDir::new().expect("temp dir"); + let data_dir = dir.path().join("profile"); + std::fs::create_dir_all(&data_dir).expect("data dir"); + let profile = ProfileRoot::new(&data_dir).with_xdg_config_home(dir.path().join("config")); + let empty_bin = dir.path().join("bin"); + std::fs::create_dir_all(&empty_bin).expect("empty bin dir"); + let _path_guard = tracedecay_runtime_core::config::HostProgramSearchPathGuard::set(&empty_bin); + + assert_eq!( + super::quiesce_installed_service_before_lease(&profile, TEST_BUILD_VERSION) + .expect("quiesce without a unit"), + DaemonServiceState::Missing + ); + assert_eq!( + super::verify_installed_service_quiesced_under_lease(&profile) + .expect("verify without a unit"), + DaemonServiceState::Missing + ); + let error = ServiceRunner::current_for_installed_unit(&profile) + .expect("deferred runner") + .stop(TEST_BUILD_VERSION) + .expect_err("driving the service manager still needs systemctl"); + assert!( + error + .to_string() + .contains("host CLI `systemctl` is unavailable"), + "{error}" + ); +} + #[cfg(target_os = "linux")] #[test] fn refresh_installed_service_preserves_existing_socket_path() { diff --git a/crates/tracedecay-private-fs/src/inode_generation.rs b/crates/tracedecay-private-fs/src/inode_generation.rs index c6477a2c66..bd948dc650 100644 --- a/crates/tracedecay-private-fs/src/inode_generation.rs +++ b/crates/tracedecay-private-fs/src/inode_generation.rs @@ -56,6 +56,9 @@ fn generation_bits(generation: libc::c_long) -> u64 { #[cfg(test)] mod tests { + #[cfg(target_os = "linux")] + use std::os::unix::ffi::OsStrExt; + #[cfg(target_os = "linux")] use super::inode_generation; @@ -65,9 +68,16 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("rollout.jsonl"); std::fs::write(&path, b"{}\n").unwrap(); - let first = inode_generation(&std::fs::File::open(&path).unwrap()) - .unwrap() - .expect("ext4 reports an inode generation"); + let Some(first) = inode_generation(&std::fs::File::open(&path).unwrap()).unwrap() else { + // tmpfs (a container's /tmp) and ZFS do not implement + // FS_IOC_GETVERSION; only ext4 is required to report one here. + assert!(!is_ext4(temp.path()), "ext4 reports an inode generation"); + eprintln!( + "skipping: the filesystem under {} reports no inode generation", + temp.path().display() + ); + return; + }; std::fs::remove_file(&path).unwrap(); std::fs::write(&path, b"{}\n").unwrap(); let second = inode_generation(&std::fs::File::open(&path).unwrap()) @@ -75,4 +85,14 @@ mod tests { .expect("ext4 reports an inode generation"); assert_ne!(first, second); } + + #[cfg(target_os = "linux")] + fn is_ext4(path: &std::path::Path) -> bool { + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: `statfs` is a plain C struct, valid when zeroed. + let mut stat: libc::statfs = unsafe { std::mem::zeroed() }; + // SAFETY: `path` is NUL-terminated and `stat` is a writable `statfs`. + assert_eq!(unsafe { libc::statfs(path.as_ptr(), &mut stat) }, 0); + stat.f_type == libc::EXT4_SUPER_MAGIC + } } diff --git a/crates/tracedecay-runtime-core/src/git_repository.rs b/crates/tracedecay-runtime-core/src/git_repository.rs index f41d617c18..fffa5b8878 100644 --- a/crates/tracedecay-runtime-core/src/git_repository.rs +++ b/crates/tracedecay-runtime-core/src/git_repository.rs @@ -881,9 +881,14 @@ impl GitRepositoryAuthority { crate::git_open::discover(path) ) .map_err(|error| match error { - gix::discover::Error::Discover(gix::discover::upwards::Error::NoGitRepository { - .. - }) => GitRepositoryError::NotARepository { + // Like git without GIT_DISCOVERY_ACROSS_FILESYSTEM, a walk that + // stops at a mount boundary (a tmpfs /tmp, say) or a ceiling has + // found no repository; nothing on disk was unreadable. + gix::discover::Error::Discover( + gix::discover::upwards::Error::NoGitRepository { .. } + | gix::discover::upwards::Error::NoGitRepositoryWithinFs { .. } + | gix::discover::upwards::Error::NoGitRepositoryWithinCeiling { .. }, + ) => GitRepositoryError::NotARepository { path: path.display().to_string(), }, error => GitRepositoryError::UnreadableRepository { diff --git a/crates/tracedecay-sessions/src/runtime/hosts/codex/tests.rs b/crates/tracedecay-sessions/src/runtime/hosts/codex/tests.rs index e66aca2199..86de635ba6 100644 --- a/crates/tracedecay-sessions/src/runtime/hosts/codex/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/hosts/codex/tests.rs @@ -1858,16 +1858,20 @@ mod recent_first_discovery_tests { let home = resolved_home.as_path(); let directory = home.join(".codex/sessions/2026/08/23"); std::fs::create_dir_all(&directory).unwrap(); + let session_id = "0198-session-beyond-default-budget"; + let expected = directory.join(format!("rollout-2026-08-23-{session_id}.jsonl")); + // Write the target mid-corpus: tmpfs lists newest entries first and + // btrfs oldest first, so either end would land in the first slice. for index in 0..4_100 { + if index == 2_050 { + std::fs::write(&expected, b"{}\n").unwrap(); + } std::fs::write( directory.join(format!("rollout-distractor-{index:04}.jsonl")), b"{}\n", ) .unwrap(); } - let session_id = "0198-session-beyond-default-budget"; - let expected = directory.join(format!("rollout-2026-08-23-{session_id}.jsonl")); - std::fs::write(&expected, b"{}\n").unwrap(); let source = CodexSource::with_home(home); let mut calls = 0_u64;