diff --git a/crates/tracedecay-cli/src/commands/index.rs b/crates/tracedecay-cli/src/commands/index.rs index 293bbb0d98..3b99586b84 100644 --- a/crates/tracedecay-cli/src/commands/index.rs +++ b/crates/tracedecay-cli/src/commands/index.rs @@ -69,8 +69,14 @@ pub(crate) async fn handle_init( true, )?; handshake.moved_store_adoption = adoption; + // A connectable socket is the whole precondition: `brokered_init` carries + // its own 120 s bootstrap deadline, so a daemon that has not finished + // answering initialize within the one-second reachability probe is still + // the daemon this init must broker through. Requiring the identity proof + // here refused cold starts on CPU-constrained hosts and told the operator + // to start a daemon that was already running. #[cfg(unix)] - let daemon_available = tracedecay_daemon_control::daemon_reachable(); + let daemon_available = tracedecay_daemon_control::daemon_socket_connectable(); #[cfg(not(unix))] let daemon_available = true; diff --git a/crates/tracedecay-daemon-control/src/lib.rs b/crates/tracedecay-daemon-control/src/lib.rs index 1f2c0b1b6a..6a7e1fdb7f 100644 --- a/crates/tracedecay-daemon-control/src/lib.rs +++ b/crates/tracedecay-daemon-control/src/lib.rs @@ -84,15 +84,15 @@ mod service; pub use service::{ DaemonProcessProofV1, DaemonServiceSpec, DaemonServiceState, MaintenanceWindowOutcome, - QuiescedDaemonLifecycle, daemon_reachable, default_socket_path, install_service, - install_service_under_lease, installed_service_process_proof, installed_service_socket_path, - installed_service_state, prepare_scoop_package_service, quiesce_installed_service_before_lease, - refresh_installed_service_under_lease_with_state, restore_installed_service_after_update, - restore_scoop_package_service, service_spec, service_spec_with_remote_tls, service_status, - socket_path_or_default, start_service, stop_service, unavailable_daemon_socket_advice, - uninstall_service, verify_installed_service_quiesced_under_lease, - wait_for_installed_service_state, with_exclusive_maintenance_window, - with_quiesced_installed_service, + QuiescedDaemonLifecycle, daemon_reachable, daemon_socket_connectable, default_socket_path, + install_service, install_service_under_lease, installed_service_process_proof, + installed_service_socket_path, installed_service_state, prepare_scoop_package_service, + quiesce_installed_service_before_lease, refresh_installed_service_under_lease_with_state, + restore_installed_service_after_update, restore_scoop_package_service, service_spec, + service_spec_with_remote_tls, service_status, socket_path_or_default, start_service, + stop_service, unavailable_daemon_socket_advice, uninstall_service, + verify_installed_service_quiesced_under_lease, wait_for_installed_service_state, + with_exclusive_maintenance_window, with_quiesced_installed_service, }; fn current_daemon_client_identity() -> Result { diff --git a/crates/tracedecay-daemon-control/src/service.rs b/crates/tracedecay-daemon-control/src/service.rs index 97b64877a0..3e7c130638 100644 --- a/crates/tracedecay-daemon-control/src/service.rs +++ b/crates/tracedecay-daemon-control/src/service.rs @@ -26,7 +26,7 @@ mod tests; #[allow(clippy::expect_used)] mod update_restore_tests; -pub use probe::{DaemonProcessProofV1, daemon_reachable}; +pub use probe::{DaemonProcessProofV1, daemon_reachable, daemon_socket_connectable}; pub use unit_file::installed_service_socket_path; use probe::{ diff --git a/crates/tracedecay-daemon-control/src/service/probe.rs b/crates/tracedecay-daemon-control/src/service/probe.rs index c974b6a920..5a1f7af18e 100644 --- a/crates/tracedecay-daemon-control/src/service/probe.rs +++ b/crates/tracedecay-daemon-control/src/service/probe.rs @@ -92,6 +92,28 @@ pub fn daemon_reachable() -> bool { }) } +/// Whether the default socket has a listener, whether or not it named itself +/// inside the reachability probe timeout. +/// +/// [`daemon_reachable`] answers "a daemon proved its identity in one second". +/// A cold daemon on a CPU-constrained host answers initialize later than that, +/// and a caller that owns its own deadline must not read that miss as "no +/// daemon is running": the socket is connectable only because a process is +/// accepting on it. +pub fn daemon_socket_connectable() -> bool { + default_socket_path().is_ok_and(|path| { + matches!( + daemon_readiness_probe( + &path, + env!("CARGO_PKG_VERSION"), + DAEMON_REACHABILITY_PROBE_TIMEOUT, + ) + .0, + DaemonSocketState::Connectable + ) + }) +} + /// Probe `socket_path` once and return both the socket observation and the /// initialize proof. Callers must not connect again to classify liveness. pub(super) fn observe_daemon_process( diff --git a/crates/tracedecay-daemon-control/src/service/tests.rs b/crates/tracedecay-daemon-control/src/service/tests.rs index 485b2faa43..8ccb333339 100644 --- a/crates/tracedecay-daemon-control/src/service/tests.rs +++ b/crates/tracedecay-daemon-control/src/service/tests.rs @@ -691,6 +691,45 @@ fn daemon_reachable_requires_an_initialize_answer() { server.join().expect("join initialize server"); } +#[cfg(unix)] +#[test] +fn daemon_socket_connectable_separates_a_slow_daemon_from_no_daemon() { + let _env_lock = lock_user_data_dir_test_env(); + let profile = TempDir::new().expect("profile temp dir"); + + let missing = profile.path().join("missing.sock"); + let missing_guard = EnvVarGuard::set(SOCKET_ENV, &missing); + assert!( + !super::daemon_socket_connectable(), + "a missing socket has no listener to broker through" + ); + drop(missing_guard); + + let stale = profile.path().join("stale.sock"); + drop(UnixListener::bind(&stale).expect("bind stale socket")); + let stale_guard = EnvVarGuard::set(SOCKET_ENV, &stale); + assert!( + !super::daemon_socket_connectable(), + "a socket file whose listener is gone has no listener to broker through" + ); + drop(stale_guard); + + // The cold-start case: a daemon is accepting but has not answered + // initialize inside the one-second reachability probe. + let silent = profile.path().join("silent.sock"); + let _listener = UnixListener::bind(&silent).expect("bind silent socket"); + let silent_guard = EnvVarGuard::set(SOCKET_ENV, &silent); + assert!( + !super::daemon_reachable(), + "the identity proof is still absent while the daemon is starting" + ); + assert!( + super::daemon_socket_connectable(), + "a daemon that has not answered initialize yet is still a running daemon" + ); + drop(silent_guard); +} + #[cfg(unix)] #[test] fn daemon_status_reports_the_initialize_proof_not_only_the_socket() {