Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/hauler-ci.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"'"
Expand Down
10 changes: 5 additions & 5 deletions crates/tracedecay-daemon-control/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ impl QuiescedDaemonLifecycle {
profile,
operation,
expected_version,
ServiceRunner::current(profile)?,
ServiceRunner::current_for_installed_unit(profile)?,
timeout,
)
}
Expand Down Expand Up @@ -1164,7 +1164,7 @@ fn refresh_installed_service_with_state(
expected_version: &str,
) -> Result<Option<PathBuf>> {
refresh_installed_service_with_state_and_runner(
&ServiceRunner::current(&spec.profile)?,
&ServiceRunner::current_for_installed_unit(&spec.profile)?,
spec,
previous_state,
expected_version,
Expand Down Expand Up @@ -1217,7 +1217,7 @@ pub fn quiesce_installed_service_before_lease(
) -> Result<DaemonServiceState> {
quiesce_installed_service_before_lease_with_runner(
profile,
&ServiceRunner::current(profile)?,
&ServiceRunner::current_for_installed_unit(profile)?,
expected_version,
)
}
Expand Down Expand Up @@ -1273,7 +1273,7 @@ pub fn verify_installed_service_quiesced_under_lease(
) -> Result<DaemonServiceState> {
verify_installed_service_quiesced_under_lease_with_runner(
profile,
&ServiceRunner::current(profile)?,
&ServiceRunner::current_for_installed_unit(profile)?,
)
}

Expand Down Expand Up @@ -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,
)
Expand Down
87 changes: 55 additions & 32 deletions crates/tracedecay-daemon-control/src/service/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
unit: SystemdUnit,
},
Launchd {
Expand Down Expand Up @@ -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<Self> {
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<Path>, profile: &ProfileRoot) -> Result<Self> {
Ok(Self::Systemd {
systemctl: required_service_program(
systemctl: Some(required_service_program(
"systemctl",
"systemd user service management",
systemctl.as_ref(),
)?,
)?),
unit: SystemdUnit::of(profile)?,
})
}
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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(())
}
Expand All @@ -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)?;
Expand All @@ -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),
Expand All @@ -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.
Expand Down Expand Up @@ -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 => {}
Expand Down Expand Up @@ -417,8 +435,13 @@ fn run_launchctl(launchctl: &Path, args: &[&str]) -> Result<std::process::Output
Err(launchctl_failure(args, &output))
}

fn run_systemctl(systemctl: &Path, args: &[&str]) -> 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()
Expand Down Expand Up @@ -580,11 +603,11 @@ impl From<ServiceStateError> 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<String, ServiceStateError> {
let output = Command::new(systemctl)
let output = Command::new(require_systemctl(systemctl)?)
.args(["--user", verb, &unit.name])
.output()
.map_err(|error| {
Expand Down Expand Up @@ -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)?;
Expand All @@ -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<Option<PathBuf>, ServiceStateError> {
let output = Command::new(systemctl)
let output = Command::new(require_systemctl(systemctl)?)
.args([
"--user",
"show",
Expand Down
36 changes: 36 additions & 0 deletions crates/tracedecay-daemon-control/src/service/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
26 changes: 23 additions & 3 deletions crates/tracedecay-private-fs/src/inode_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -65,14 +68,31 @@ 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())
.unwrap()
.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
}
}
11 changes: 8 additions & 3 deletions crates/tracedecay-runtime-core/src/git_repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions crates/tracedecay-sessions/src/runtime/hosts/codex/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading