Skip to main content

subc_daemon/
bootstrap.rs

1use std::{
2    collections::BTreeMap,
3    env,
4    error::Error,
5    ffi::OsString,
6    fmt, fs, io,
7    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
8    path::{Path, PathBuf},
9    process,
10    time::Duration,
11};
12
13use fs4::{FileExt, TryLockError};
14use subc_protocol::PROTOCOL_VERSION;
15pub use subc_transport::user_connection_token;
16use subc_transport::{
17    authenticate_client, connection_file, generate_daemon_id, generate_key, write_atomic,
18    AuthError, ConnectionFileError, ConnectionInfo, Endpoint, SCHEMA_VERSION,
19};
20use tokio::{
21    net::{TcpListener, TcpStream},
22    task::{JoinError, JoinHandle},
23    time::{sleep, timeout},
24};
25use tracing::{error, info, warn};
26
27use crate::{
28    daemon_config::{self, ConfiguredModule, DaemonConfigError},
29    server::{serve_listeners, ServerAuth, ServerError},
30    supervise::HealthConfig,
31    ConnectedClients, ControlHandler, DaemonSelfWatchdog, DaemonSelfWatchdogConfig,
32    ForwardingTable, Registry, RestartPolicy, Router, Supervisor, SupervisorHandle,
33    SupervisorProcessLiveness,
34};
35use std::sync::Arc;
36
37pub const DEFAULT_SUBC_PORT: u16 = 8757;
38pub const SUBC_PORT_ENV: &str = "SUBC_PORT";
39use subc_transport::CONNECTION_FILE_NAME;
40const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
41const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
42const PROBE_AUTH_DEADLINE: Duration = Duration::from_secs(2);
43const START_LOCK_RETRIES: usize = 40;
44const START_LOCK_RETRY_DELAY: Duration = Duration::from_millis(25);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ConnectionFileSource {
48    XdgRuntimeDir,
49    TempDirFallback,
50    Explicit,
51}
52
53impl ConnectionFileSource {
54    fn reason(self) -> &'static str {
55        match self {
56            Self::XdgRuntimeDir => "XDG_RUNTIME_DIR set and non-empty",
57            Self::TempDirFallback => "XDG_RUNTIME_DIR unset or empty",
58            Self::Explicit => "configured path",
59        }
60    }
61}
62
63impl fmt::Display for ConnectionFileSource {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        formatter.write_str(match self {
66            Self::XdgRuntimeDir => "xdg_runtime_dir",
67            Self::TempDirFallback => "temp_dir_fallback",
68            Self::Explicit => "explicit",
69        })
70    }
71}
72
73/// Runtime bootstrap configuration. Production uses the default fixed port and
74/// optional daemon-config override; tests pass port 0 to let the OS assign a free
75/// loopback port and discover it from the connection file.
76#[derive(Debug, Clone, Default)]
77struct AdmissionFactsConfig {
78    carrier_module_id: Option<String>,
79    targets: Option<Vec<String>>,
80}
81
82/// Controls where module cgroups are prepared.
83///
84/// In-process daemons default to [`Self::Disabled`] so they never derive a
85/// production location from the host process. The shipped daemon explicitly uses
86/// [`Self::Current`]. The `ck-subc` binary accepts
87/// `SUBC_CGROUP_PLACEMENT=disabled` for isolated test processes; an unset
88/// variable retains `Current`, and every other value is rejected at startup.
89/// Tests that exercise placement can own a [`Self::Root`].
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub enum CgroupPlacementConfig {
92    #[default]
93    Disabled,
94    Current,
95    Root(PathBuf),
96}
97
98#[derive(Debug, Clone)]
99pub struct BootstrapConfig {
100    pub operator_queue_wait: Duration,
101    pub operator_timeout: Duration,
102    pub operator_stuck_grace: Duration,
103    #[cfg(feature = "test-support")]
104    pub operator_script: Option<PathBuf>,
105    #[cfg(feature = "test-support")]
106    pub operator_events: Option<PathBuf>,
107    pub connection_file_path: PathBuf,
108    pub port: u16,
109    pub daemon_ver: String,
110    configured_modules: Vec<ConfiguredModule>,
111    storage_config: Option<daemon_config::StorageConfig>,
112    admission_facts: AdmissionFactsConfig,
113    /// Module ids whose scopes may carry the attributes that grant authority.
114    scope_authority_owners: Vec<String>,
115    privileged_operator_prompt: daemon_config::PrivilegedOperatorPrompt,
116    activity_config: daemon_config::ActivityConfig,
117    daemon_config_path: Option<PathBuf>,
118    configured_port: Option<u16>,
119    /// Daemon-wide route.bind relay budget in milliseconds (the fallback for
120    /// any module without a per-module override). `None` = built-in default
121    /// (12s — see `control::DEFAULT_ROUTE_BIND_RELAY_TIMEOUT`).
122    route_bind_relay_default_ms: Option<u64>,
123    reserved_capabilities: BTreeMap<String, String>,
124    watchdog_config: DaemonSelfWatchdogConfig,
125    connection_file_source: ConnectionFileSource,
126    /// Where module cgroups are prepared. Disabled unless a caller explicitly
127    /// opts in, because `Current` derives a host location from `/proc/self/cgroup`.
128    cgroup_placement: CgroupPlacementConfig,
129    /// Directory the supervisor writes per-module stdout/stderr capture files
130    /// into. `None` disables capture; the shipped binary supplies its real run
131    /// directory explicitly.
132    capture_logs_dir: Option<PathBuf>,
133    /// File the supervisor appends every module's terminal exits to, so exit
134    /// history survives a daemon restart. `None` keeps terminal history in
135    /// memory only (each module's ring). Absent by default for the same reason
136    /// as `capture_logs_dir`: an in-process daemon booted by a test must not
137    /// append its exits to the operator's real `terminals.jsonl`, where they
138    /// would show up in `ck module terminals`. The shipped binary supplies
139    /// `<run dir>/terminals.jsonl` explicitly.
140    terminal_journal_path: Option<PathBuf>,
141    /// Where the machine id is read from, or minted into when absent. `None`
142    /// serves no machine id. Absent by default for the same reason as
143    /// `capture_logs_dir`: an in-process daemon booted by a test must never
144    /// derive the operator's real data home and mint into it. The shipped binary
145    /// supplies `<data home>/cortexkit/machine-id` explicitly.
146    machine_id_path: Option<PathBuf>,
147    /// The live-children record: every supervised process this daemon has
148    /// running, kept so the next daemon can end the ones a crash left behind.
149    /// At startup, before any module is spawned, the previous daemon's record
150    /// here is swept. `None` keeps no record and sweeps nothing, for the same
151    /// reason as `capture_logs_dir`: an in-process daemon booted by a test
152    /// must never signal processes listed in the operator's real run
153    /// directory. The shipped binary supplies `<run dir>/live-children.json`.
154    live_children_path: Option<PathBuf>,
155    privacy_trampoline: Option<PathBuf>,
156}
157
158impl BootstrapConfig {
159    pub fn new(connection_file_path: impl Into<PathBuf>, port: u16) -> Self {
160        Self {
161            connection_file_path: connection_file_path.into(),
162            operator_queue_wait: Duration::from_secs(150),
163            operator_timeout: Duration::from_secs(120),
164            operator_stuck_grace: Duration::from_secs(10),
165            #[cfg(feature = "test-support")]
166            operator_script: None,
167            #[cfg(feature = "test-support")]
168            operator_events: None,
169            port,
170            daemon_ver: DAEMON_VERSION.to_owned(),
171            configured_modules: Vec::new(),
172            storage_config: None,
173            admission_facts: AdmissionFactsConfig::default(),
174            scope_authority_owners: daemon_config::default_scope_authority_owners(),
175            privileged_operator_prompt: daemon_config::PrivilegedOperatorPrompt::default(),
176            activity_config: daemon_config::ActivityConfig::default(),
177            daemon_config_path: None,
178            configured_port: None,
179            route_bind_relay_default_ms: None,
180            reserved_capabilities: BTreeMap::new(),
181            watchdog_config: DaemonSelfWatchdogConfig::default(),
182            connection_file_source: ConnectionFileSource::Explicit,
183            cgroup_placement: CgroupPlacementConfig::default(),
184            capture_logs_dir: None,
185            terminal_journal_path: None,
186            machine_id_path: None,
187            live_children_path: None,
188            privacy_trampoline: None,
189        }
190    }
191
192    /// Keep the live-children record at `path`, and at startup end the
193    /// processes a previous daemon recorded there that are still running.
194    /// Embedding daemons and tests pass a path inside their own fixture tree.
195    pub fn with_live_children_record(mut self, path: impl Into<PathBuf>) -> Self {
196        self.live_children_path = Some(path.into());
197        self
198    }
199
200    /// macOS module launches require an explicit executable implementing the
201    /// subc-os trampoline entry point. Other systems ignore this setting.
202    pub fn with_privacy_trampoline(mut self, path: impl Into<PathBuf>) -> Self {
203        self.privacy_trampoline = Some(path.into());
204        self
205    }
206
207    /// Serve the machine id stored at `path`, minting it there at startup when
208    /// the file is absent. Embedding daemons and tests pass a path inside their
209    /// own fixture tree.
210    pub fn with_machine_id_path(mut self, path: impl Into<PathBuf>) -> Self {
211        self.machine_id_path = Some(path.into());
212        self
213    }
214
215    /// Selects module cgroup placement. The default is disabled.
216    pub fn with_cgroup_placement(mut self, placement: CgroupPlacementConfig) -> Self {
217        self.cgroup_placement = placement;
218        self
219    }
220
221    /// Redirects per-module stdout/stderr capture files out of the real run
222    /// directory. Tests that start an in-process daemon must call this with a
223    /// path inside their fixture tree.
224    pub fn with_capture_logs_dir(mut self, dir: impl Into<PathBuf>) -> Self {
225        self.capture_logs_dir = Some(dir.into());
226        self
227    }
228
229    /// Redirects the daemon-private journal, allowing embedded daemons and tests
230    /// to keep their observations out of the operator's live run directory.
231    pub fn with_terminal_journal_path(mut self, path: impl Into<PathBuf>) -> Self {
232        self.terminal_journal_path = Some(path.into());
233        self
234    }
235
236    pub fn from_env() -> Result<Self, BootstrapError> {
237        Self::from_env_with_daemon_config_path(daemon_config::default_config_path())
238    }
239
240    /// The SHIPPED BINARY's config, which is the only caller that should capture
241    /// child output into the operator's real run directory.
242    ///
243    /// Kept separate from `from_env` deliberately: see `capture_logs_dir` and
244    /// `terminal_journal_path` on this struct for why an absent value must mean
245    /// NO CAPTURE and NO JOURNAL rather than the operator's real run directory.
246    /// A run directory that cannot be resolved (a relative data home) refuses
247    /// startup instead of landing under the working directory.
248    pub fn from_env_for_daemon_binary() -> Result<Self, BootstrapError> {
249        let run_dir = daemon_config::daemon_run_dir().map_err(BootstrapError::RunDir)?;
250        let machine_id_path =
251            crate::machine_id::default_machine_id_path().map_err(BootstrapError::MachineId)?;
252        let config = Self::from_env()?
253            .with_capture_logs_dir(run_dir.join("logs"))
254            .with_terminal_journal_path(run_dir.join("terminals.jsonl"))
255            .with_live_children_record(crate::live_children::record_path(&run_dir))
256            .with_machine_id_path(machine_id_path);
257        #[cfg(feature = "test-support")]
258        let config = {
259            let mut config = config;
260            config.operator_script =
261                std::env::var_os("SUBC_TEST_OPERATOR_SCRIPT").map(PathBuf::from);
262            config.operator_events =
263                std::env::var_os("SUBC_TEST_OPERATOR_EVENTS").map(PathBuf::from);
264            for (name, value) in [
265                (
266                    "SUBC_TEST_OPERATOR_QUEUE_WAIT_MS",
267                    &mut config.operator_queue_wait,
268                ),
269                (
270                    "SUBC_TEST_OPERATOR_TIMEOUT_MS",
271                    &mut config.operator_timeout,
272                ),
273                (
274                    "SUBC_TEST_OPERATOR_STUCK_MS",
275                    &mut config.operator_stuck_grace,
276                ),
277            ] {
278                if let Ok(ms) = std::env::var(name) {
279                    if let Ok(ms) = ms.parse::<u64>() {
280                        *value = Duration::from_millis(ms);
281                    }
282                }
283            }
284            config
285        };
286        Ok(config)
287    }
288
289    pub fn from_env_with_daemon_config_path(
290        daemon_config_path: impl AsRef<Path>,
291    ) -> Result<Self, BootstrapError> {
292        let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
293        let (daemon_config, activity_config) =
294            daemon_config::load_with_activity(&daemon_config_path)
295                .map_err(BootstrapError::DaemonConfig)?
296                .map(|(config, activity)| (Some(config), activity))
297                .unwrap_or_default();
298        let config_port = daemon_config.as_ref().and_then(|config| config.port);
299        let storage_config = daemon_config
300            .as_ref()
301            .and_then(|config| config.storage.clone());
302        let admission_facts_carrier_module_id = daemon_config
303            .as_ref()
304            .and_then(|config| config.admission_facts_carrier_module_id.clone());
305        let admission_facts_targets = daemon_config
306            .as_ref()
307            .and_then(|config| config.admission_facts_targets.clone());
308        let scope_authority_owners = daemon_config
309            .as_ref()
310            .map(|config| config.scope_authority_owners.clone())
311            .unwrap_or_else(daemon_config::default_scope_authority_owners);
312        let privileged_operator_prompt = daemon_config
313            .as_ref()
314            .map(|config| config.privileged_operator_prompt)
315            .unwrap_or_default();
316        let route_bind_relay_default_ms = daemon_config
317            .as_ref()
318            .and_then(|config| config.route_bind_relay_timeout_ms);
319        let reserved_capabilities = daemon_config
320            .as_ref()
321            .map(|config| config.reserved_capabilities.clone())
322            .unwrap_or_default();
323        let configured_modules = daemon_config
324            .map(|config| config.modules)
325            .unwrap_or_default();
326
327        let port = match env::var(SUBC_PORT_ENV) {
328            Ok(raw) if !raw.trim().is_empty() => {
329                let port = raw
330                    .parse::<u16>()
331                    .map_err(|source| BootstrapError::InvalidPort { raw, source })?;
332                if let Some(config_port) = config_port {
333                    info!(
334                        env = SUBC_PORT_ENV,
335                        env_port = port,
336                        config_port,
337                        "SUBC_PORT overrides daemon config port"
338                    );
339                }
340                port
341            }
342            Ok(_) | Err(_) => config_port.unwrap_or(DEFAULT_SUBC_PORT),
343        };
344
345        let (connection_file_path, connection_file_source) =
346            connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR"));
347        Ok(Self::new(connection_file_path, port)
348            .with_configured_modules(configured_modules)
349            .with_storage_config(storage_config)
350            .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
351            .with_scope_authority_owners(scope_authority_owners)
352            .with_privileged_operator_prompt(privileged_operator_prompt)
353            .with_activity_config(activity_config)
354            .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
355            .with_reserved_capabilities(reserved_capabilities)
356            .with_daemon_config_source(daemon_config_path, config_port)
357            .with_connection_file_source(connection_file_source))
358    }
359
360    pub fn with_daemon_config_path(
361        self,
362        daemon_config_path: impl AsRef<Path>,
363    ) -> Result<Self, BootstrapError> {
364        let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
365        let (daemon_config, activity_config) =
366            daemon_config::load_with_activity(&daemon_config_path)
367                .map_err(BootstrapError::DaemonConfig)?
368                .map(|(config, activity)| (Some(config), activity))
369                .unwrap_or_default();
370        let configured_port = daemon_config.as_ref().and_then(|config| config.port);
371        let storage_config = daemon_config
372            .as_ref()
373            .and_then(|config| config.storage.clone());
374        let admission_facts_carrier_module_id = daemon_config
375            .as_ref()
376            .and_then(|config| config.admission_facts_carrier_module_id.clone());
377        let admission_facts_targets = daemon_config
378            .as_ref()
379            .and_then(|config| config.admission_facts_targets.clone());
380        let scope_authority_owners = daemon_config
381            .as_ref()
382            .map(|config| config.scope_authority_owners.clone())
383            .unwrap_or_else(daemon_config::default_scope_authority_owners);
384        let privileged_operator_prompt = daemon_config
385            .as_ref()
386            .map(|config| config.privileged_operator_prompt)
387            .unwrap_or_default();
388        let route_bind_relay_default_ms = daemon_config
389            .as_ref()
390            .and_then(|config| config.route_bind_relay_timeout_ms);
391        let reserved_capabilities = daemon_config
392            .as_ref()
393            .map(|config| config.reserved_capabilities.clone())
394            .unwrap_or_default();
395        let configured_modules = daemon_config
396            .map(|config| config.modules)
397            .unwrap_or_default();
398        Ok(self
399            .with_configured_modules(configured_modules)
400            .with_storage_config(storage_config)
401            .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
402            .with_scope_authority_owners(scope_authority_owners)
403            .with_privileged_operator_prompt(privileged_operator_prompt)
404            .with_activity_config(activity_config)
405            .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
406            .with_reserved_capabilities(reserved_capabilities)
407            .with_daemon_config_source(daemon_config_path, configured_port))
408    }
409
410    pub fn with_configured_modules(
411        mut self,
412        modules: impl IntoIterator<Item = ConfiguredModule>,
413    ) -> Self {
414        self.configured_modules = modules.into_iter().collect();
415        self.configured_modules
416            .sort_by(|left, right| left.module_id.cmp(&right.module_id));
417        self
418    }
419
420    pub fn with_storage_config(
421        mut self,
422        storage_config: Option<daemon_config::StorageConfig>,
423    ) -> Self {
424        self.storage_config = storage_config;
425        self
426    }
427
428    pub fn with_admission_facts_config(
429        mut self,
430        carrier_module_id: Option<String>,
431        targets: Option<Vec<String>>,
432    ) -> Self {
433        self.admission_facts = AdmissionFactsConfig {
434            carrier_module_id,
435            targets,
436        };
437        self
438    }
439
440    /// Set the module ids whose scopes may carry `agent_id` and `delegates`.
441    /// Absent from a config, this is `daemon_config::default_scope_authority_owners`.
442    pub fn with_scope_authority_owners(mut self, owners: Vec<String>) -> Self {
443        self.scope_authority_owners = owners;
444        self
445    }
446
447    /// Choose the privileged-operation policy for the daemon's entire lifetime.
448    pub fn with_privileged_operator_prompt(
449        mut self,
450        mode: daemon_config::PrivilegedOperatorPrompt,
451    ) -> Self {
452        self.privileged_operator_prompt = mode;
453        self
454    }
455
456    pub fn with_activity_config(mut self, config: daemon_config::ActivityConfig) -> Self {
457        self.activity_config = config;
458        self
459    }
460
461    /// Set the daemon-wide route.bind relay default (the fallback for any
462    /// module without a per-module override). `None` preserves the built-in
463    /// default (12s). `serve_bound_daemon` reads this at startup and threads
464    /// it into the control handler's daemon-wide field.
465    pub fn with_route_bind_relay_default_ms(mut self, ms: Option<u64>) -> Self {
466        self.route_bind_relay_default_ms = ms;
467        self
468    }
469
470    pub fn with_reserved_capabilities(
471        mut self,
472        reserved_capabilities: BTreeMap<String, String>,
473    ) -> Self {
474        self.reserved_capabilities = reserved_capabilities;
475        self
476    }
477
478    fn with_daemon_config_source(
479        mut self,
480        daemon_config_path: PathBuf,
481        configured_port: Option<u16>,
482    ) -> Self {
483        self.daemon_config_path = Some(daemon_config_path);
484        self.configured_port = configured_port;
485        self
486    }
487
488    fn with_connection_file_source(mut self, source: ConnectionFileSource) -> Self {
489        self.connection_file_source = source;
490        self
491    }
492
493    pub fn with_watchdog_config(mut self, watchdog_config: DaemonSelfWatchdogConfig) -> Self {
494        self.watchdog_config = watchdog_config;
495        self
496    }
497}
498
499/// Result of singleton discovery.
500// Built once per daemon start and immediately matched, so the size gap between
501// the variants costs nothing worth a box on the public shape.
502#[allow(clippy::large_enum_variant)]
503#[derive(Debug)]
504pub enum Outcome {
505    /// A live daemon authenticated from the connection file; this invocation should exit 0.
506    AlreadyRunning,
507    /// This process won the singleton race, owns bound loopback listener(s), and
508    /// has published a fresh connection file.
509    Bound(BoundDaemon),
510}
511
512#[derive(Debug)]
513pub struct BoundDaemon {
514    pub listeners: Vec<TcpListener>,
515    pub connection_info: ConnectionInfo,
516    pub connection_file_path: PathBuf,
517    pub connection_file_source: ConnectionFileSource,
518    /// The machine id this daemon serves, established before any connection is
519    /// accepted. `None` when the config named no machine id path.
520    pub machine_id: Option<crate::machine_id::MachineId>,
521    /// Ownership of the run directory, held until the daemon exits so no other
522    /// daemon sweeps or writes this one's run state. `None` when the config
523    /// keeps no live-children record, and so has no run directory to own.
524    run_dir_lock: Option<crate::run_dir_lock::RunDirLock>,
525    /// The singleton start lock, kept when binding deferred publishing the
526    /// connection file. It stays held until the daemon has verified it is serving
527    /// and written the connection file, so a second daemon starting meanwhile
528    /// cannot claim the singleton too. `None` when the connection
529    /// file was already written at bind time.
530    publication_lock: Option<StartLock>,
531}
532
533/// Resolve subc's per-user TCP connection-file path.
534///
535/// `$XDG_RUNTIME_DIR/subc-connection.json` is preferred because the runtime
536/// directory is already per-user on Unix desktops. Without it, subc falls back
537/// to the system temp dir with a per-user token in the filename so different OS
538/// users do not collide on shared temp directories.
539pub fn connection_file_path() -> PathBuf {
540    connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR")).0
541}
542
543fn connection_file_path_with_source(
544    runtime_dir: Option<OsString>,
545) -> (PathBuf, ConnectionFileSource) {
546    if let Some(runtime_dir) = runtime_dir.filter(|value| !value.is_empty()) {
547        return (
548            PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
549            ConnectionFileSource::XdgRuntimeDir,
550        );
551    }
552
553    (
554        env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token())),
555        ConnectionFileSource::TempDirFallback,
556    )
557}
558
559/// Resolve, claim, and serve the per-user daemon singleton.
560///
561/// A second invocation is successful: if a live daemon authenticates from the
562/// existing connection file, this returns `Ok(())` after logging and the caller
563/// exits with status 0.
564pub async fn run() -> Result<(), BootstrapError> {
565    // `run` is a binary entry point, so it opts into the production cgroup and
566    // child-log locations. In-process callers keep both features disabled by default.
567    run_with_config(
568        BootstrapConfig::from_env_for_daemon_binary()?
569            .with_cgroup_placement(CgroupPlacementConfig::Current),
570    )
571    .await
572}
573
574/// Serve a daemon from an explicit config. This is the entry point the twelve
575/// sibling repos use to boot an in-process daemon in their integration tests.
576///
577/// # THIS INSTALLS NO TRACING SUBSCRIBER, SO THE DAEMON IS SILENT BY DEFAULT
578///
579/// The daemon's own diagnostics go through `tracing`, and `tracing` DISCARDS
580/// every event when no subscriber is installed. The shipped binary installs one
581/// in `main` (`init_tracing`); this function deliberately does not, because a
582/// library that installs a global subscriber fights with whatever the host
583/// process already set up.
584///
585/// The consequence for a test harness is not "less verbose": it is that the
586/// daemon has NOTHING TO SAY about any failure, and a missing instrument reads
587/// exactly like a clean one. PLEX found this on 2026-09-18 while trying to
588/// capture daemon logs beside an intermittent bind failure, and discovered the
589/// daemon had been silent in every conformance run that repo had ever done --
590/// so the one client-side error string was all the evidence that could exist,
591/// and they had spent a real investigation on a failure whose second source was
592/// never being recorded.
593///
594/// Install one in the harness before calling this, and assert it did something
595/// (they measured 236 daemon lines with the subscriber installed, 0 with the
596/// call commented out) -- otherwise the fix is itself unverified.
597pub async fn run_with_config(config: BootstrapConfig) -> Result<(), BootstrapError> {
598    let forwarding = Arc::new(ForwardingTable::default());
599    forwarding.operator_confirms().configure_bootstrap(&config);
600    let configured_modules = config.configured_modules.clone();
601    let storage_config = config.storage_config.clone();
602    let admission_facts = config.admission_facts.clone();
603    let scope_authority_owners = config.scope_authority_owners.clone();
604    let privileged_operator_prompt = config.privileged_operator_prompt;
605    let activity_config = config.activity_config;
606    let daemon_config_path = config.daemon_config_path.clone();
607    let configured_port = config.configured_port;
608    let route_bind_relay_default_ms = config.route_bind_relay_default_ms;
609    let reserved_capabilities = config.reserved_capabilities.clone();
610    let watchdog_config = config.watchdog_config.clone();
611    let cgroup_placement_config = config.cgroup_placement.clone();
612    let capture_logs_dir = config.capture_logs_dir.clone();
613    let terminal_journal_path = config.terminal_journal_path.clone();
614    let live_children_path = config.live_children_path.clone();
615    let privacy_trampoline = config.privacy_trampoline.clone();
616    match ensure_singleton_inner(config, false).await? {
617        Outcome::AlreadyRunning => {
618            info!("subc daemon already running");
619            Ok(())
620        }
621        Outcome::Bound(bound) => {
622            #[cfg(target_os = "linux")]
623            let cgroup_placement = prepare_cgroup_placement(&cgroup_placement_config);
624            #[cfg(not(target_os = "linux"))]
625            let _ = cgroup_placement_config;
626            serve_bound_daemon(
627                bound,
628                forwarding,
629                configured_modules,
630                storage_config,
631                admission_facts,
632                scope_authority_owners,
633                privileged_operator_prompt,
634                activity_config,
635                daemon_config_path,
636                configured_port,
637                route_bind_relay_default_ms,
638                reserved_capabilities,
639                watchdog_config,
640                capture_logs_dir,
641                terminal_journal_path,
642                live_children_path,
643                privacy_trampoline,
644                #[cfg(target_os = "linux")]
645                cgroup_placement,
646            )
647            .await
648        }
649    }
650}
651
652#[cfg(target_os = "linux")]
653fn prepare_cgroup_placement(config: &CgroupPlacementConfig) -> Option<subc_cgroup::Placement> {
654    let result = match config {
655        CgroupPlacementConfig::Disabled => return None,
656        CgroupPlacementConfig::Current => subc_cgroup::prepare_current(),
657        CgroupPlacementConfig::Root(root) => subc_cgroup::prepare_at(root),
658    };
659
660    match result {
661        Ok(Some(placement)) => Some(placement),
662        Ok(None) => {
663            warn!(
664                placement = ?config,
665                "module cgroup placement is disabled: configured cgroup root is not delegated"
666            );
667            None
668        }
669        Err(error) => {
670            warn!(
671                placement = ?config,
672                error = %error,
673                "module cgroup placement is disabled by an unexpected cgroup probe error"
674            );
675            None
676        }
677    }
678}
679
680/// Target soft limit for open file descriptors, applied to the daemon before any
681/// module is spawned so children inherit it. Multi-root modules (one process
682/// aggregating every project root's sqlite stores, index caches, watchers, and
683/// LSP pipes) trivially exceed the macOS default soft limit of 256; a launchd
684/// user agent does not pass login-shell ulimits through, so the raise must
685/// happen in-process.
686#[cfg(unix)]
687const NOFILE_TARGET: u64 = 65536;
688
689/// Raise RLIMIT_NOFILE to `NOFILE_TARGET` (clamped to the hard limit).
690/// Best-effort: failure is logged and never fatal, since the daemon can run
691/// under the inherited limit — modules with few roots just have less headroom.
692#[cfg(unix)]
693fn raise_nofile_limit() {
694    #[cfg(target_os = "macos")]
695    let ceiling = std::process::Command::new("/usr/sbin/sysctl")
696        .args(["-n", "kern.maxfilesperproc"])
697        .output()
698        .ok()
699        .filter(|output| output.status.success())
700        .and_then(|output| String::from_utf8(output.stdout).ok())
701        .and_then(|value| value.trim().parse::<u64>().ok())
702        .filter(|value| *value > 0);
703    #[cfg(not(target_os = "macos"))]
704    let ceiling = None;
705    raise_nofile_limit_with_ceiling(ceiling);
706}
707
708#[cfg(unix)]
709fn raise_nofile_limit_with_ceiling(kernel_ceiling: Option<u64>) {
710    match rlimit::Resource::NOFILE.get() {
711        Ok((soft, hard)) => {
712            // On XNU an infinite hard limit does not remove the separate
713            // per-process kernel ceiling. Exceeding it makes setrlimit fail
714            // without raising the inherited (often 256) soft limit at all.
715            let target = NOFILE_TARGET
716                .min(hard)
717                .min(kernel_ceiling.unwrap_or(u64::MAX));
718            if soft >= target {
719                return;
720            }
721            match rlimit::Resource::NOFILE.set(target, hard) {
722                Ok(()) => info!(
723                    previous_soft = soft,
724                    new_soft = target,
725                    hard,
726                    "raised open-file soft limit for daemon and module children"
727                ),
728                Err(err) => warn!(
729                    soft,
730                    hard,
731                    error = %err,
732                    "could not raise open-file soft limit; multi-root modules may exhaust descriptors"
733                ),
734            }
735        }
736        Err(err) => warn!(error = %err, "could not read open-file limit"),
737    }
738}
739
740/// CRT stdio-stream target on Windows (the `_setmaxstdio` maximum). Win32
741/// HANDLEs — what Rust `File`, tokio sockets, and SQLite's Win32 VFS actually
742/// consume — have a per-process quota in the millions and need no raise; the
743/// C-runtime stream table (default 512) is the only low ceiling, and it is
744/// per-process rather than inherited, so supervised modules linking the CRT
745/// must raise their own. Raising it here covers the daemon itself.
746#[cfg(windows)]
747fn raise_nofile_limit() {
748    const MAXSTDIO_TARGET: u32 = 8192;
749    let current = rlimit::getmaxstdio();
750    if current >= MAXSTDIO_TARGET {
751        return;
752    }
753    match rlimit::setmaxstdio(MAXSTDIO_TARGET) {
754        Ok(new_max) => info!(
755            previous = current,
756            new_max, "raised CRT stdio-stream limit for daemon"
757        ),
758        Err(err) => warn!(
759            current,
760            error = %err,
761            "could not raise CRT stdio-stream limit"
762        ),
763    }
764}
765
766#[cfg(not(any(unix, windows)))]
767fn raise_nofile_limit() {}
768
769pub async fn run_with_daemon_config_path(
770    config: BootstrapConfig,
771    daemon_config_path: impl AsRef<Path>,
772) -> Result<(), BootstrapError> {
773    run_with_config(config.with_daemon_config_path(daemon_config_path)?).await
774}
775
776#[allow(clippy::too_many_arguments)]
777async fn serve_bound_daemon(
778    bound: BoundDaemon,
779    forwarding: Arc<ForwardingTable>,
780    configured_modules: Vec<ConfiguredModule>,
781    storage_config: Option<daemon_config::StorageConfig>,
782    admission_facts: AdmissionFactsConfig,
783    scope_authority_owners: Vec<String>,
784    privileged_operator_prompt: daemon_config::PrivilegedOperatorPrompt,
785    activity_config: daemon_config::ActivityConfig,
786    daemon_config_path: Option<PathBuf>,
787    configured_port: Option<u16>,
788    route_bind_relay_default_ms: Option<u64>,
789    reserved_capabilities: BTreeMap<String, String>,
790    watchdog_config: DaemonSelfWatchdogConfig,
791    capture_logs_dir: Option<PathBuf>,
792    terminal_journal_path: Option<PathBuf>,
793    live_children_path: Option<PathBuf>,
794    privacy_trampoline: Option<PathBuf>,
795    #[cfg(target_os = "linux")] cgroup_placement: Option<subc_cgroup::Placement>,
796) -> Result<(), BootstrapError> {
797    #[cfg(unix)]
798    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
799        .map_err(BootstrapError::Signal)?;
800    // Windows has no SIGTERM. Ctrl-C is a different event, not an equivalent
801    // service-stop contract, so this shutdown handler is intentionally Unix-only.
802    raise_nofile_limit();
803
804    info!(
805        connection_file = %bound.connection_file_path.display(),
806        connection_file_source = %bound.connection_file_source,
807        connection_file_source_reason = bound.connection_file_source.reason(),
808        endpoints = ?bound.connection_info.endpoints,
809        configured_modules = configured_modules.len(),
810        machine_id = bound.machine_id.as_ref().map(|id| id.as_str()).unwrap_or("none"),
811        "subc daemon starting"
812    );
813
814    // Before anything can spawn a module (the configured modules below, or a
815    // client's start request once the listeners are served): a previous daemon
816    // that died without its shutdown stop may have left children running, and
817    // a fresh copy beside one would fight it for its port and stores. The
818    // record is not a running daemon's because this daemon holds the lock on
819    // the run directory the record lives in, and a running daemon holds that
820    // lock for its whole life. (Claiming the singleton is not enough: it is
821    // keyed on the connection file, which can live in a different runtime
822    // directory from the run directory.) The lock stays held until this
823    // function returns, which is when the daemon exits.
824    let run_dir_lock = bound.run_dir_lock;
825    let publication_lock = bound.publication_lock;
826    if let Some(owner) = &run_dir_lock {
827        crate::live_children::sweep_orphans(
828            owner,
829            &crate::live_children::AdoptedPids::none(),
830            crate::live_children::SweepBounds::default(),
831        )
832        .await;
833    }
834
835    let registry = Arc::new(Registry::default());
836    let process_liveness = Arc::new(SupervisorProcessLiveness::new());
837    let supervisor_handle = SupervisorHandle::new();
838    let connected_clients = ConnectedClients::new();
839    let daemon_incarnation = format!(
840        "{:032x}",
841        u128::from_be_bytes(bound.connection_info.daemon_id)
842    );
843    let supervisor = Supervisor::new(Arc::clone(&registry), RestartPolicy::default())
844        .with_process_liveness(process_liveness.clone())
845        .with_forwarding(Arc::clone(&forwarding))
846        .with_handle(supervisor_handle.clone())
847        .with_connection_file_path(bound.connection_file_path.clone())
848        .with_daemon_incarnation(daemon_incarnation.clone());
849    let supervisor = match privacy_trampoline {
850        Some(path) => supervisor.with_privacy_trampoline(path),
851        None => supervisor,
852    };
853    // ABSENT MEANS NO CAPTURE AND NO JOURNAL, NOT "THE REAL RUN DIRECTORY", and
854    // the difference is a production-corruption hazard rather than a preference.
855    // Both fields below follow the same rule: `None` for `capture_logs_dir`
856    // means supervised output is not captured, and `None` for
857    // `terminal_journal_path` means terminal history lives only in each
858    // module's in-memory ring (`supervisor.terminals` still answers, with the
859    // journal counters at zero).
860    //
861    // The terminal journal used to fall back to
862    // `daemon_run_dir().join("terminals.jsonl")`, so an in-process test daemon
863    // appended its fixture exits to the operator's journal, where they then
864    // appeared in `ck module terminals`.
865    //
866    // The capture line used to be `unwrap_or_else(|| daemon_run_dir().join("logs"))`,
867    // so ANY caller that did not set the field captured supervised children into
868    // the operator's live `~/.local/share/cortexkit/run/logs/`. That is twelve
869    // sibling repos whose integration tests boot an in-process daemon through
870    // `run_with_config` -- none of which asked for it, and none of which can see
871    // it from their side.
872    //
873    // Harmless while fixture module ids are fixture-shaped: this host carries 20
874    // zero-byte files from subc's own tests (good-aft, missing-aft,
875    // preview-consumer...). THE HAZARD IS A COLLISION. A fixture named "broca"
876    // or "aft" appends to a PRODUCTION capture file that operators read
877    // forensically and that placement gates count lines in -- with no residue to
878    // notice, because the file legitimately exists and legitimately grows.
879    //
880    // Found by BROCA (2026-09-19) from the other side: their rigs spawn the
881    // SHIPPED ck-subc and set XDG_CONFIG_HOME + XDG_RUNTIME_DIR but not
882    // XDG_DATA_HOME, so every local rig run supervised a module named "broca"
883    // and captured it into production's broca.stderr.log -- the same file I
884    // count seal lines in before and after placing their binaries.
885    //
886    // The supervisor already treats `None` as no-capture and no-journal, so
887    // this only removes invented defaults. The binary keeps capturing and
888    // journaling via `BootstrapConfig::from_env_for_daemon_binary`.
889
890    // Keep both ledgers in the explicitly selected run directory. An embedded
891    // daemon without a journal path must not write to the operator's live ledger.
892    let activity_run_dir = terminal_journal_path
893        .as_ref()
894        .and_then(|path| path.parent())
895        .map(Path::to_path_buf);
896    let supervisor = match terminal_journal_path {
897        Some(path) => supervisor.with_terminal_journal(path, daemon_incarnation.clone()),
898        None => supervisor,
899    };
900    let supervisor = match capture_logs_dir {
901        Some(dir) => supervisor.with_capture_logs_dir(dir),
902        None => supervisor,
903    };
904    let supervisor = match live_children_path {
905        Some(path) => supervisor.with_live_children_record(path),
906        None => supervisor,
907    };
908    #[cfg(target_os = "linux")]
909    let supervisor = supervisor.with_cgroup_placement(cgroup_placement);
910    // Collect per-module route.bind relay overrides BEFORE handing the
911    // `configured_modules` vector to the supervisor (which only needs each
912    // module's `drain_timeout_ms`). Each entry was filled in by parse-time
913    // resolution (per-module > daemon-wide > absent), so modules with no
914    // override are absent from this map and the daemon-wide default applies.
915    let route_bind_relay_timeouts = configured_modules
916        .iter()
917        .filter_map(|module| {
918            module
919                .route_bind_relay_timeout_ms
920                .map(|ms| (module.module_id.clone(), Duration::from_millis(ms)))
921        })
922        .collect::<std::collections::BTreeMap<_, _>>();
923    let control_start_clock = crate::clock::StartClock::capture();
924    let mut control = ControlHandler::with_forwarding(Arc::clone(&registry), forwarding)
925        .with_process_liveness(process_liveness)
926        .with_supervisor(supervisor_handle)
927        .with_connected_clients(connected_clients.clone())
928        .with_storage_config(storage_config)
929        .with_machine_id(bound.machine_id.clone())
930        .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets)
931        .with_scope_authority_owners(scope_authority_owners)
932        .with_privileged_operator_prompt(privileged_operator_prompt)
933        .with_route_bind_relay_timeouts(route_bind_relay_timeouts)
934        .with_daemon_provenance(
935            bound.connection_info.pid,
936            control_start_clock.started_at_ms(),
937            std::env::current_exe().ok(),
938            normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")),
939            normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")),
940        )
941        .with_daemon_start_clock(control_start_clock)
942        .with_capability_config(
943            configured_modules
944                .iter()
945                .map(|module| (module.module_id.clone(), module.enabled)),
946            reserved_capabilities,
947        );
948    if let Some(run_dir) = activity_run_dir {
949        control =
950            control.with_activity_ledger_config(&run_dir, daemon_incarnation, activity_config);
951    }
952    if let Some(ms) = route_bind_relay_default_ms {
953        // A daemon-wide config value overrides the built-in default; a
954        // `None` here leaves the ControlHandler's 12s default in place.
955        control = control.with_route_bind_relay_timeout(Duration::from_millis(ms));
956    }
957    if let Some(config_path) = daemon_config_path {
958        control = control.with_supervisor_rescan(supervisor.clone(), config_path, configured_port);
959    }
960    let control = Arc::new(control);
961    let router = Arc::new(Router::with_control_handler(Arc::clone(&control)));
962    let auth = ServerAuth::new(
963        bound.connection_info.key.clone(),
964        bound.connection_info.daemon_id,
965        bound.connection_info.daemon_ver.clone(),
966    )
967    .with_connected_clients(connected_clients);
968
969    // Discovery must expose every accepted configured module, including disabled
970    // ones. Registration creates status handles but neither starts monitors
971    // nor launches processes: children need the published file for `--subc`.
972    let mut registered_modules = Vec::with_capacity(configured_modules.len());
973    for configured in configured_modules {
974        let enabled = configured.enabled;
975        let health = configured.health.clone();
976        let module_id = configured.module_id.clone();
977        let registered = match supervisor.register_configured_with_health(
978            configured.module_spec(),
979            enabled,
980            health.clone(),
981            configured.drain_timeout_ms,
982            configured.restart,
983        ) {
984            Ok(registered) => registered,
985            Err(err) => {
986                error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
987                continue;
988            }
989        };
990        registered_modules.push(registered);
991        // A relaxed threshold can silently outlive the temporary allowance
992        // that required it, so report the extra tolerance on every boot.
993        let default_threshold = HealthConfig::default().failure_threshold;
994        if enabled && health.failure_threshold > default_threshold {
995            warn!(
996                module_id = %module_id,
997                failure_threshold = health.failure_threshold,
998                default_threshold,
999                tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
1000                "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
1001            );
1002        }
1003        info!(module_id = %module_id, enabled, "configured module registered");
1004    }
1005    control.refresh_capability_requirements();
1006
1007    let mut serve_task =
1008        AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
1009    verify_serving(&bound.connection_info).await?;
1010    // A published endpoint promises that authentication can complete now.
1011    // Orphan cleanup and control-plane construction can take longer than an
1012    // existing-daemon probe's deadline, so finish both before discovery.
1013    // The write fsyncs, so it runs on tokio's blocking pool (see
1014    // `publish_connection_file`). The singleton lock travels with it and comes
1015    // back only once the file is in place.
1016    let (published, publication_lock) = publish_connection_file(
1017        &bound.connection_file_path,
1018        &bound.connection_info,
1019        publication_lock,
1020    )
1021    .await;
1022    published?;
1023    #[cfg(test)]
1024    startup_publication_hook::run(&bound.connection_file_path).await;
1025    drop(publication_lock);
1026    let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
1027    // Off the startup path: it runs `systemctl`, and only ever logs.
1028    #[cfg(target_os = "linux")]
1029    let _kill_mode_check = AbortOnDrop::new(tokio::spawn(
1030        crate::systemd_kill_mode::warn_if_kill_mode_defeats_ordered_shutdown(),
1031    ));
1032    let _watchdog_task = AbortOnDrop::new(
1033        DaemonSelfWatchdog::new(
1034            bound.connection_info.clone(),
1035            bound.connection_file_path.clone(),
1036        )
1037        .with_config(watchdog_config)
1038        .spawn(),
1039    );
1040
1041    for registered in registered_modules {
1042        match supervisor.start_registered_configured(registered, None) {
1043            Ok(module) => {
1044                info!(module_id = %module.module_id(), "configured module supervised");
1045            }
1046            Err(err) => {
1047                error!(error = %err, "failed to start configured module; continuing daemon startup");
1048            }
1049        }
1050    }
1051
1052    control.refresh_capability_requirements();
1053    Arc::clone(&control).spawn_capability_deadline_loop();
1054    Arc::clone(&control).spawn_scope_expiry_loop();
1055
1056    #[cfg(unix)]
1057    {
1058        tokio::select! {
1059            result = serve_task.join() => {
1060                return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
1061            }
1062            _ = terminate.recv() => {}
1063        }
1064        // First, before the notice, the drain, or any connection close: from
1065        // here on a module exit is recorded as `daemon_shutdown` and never
1066        // respawned. Also before allowing a second signal to cut the bounded
1067        // wait short, so the journal marker is always written.
1068        supervisor.begin_daemon_shutdown();
1069        // Stop the self-watchdog before closing the listener. Its next tick would
1070        // connect to that listener, fail, and log an ERROR indistinguishable from
1071        // a wedged daemon, once for every interval a planned stop lasts.
1072        drop(_watchdog_task);
1073        // Dropping the listener stops new accepts, not established connections:
1074        // their detached tasks must remain live throughout notice and drain.
1075        drop(serve_task);
1076        let escalated = tokio::select! {
1077            biased;
1078            _ = terminate.recv() => {
1079                info!("second SIGTERM: abandoning daemon shutdown wait");
1080                true
1081            }
1082            result = supervisor.drain_for_daemon_shutdown() => {
1083                if let Err(error) = result {
1084                    warn!(%error, "daemon shutdown drain failed; exiting anyway");
1085                }
1086                false
1087            }
1088        };
1089        // Supervised modules lead their own process groups, so a service
1090        // manager's kill of this process's group does not reach them. (A
1091        // systemd unit with KillMode=control-group kills by cgroup instead and
1092        // does reach them; see `systemd_kill_mode`.) The daemon ends them
1093        // itself: EOF (or SIGTERM for a protocol none child) first, then
1094        // signals at each child's own drain deadline.
1095        supervisor
1096            .end_children_for_daemon_shutdown(escalated, async {
1097                terminate.recv().await;
1098            })
1099            .await;
1100        Ok(())
1101    }
1102    #[cfg(not(unix))]
1103    serve_task
1104        .join()
1105        .await
1106        .map_err(BootstrapError::ServeJoin)?
1107        .map_err(BootstrapError::Serve)
1108}
1109
1110fn normalized_build_provenance(value: &str) -> Option<String> {
1111    match value.trim() {
1112        "" | "unavailable" => None,
1113        value => Some(value.to_string()),
1114    }
1115}
1116
1117/// Find an existing daemon or atomically bind loopback TCP for this daemon.
1118///
1119/// The algorithm is intentionally connect-first: an endpoint from the connection
1120/// file is treated as live only after the TCP+key server-proof authenticates for
1121/// that file's key and daemon_id. Stale or foreign connection files are reclaimed
1122/// only while holding the per-user start lock; the TCP port is never the
1123/// singleton primitive.
1124pub async fn ensure_singleton(
1125    connection_file_path: impl AsRef<Path>,
1126    port: u16,
1127) -> Result<Outcome, BootstrapError> {
1128    ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
1129}
1130
1131pub async fn ensure_singleton_with_config(
1132    config: BootstrapConfig,
1133) -> Result<Outcome, BootstrapError> {
1134    ensure_singleton_inner(config, true).await
1135}
1136
1137async fn ensure_singleton_inner(
1138    config: BootstrapConfig,
1139    publish: bool,
1140) -> Result<Outcome, BootstrapError> {
1141    let path = config.connection_file_path;
1142
1143    if matches!(probe_existing(&path).await?, Probe::Live) {
1144        return Ok(Outcome::AlreadyRunning);
1145    }
1146
1147    let lock = StartLock::acquire(&path).await?;
1148
1149    // Re-probe after acquiring the start lock so a peer that won the race between
1150    // our first failed probe and the lock acquisition is observed instead of
1151    // overwritten.
1152    if matches!(probe_existing(&path).await?, Probe::Live) {
1153        return Ok(Outcome::AlreadyRunning);
1154    }
1155
1156    // The start lock and the probe above only rule out a daemon using this
1157    // connection file. A daemon started with another runtime directory but
1158    // the same data home shares the run directory while passing both, so the
1159    // run directory needs its own owner: refuse at once if a live daemon holds
1160    // it, before anything below reads or writes run or data-home state.
1161    let run_dir_lock = config
1162        .live_children_path
1163        .as_deref()
1164        .map(crate::run_dir_lock::RunDirLock::acquire)
1165        .transpose()?;
1166
1167    remove_stale_connection_file_if_present(&path)?;
1168
1169    // Established under the start lock, and under the run-directory lock when
1170    // there is one, and before binding: a corrupt file stops boot before
1171    // anything is published, and two daemons racing to start cannot both mint.
1172    //
1173    // Minting a new machine id fsyncs its file, so loading or minting runs on
1174    // tokio's blocking pool. Both locks travel with that work, so neither is
1175    // released while a mint is in flight.
1176    let (machine_id, lock, run_dir_lock) = match config.machine_id_path {
1177        None => (None, lock, run_dir_lock),
1178        Some(machine_id_path) => {
1179            let task_path = machine_id_path.clone();
1180            match run_blocking_holding((lock, run_dir_lock), move || {
1181                crate::machine_id::load_or_mint(&task_path)
1182            })
1183            .await
1184            {
1185                Ok((Ok(id), (lock, run_dir_lock))) => (Some(id), lock, run_dir_lock),
1186                Ok((Err(error), _locks)) => return Err(BootstrapError::MachineId(error)),
1187                Err(join_error) => {
1188                    return Err(BootstrapError::MachineId(
1189                        crate::machine_id::MachineIdFileError::Write {
1190                            path: machine_id_path,
1191                            source: io::Error::other(describe_blocking_task_failure(
1192                                "loading or minting the machine id",
1193                                &join_error,
1194                            )),
1195                        },
1196                    ))
1197                }
1198            }
1199        }
1200    };
1201
1202    let (listeners, endpoints) = bind_loopback(config.port).await?;
1203    let connection_info = ConnectionInfo {
1204        schema: SCHEMA_VERSION,
1205        wire_version: Some(PROTOCOL_VERSION),
1206        endpoints,
1207        key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
1208        daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
1209        pid: process::id(),
1210        daemon_ver: config.daemon_ver,
1211    };
1212
1213    // The low-level binding API historically publishes for callers that serve
1214    // these listeners themselves. The daemon entry point defers publication
1215    // until its own authentication server is running.
1216    let publication_lock = if publish {
1217        let (published, lock) = publish_connection_file(&path, &connection_info, lock).await;
1218        if let Err(error) = published {
1219            drop(listeners);
1220            return Err(error);
1221        }
1222        drop(lock);
1223        None
1224    } else {
1225        Some(lock)
1226    };
1227
1228    Ok(Outcome::Bound(BoundDaemon {
1229        listeners,
1230        connection_info,
1231        connection_file_path: path,
1232        connection_file_source: config.connection_file_source,
1233        machine_id,
1234        run_dir_lock,
1235        publication_lock,
1236    }))
1237}
1238
1239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1240enum Probe {
1241    Live,
1242    StaleOrAbsent,
1243}
1244
1245async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
1246    let info = match connection_file::read(path) {
1247        Ok(info) => info,
1248        Err(source) if is_absent_or_stale_connection_file(&source) => {
1249            return Ok(Probe::StaleOrAbsent)
1250        }
1251        Err(source) => {
1252            return Err(BootstrapError::ConnectionFileRead {
1253                path: path.to_path_buf(),
1254                source,
1255            })
1256        }
1257    };
1258
1259    for endpoint in &info.endpoints {
1260        if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
1261            return Ok(Probe::Live);
1262        }
1263    }
1264
1265    Ok(Probe::StaleOrAbsent)
1266}
1267
1268async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
1269    let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
1270        return Probe::StaleOrAbsent;
1271    };
1272    if !ip.is_loopback() {
1273        return Probe::StaleOrAbsent;
1274    }
1275    let addr = SocketAddr::new(ip, endpoint.port);
1276
1277    let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
1278        Ok(Ok(stream)) => stream,
1279        Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
1280    };
1281
1282    match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
1283        Ok(()) => Probe::Live,
1284        Err(AuthError::DaemonIdMismatch)
1285        | Err(AuthError::InvalidServerProof)
1286        | Err(AuthError::UnexpectedEof { .. })
1287        | Err(AuthError::Timeout { .. })
1288        | Err(AuthError::JsonEncode { .. })
1289        | Err(AuthError::JsonDecode { .. })
1290        | Err(AuthError::Io { .. })
1291        | Err(AuthError::MessageTooLarge { .. })
1292        | Err(AuthError::KeyTooShort { .. })
1293        | Err(AuthError::Random(_))
1294        | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
1295    }
1296}
1297
1298async fn verify_serving(info: &ConnectionInfo) -> Result<(), BootstrapError> {
1299    // Scheduling a server task is not a readiness barrier. Authenticate once
1300    // against the mandatory IPv4 listener before exposing discovery to peers.
1301    // This uses the normal probe deadline, with no retries or widened budget.
1302    if let Some(endpoint) = info.endpoints.first() {
1303        if matches!(probe_endpoint(info, endpoint).await, Probe::Live) {
1304            return Ok(());
1305        }
1306    }
1307    Err(BootstrapError::StartupNotServing)
1308}
1309
1310/// Run blocking filesystem work (an fsync, a rename) on tokio's blocking-thread
1311/// pool rather than on the async worker thread that awaits it. On a slow or
1312/// busy disk an fsync can take seconds, and a runtime has few worker threads
1313/// (an in-process test daemon may share two with everything else), so doing it
1314/// inline stalls unrelated tasks for that long.
1315///
1316/// `guards` (lock handles) move into the blocking task and are handed back
1317/// with the work's result. If the awaiting future is dropped first, the work
1318/// still runs to completion on its thread and the guards are released only
1319/// after it ends, so a lock is never given up while the work it protects is
1320/// still writing.
1321///
1322/// `Err` means the task panicked, or was cancelled before it started because
1323/// the runtime is shutting down; the guards have then been released already.
1324async fn run_blocking_holding<G, T>(
1325    guards: G,
1326    work: impl FnOnce() -> T + Send + 'static,
1327) -> Result<(T, G), JoinError>
1328where
1329    G: Send + 'static,
1330    T: Send + 'static,
1331{
1332    tokio::task::spawn_blocking(move || {
1333        let output = work();
1334        (output, guards)
1335    })
1336    .await
1337}
1338
1339/// Explain, for a startup error, why blocking work produced no result.
1340fn describe_blocking_task_failure(work: &str, join_error: &JoinError) -> String {
1341    if join_error.is_panic() {
1342        format!("the background thread {work} panicked before finishing: {join_error}")
1343    } else {
1344        format!(
1345            "the background thread {work} was cancelled before finishing, because the async runtime is shutting down: {join_error}"
1346        )
1347    }
1348}
1349
1350/// Publish the connection file: owner-only permissions, written to a temporary
1351/// file, fsynced and renamed over `path` (all done by `write_atomic`). The
1352/// write runs on tokio's blocking pool through [`run_blocking_holding`], so the
1353/// fsync never holds an async worker thread.
1354///
1355/// `guard` is the singleton start lock (or whatever the caller must keep held
1356/// across publication); it is returned with the result. It comes back `None`
1357/// only when the write task panicked or was cancelled, in which case the
1358/// result is an error and the file must be treated as not published.
1359async fn publish_connection_file<G: Send + 'static>(
1360    path: &Path,
1361    info: &ConnectionInfo,
1362    guard: G,
1363) -> (Result<(), BootstrapError>, Option<G>) {
1364    let task_path = path.to_path_buf();
1365    let task_info = info.clone();
1366    let outcome =
1367        run_blocking_holding(guard, move || write_connection_file(&task_path, &task_info)).await;
1368    match outcome {
1369        Ok((Ok(()), guard)) => (Ok(()), Some(guard)),
1370        Ok((Err(source), guard)) => (
1371            Err(BootstrapError::ConnectionFileWrite {
1372                path: path.to_path_buf(),
1373                source,
1374            }),
1375            Some(guard),
1376        ),
1377        Err(join_error) => (
1378            Err(BootstrapError::ConnectionFileWrite {
1379                path: path.to_path_buf(),
1380                source: ConnectionFileError::Io {
1381                    op: "write",
1382                    path: path.to_path_buf(),
1383                    source: io::Error::other(describe_blocking_task_failure(
1384                        "writing the connection file",
1385                        &join_error,
1386                    )),
1387                },
1388            }),
1389            None,
1390        ),
1391    }
1392}
1393
1394/// The synchronous connection-file write, including its fsync. Call it only
1395/// through [`publish_connection_file`], which keeps it off the async workers.
1396fn write_connection_file(path: &Path, info: &ConnectionInfo) -> Result<(), ConnectionFileError> {
1397    #[cfg(test)]
1398    connection_file_write_hook::run(path);
1399    write_atomic(path, info)
1400}
1401
1402/// Lets a test run code inside the connection-file write, on whichever thread
1403/// performs it, for example to block there and check what else can still run.
1404#[cfg(test)]
1405mod connection_file_write_hook {
1406    use std::{
1407        path::{Path, PathBuf},
1408        sync::{Mutex, PoisonError},
1409    };
1410
1411    type Hook = Box<dyn FnOnce() + Send>;
1412
1413    /// Keyed by connection-file path, so a hook fires only for the test that
1414    /// installed it while other tests publish their own files in parallel.
1415    static HOOKS: Mutex<Vec<(PathBuf, Hook)>> = Mutex::new(Vec::new());
1416
1417    /// Run `hook` once, at the start of the next write of `path`.
1418    pub(super) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) {
1419        HOOKS
1420            .lock()
1421            .unwrap_or_else(PoisonError::into_inner)
1422            .push((path.to_path_buf(), Box::new(hook)));
1423    }
1424
1425    pub(super) fn run(path: &Path) {
1426        let hook = {
1427            let mut hooks = HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
1428            hooks
1429                .iter()
1430                .position(|(hooked, _)| hooked == path)
1431                .map(|index| hooks.swap_remove(index).1)
1432        };
1433        if let Some(hook) = hook {
1434            hook();
1435        }
1436    }
1437}
1438
1439/// A path-scoped barrier after publication and before any configured activation.
1440/// The async wait leaves the listeners free to answer discovery-time requests.
1441#[cfg(test)]
1442mod startup_publication_hook {
1443    use std::{
1444        path::{Path, PathBuf},
1445        sync::{Mutex, PoisonError},
1446    };
1447    use tokio::sync::oneshot;
1448
1449    type Barrier = (oneshot::Sender<()>, oneshot::Receiver<()>);
1450    static BARRIERS: Mutex<Vec<(PathBuf, Barrier)>> = Mutex::new(Vec::new());
1451
1452    pub(super) fn install(path: &Path) -> (oneshot::Receiver<()>, oneshot::Sender<()>) {
1453        let (entered_tx, entered_rx) = oneshot::channel();
1454        let (release_tx, release_rx) = oneshot::channel();
1455        BARRIERS
1456            .lock()
1457            .unwrap_or_else(PoisonError::into_inner)
1458            .push((path.to_path_buf(), (entered_tx, release_rx)));
1459        (entered_rx, release_tx)
1460    }
1461
1462    pub(super) async fn run(path: &Path) {
1463        let barrier = {
1464            let mut barriers = BARRIERS.lock().unwrap_or_else(PoisonError::into_inner);
1465            barriers
1466                .iter()
1467                .position(|(hooked, _)| hooked == path)
1468                .map(|index| barriers.swap_remove(index).1)
1469        };
1470        if let Some((entered, release)) = barrier {
1471            let _ = entered.send(());
1472            let _ = release.await;
1473        }
1474    }
1475}
1476
1477fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
1478    match err {
1479        ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
1480        ConnectionFileError::JsonRead { .. }
1481        | ConnectionFileError::UnsupportedSchema { .. }
1482        | ConnectionFileError::Invalid { .. }
1483        | ConnectionFileError::KeyTooShort { .. }
1484        // A live daemon always publishes the file owner-only (0600), so a file
1485        // with insecure permissions is never a daemon we should defer to: treat it
1486        // as stale and take over (which republishes a correct 0600 file).
1487        | ConnectionFileError::InsecurePermissions { .. } => true,
1488        ConnectionFileError::MissingParent { .. }
1489        | ConnectionFileError::MissingFileName { .. }
1490        // A writable ancestor is an operator misconfiguration, never evidence
1491        // about whether a daemon is live. Reclaiming the file would republish key
1492        // material into the same directory the refusal is about.
1493        | ConnectionFileError::InsecureParentDirectory { .. }
1494        | ConnectionFileError::Io { .. }
1495        | ConnectionFileError::JsonWrite { .. }
1496        | ConnectionFileError::Random(_)
1497        // A wire mismatch may identify a newer live daemon, so never reclaim its
1498        // connection file merely because this binary cannot speak its envelope.
1499        | ConnectionFileError::WireVersionMismatch { .. } => false,
1500    }
1501}
1502
1503fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
1504    match fs::remove_file(path) {
1505        Ok(()) => Ok(()),
1506        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1507        Err(source) => Err(BootstrapError::RemoveStale {
1508            path: path.to_path_buf(),
1509            source,
1510        }),
1511    }
1512}
1513
1514async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
1515    bind_loopback_with(port, |port| TcpListener::bind((Ipv6Addr::LOCALHOST, port))).await
1516}
1517
1518async fn bind_loopback_with<F, Fut>(
1519    port: u16,
1520    mut bind_v6: F,
1521) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError>
1522where
1523    F: FnMut(u16) -> Fut,
1524    Fut: std::future::Future<Output = io::Result<TcpListener>>,
1525{
1526    let v4_host = Ipv4Addr::LOCALHOST;
1527    let v4 = TcpListener::bind((v4_host, port))
1528        .await
1529        .map_err(|source| BootstrapError::Bind {
1530            host: v4_host.to_string(),
1531            port,
1532            source,
1533        })?;
1534    let actual_port = v4
1535        .local_addr()
1536        .map_err(|source| BootstrapError::LocalAddr {
1537            host: v4_host.to_string(),
1538            source,
1539        })?
1540        .port();
1541
1542    let mut listeners = vec![v4];
1543    let mut endpoints = vec![Endpoint {
1544        host: v4_host.to_string(),
1545        port: actual_port,
1546    }];
1547
1548    let v6_host = Ipv6Addr::LOCALHOST;
1549    match bind_v6(actual_port).await {
1550        Ok(v6) => {
1551            listeners.push(v6);
1552            endpoints.push(Endpoint {
1553                host: v6_host.to_string(),
1554                port: actual_port,
1555            });
1556        }
1557        Err(err)
1558            if ipv6_loopback_unavailable(&err)
1559                || (port == 0 && err.kind() == io::ErrorKind::AddrInUse) =>
1560        {
1561            warn!(
1562                port = actual_port,
1563                error = %err,
1564                "IPv6 loopback unavailable; serving only IPv4 loopback"
1565            );
1566        }
1567        Err(source) => {
1568            drop(listeners);
1569            return Err(BootstrapError::Bind {
1570                host: v6_host.to_string(),
1571                port: actual_port,
1572                source,
1573            });
1574        }
1575    }
1576
1577    Ok((listeners, endpoints))
1578}
1579
1580fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
1581    matches!(
1582        err.kind(),
1583        io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1584    ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1585}
1586
1587struct AbortOnDrop<T> {
1588    handle: JoinHandle<T>,
1589}
1590
1591impl<T> AbortOnDrop<T> {
1592    fn new(handle: JoinHandle<T>) -> Self {
1593        Self { handle }
1594    }
1595
1596    async fn join(&mut self) -> Result<T, JoinError> {
1597        (&mut self.handle).await
1598    }
1599}
1600
1601impl<T> Drop for AbortOnDrop<T> {
1602    fn drop(&mut self) {
1603        if !self.handle.is_finished() {
1604            self.handle.abort();
1605        }
1606    }
1607}
1608
1609#[derive(Debug)]
1610struct StartLock {
1611    // Keep the locked file handle alive for the duration of bootstrap.
1612    _file: fs::File,
1613}
1614
1615impl Drop for StartLock {
1616    fn drop(&mut self) {
1617        // Closing alone leaves the lock held if a concurrent fork inherited
1618        // the open file description and has not exec'd yet. Unlock explicitly
1619        // so a restart can reclaim the singleton as soon as bootstrap ends.
1620        if let Err(error) = FileExt::unlock(&self._file) {
1621            tracing::warn!(%error, "failed to release the singleton start lock");
1622        }
1623    }
1624}
1625
1626impl StartLock {
1627    async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1628        let path = start_lock_path(connection_file_path);
1629        for _ in 0..START_LOCK_RETRIES {
1630            let file = match open_owner_only_lock(&path) {
1631                Ok(file) => file,
1632                Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1633            };
1634            match FileExt::try_lock(&file) {
1635                Ok(()) => return Ok(Self { _file: file }),
1636                Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1637                Err(TryLockError::Error(source)) => {
1638                    return Err(BootstrapError::StartLockCreate { path, source });
1639                }
1640            }
1641        }
1642
1643        Err(BootstrapError::StartLockBusy {
1644            path,
1645            attempts: START_LOCK_RETRIES,
1646        })
1647    }
1648}
1649
1650pub(crate) fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1651    let mut options = fs::OpenOptions::new();
1652    options.read(true).write(true).create(true);
1653    #[cfg(unix)]
1654    {
1655        use std::os::unix::fs::OpenOptionsExt;
1656        options.mode(0o600);
1657    }
1658    options.open(path)
1659}
1660
1661fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1662    let file_name = connection_file_path
1663        .file_name()
1664        .map(|name| name.to_string_lossy())
1665        .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1666    let lock_name = format!("{file_name}.start-lock");
1667    connection_file_path
1668        .parent()
1669        .filter(|parent| !parent.as_os_str().is_empty())
1670        .unwrap_or_else(|| Path::new("."))
1671        .join(lock_name)
1672}
1673
1674fn non_empty_os_var(key: &str) -> Option<OsString> {
1675    let value = env::var_os(key)?;
1676    if value.is_empty() {
1677        None
1678    } else {
1679        Some(value)
1680    }
1681}
1682
1683/// Bootstrap-layer errors are deliberately typed so startup never panics for
1684/// ordinary daemon-discovery races or stale filesystem state.
1685#[derive(Debug)]
1686pub enum BootstrapError {
1687    /// The bound server could not authenticate its own readiness probe.
1688    StartupNotServing,
1689    #[cfg(unix)]
1690    Signal(io::Error),
1691    InvalidPort {
1692        raw: String,
1693        source: std::num::ParseIntError,
1694    },
1695    ConnectionFileRead {
1696        path: PathBuf,
1697        source: ConnectionFileError,
1698    },
1699    ConnectionFileWrite {
1700        path: PathBuf,
1701        source: ConnectionFileError,
1702    },
1703    GenerateConnectionFile(ConnectionFileError),
1704    StartLockCreate {
1705        path: PathBuf,
1706        source: io::Error,
1707    },
1708    StartLockBusy {
1709        path: PathBuf,
1710        attempts: usize,
1711    },
1712    /// The run-directory lock file could not be created, opened or locked.
1713    RunDirLockCreate {
1714        path: PathBuf,
1715        source: io::Error,
1716    },
1717    /// Another live process, almost certainly a daemon started with a
1718    /// different runtime directory over the same data home, owns the run
1719    /// directory. The daemon does not start. `holder_pid` is read from the
1720    /// lock file when possible and is informational only.
1721    RunDirBusy {
1722        path: PathBuf,
1723        holder_pid: Option<u32>,
1724    },
1725    RemoveStale {
1726        path: PathBuf,
1727        source: io::Error,
1728    },
1729    Bind {
1730        host: String,
1731        port: u16,
1732        source: io::Error,
1733    },
1734    LocalAddr {
1735        host: String,
1736        source: io::Error,
1737    },
1738    DaemonConfig(DaemonConfigError),
1739    /// The machine id could not be established: its file is corrupt, unreadable
1740    /// or unwritable, or the data home is relative. The daemon does not start.
1741    MachineId(crate::machine_id::MachineIdFileError),
1742    /// The daemon run directory could not be resolved because the data home is
1743    /// relative. The daemon does not start.
1744    RunDir(daemon_config::DaemonRunDirError),
1745    Serve(ServerError),
1746    ServeJoin(tokio::task::JoinError),
1747}
1748
1749impl fmt::Display for BootstrapError {
1750    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1751        match self {
1752            Self::StartupNotServing => write!(f, "bound daemon did not authenticate its readiness probe; connection file was not published"),
1753            #[cfg(unix)]
1754            Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1755            Self::InvalidPort { raw, source } => {
1756                write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1757            }
1758            Self::ConnectionFileRead { path, source } => write!(
1759                f,
1760                "failed to read connection file {}: {source}",
1761                path.display()
1762            ),
1763            Self::ConnectionFileWrite { path, source } => write!(
1764                f,
1765                "failed to publish connection file {}: {source}",
1766                path.display()
1767            ),
1768            Self::GenerateConnectionFile(err) => {
1769                write!(f, "failed to generate connection-file auth material: {err}")
1770            }
1771            Self::StartLockCreate { path, source } => {
1772                write!(
1773                    f,
1774                    "failed to create start lock {}: {source}",
1775                    path.display()
1776                )
1777            }
1778            Self::StartLockBusy { path, attempts } => write!(
1779                f,
1780                "start lock {} remained busy after {attempts} attempts",
1781                path.display()
1782            ),
1783            Self::RunDirLockCreate { path, source } => write!(
1784                f,
1785                "refusing to start: failed to lock run directory via {}: {source}",
1786                path.display()
1787            ),
1788            Self::RunDirBusy { path, holder_pid } => {
1789                write!(
1790                    f,
1791                    "refusing to start: run directory lock {} is held by another daemon",
1792                    path.display()
1793                )?;
1794                if let Some(pid) = holder_pid {
1795                    write!(f, " (pid {pid})")?;
1796                }
1797                write!(
1798                    f,
1799                    "; a live daemon owns this data home's run state (typically one started with a different XDG_RUNTIME_DIR)"
1800                )
1801            }
1802            Self::RemoveStale { path, source } => write!(
1803                f,
1804                "failed to remove stale connection file {}: {source}",
1805                path.display()
1806            ),
1807            Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1808                write!(
1809                    f,
1810                    "port {port} in use on loopback {host}: {source}; set the port in config"
1811                )
1812            }
1813            Self::Bind { host, port, source } => {
1814                write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1815            }
1816            Self::LocalAddr { host, source } => {
1817                write!(f, "failed to read local address for {host}: {source}")
1818            }
1819            Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1820            Self::MachineId(err) => write!(f, "refusing to start: {err}"),
1821            Self::RunDir(err) => write!(f, "refusing to start: {err}"),
1822            Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1823            Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1824        }
1825    }
1826}
1827
1828impl Error for BootstrapError {
1829    fn source(&self) -> Option<&(dyn Error + 'static)> {
1830        match self {
1831            Self::StartupNotServing => None,
1832            #[cfg(unix)]
1833            Self::Signal(source) => Some(source),
1834            Self::InvalidPort { source, .. } => Some(source),
1835            Self::ConnectionFileRead { source, .. }
1836            | Self::ConnectionFileWrite { source, .. }
1837            | Self::GenerateConnectionFile(source) => Some(source),
1838            Self::StartLockCreate { source, .. }
1839            | Self::RunDirLockCreate { source, .. }
1840            | Self::RemoveStale { source, .. }
1841            | Self::Bind { source, .. }
1842            | Self::LocalAddr { source, .. } => Some(source),
1843            Self::DaemonConfig(err) => Some(err),
1844            Self::MachineId(err) => Some(err),
1845            Self::RunDir(err) => Some(err),
1846            Self::Serve(err) => Some(err),
1847            Self::ServeJoin(err) => Some(err),
1848            Self::StartLockBusy { .. } | Self::RunDirBusy { .. } => None,
1849        }
1850    }
1851}
1852
1853#[cfg(test)]
1854mod tests {
1855    mod activity {
1856        include!("activity_bootstrap_tests.rs");
1857    }
1858    mod startup_order {
1859        include!("startup_order_tests.rs");
1860    }
1861    use super::*;
1862    use crate::server::ServerAuth;
1863    use cortexkit_test_support::ScratchDir;
1864    #[cfg(target_os = "linux")]
1865    use std::collections::BTreeSet;
1866    use std::sync::Mutex;
1867    #[cfg(target_os = "linux")]
1868    use subc_control::ModuleProtocol;
1869    use subc_transport::MIN_KEY_LEN;
1870    use tokio::io::AsyncReadExt;
1871    use tokio::task::JoinHandle;
1872
1873    #[cfg(unix)]
1874    use std::os::unix::fs::PermissionsExt;
1875
1876    static ENV_LOCK: Mutex<()> = Mutex::new(());
1877
1878    #[test]
1879    fn normalized_build_provenance_preserves_real_values() {
1880        assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1881    }
1882
1883    #[test]
1884    fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1885        assert_eq!(normalized_build_provenance("unavailable"), None);
1886        assert_eq!(normalized_build_provenance(""), None);
1887    }
1888
1889    #[cfg(target_os = "linux")]
1890    fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1891        let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1892        let relative = cgroups
1893            .lines()
1894            .find_map(|line| line.strip_prefix("0::"))
1895            .ok_or_else(|| {
1896                io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1897            })?;
1898        Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1899    }
1900
1901    #[cfg(target_os = "linux")]
1902    fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1903        let modules = current_cgroup_path_for_test()?.join("subc-modules");
1904        let entries = match fs::read_dir(modules) {
1905            Ok(entries) => entries,
1906            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1907            Err(error) => return Err(error),
1908        };
1909        let mut directories = BTreeSet::new();
1910        for entry in entries {
1911            let entry = entry?;
1912            if entry.file_type()?.is_dir() {
1913                directories.insert(entry.file_name());
1914            }
1915        }
1916        Ok(directories)
1917    }
1918
1919    #[cfg(target_os = "linux")]
1920    async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1921        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1922        while !path.exists() && tokio::time::Instant::now() < deadline {
1923            assert!(
1924                !task.is_finished(),
1925                "daemon exited before creating {}",
1926                path.display()
1927            );
1928            sleep(Duration::from_millis(10)).await;
1929        }
1930        assert!(path.exists(), "daemon did not create {}", path.display());
1931    }
1932
1933    /// An in-process daemon with the default (disabled) cgroup placement must
1934    /// leave the host's live module cgroup tree exactly as it found it.
1935    /// Red-checking this test (making it fail) creates a
1936    /// `cgroup-isolation-probe-*` directory in the live cgroup tree, which must be removed by hand.
1937    #[cfg(target_os = "linux")]
1938    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1939    async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1940        let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1941        let module_id = format!("cgroup-isolation-probe-{}", process::id());
1942        let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1943        assert!(
1944            !before.contains(&OsString::from(&module_id)),
1945            "isolation probe cgroup already exists before this daemon starts"
1946        );
1947        let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1948        let module = ConfiguredModule {
1949            module_id,
1950            program: PathBuf::from("sh"),
1951            args: vec!["-c".to_string(), "sleep 30".to_string()],
1952            env: Vec::new(),
1953            log: None,
1954            enabled: true,
1955            reserved: false,
1956            reserved_prefixes: Vec::new(),
1957            protocol: ModuleProtocol::None,
1958            overlap: Default::default(),
1959            health: HealthConfig::default(),
1960            drain_timeout_ms: None,
1961            route_bind_relay_timeout_ms: None,
1962            restart: RestartPolicy::default(),
1963        };
1964        let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1965            .with_configured_modules([module])
1966            .with_capture_logs_dir(temp.join("logs"))
1967            .with_terminal_journal_path(temp.join("terminals.jsonl"));
1968        let task = tokio::spawn(run_with_config(config));
1969
1970        wait_for_path(&capture, &task).await;
1971        let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1972
1973        task.abort();
1974        assert!(task
1975            .await
1976            .expect_err("aborted daemon task must cancel")
1977            .is_cancelled());
1978        assert_eq!(
1979            before, after,
1980            "an in-process daemon must not create or reconcile ambient module cgroups"
1981        );
1982    }
1983
1984    #[cfg(target_os = "linux")]
1985    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1986    async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1987        let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1988        let cgroup_root = temp.join("cgroup");
1989        fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1990        fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1991        let modules = cgroup_root.join("subc-modules");
1992        let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1993            .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1994            .with_terminal_journal_path(temp.join("terminals.jsonl"));
1995        let task = tokio::spawn(run_with_config(config));
1996
1997        wait_for_path(&modules, &task).await;
1998
1999        task.abort();
2000        assert!(task
2001            .await
2002            .expect_err("aborted daemon task must cancel")
2003            .is_cancelled());
2004        assert!(
2005            fs::read_dir(&modules)
2006                .expect("read prepared modules directory")
2007                .next()
2008                .is_none(),
2009            "the delegation probe must clean up after itself"
2010        );
2011    }
2012
2013    struct EnvGuard {
2014        key: &'static str,
2015        previous: Option<OsString>,
2016    }
2017
2018    impl EnvGuard {
2019        fn set(key: &'static str, value: &Path) -> Self {
2020            let previous = env::var_os(key);
2021            env::set_var(key, value);
2022            Self { key, previous }
2023        }
2024
2025        fn set_str(key: &'static str, value: &str) -> Self {
2026            let previous = env::var_os(key);
2027            env::set_var(key, value);
2028            Self { key, previous }
2029        }
2030
2031        fn unset(key: &'static str) -> Self {
2032            let previous = env::var_os(key);
2033            env::remove_var(key);
2034            Self { key, previous }
2035        }
2036    }
2037
2038    impl Drop for EnvGuard {
2039        fn drop(&mut self) {
2040            match &self.previous {
2041                Some(value) => env::set_var(self.key, value),
2042                None => env::remove_var(self.key),
2043            }
2044        }
2045    }
2046
2047    fn unique_temp_dir(name: &str) -> ScratchDir {
2048        ScratchDir::new(name)
2049    }
2050
2051    fn temp_connection_file_path(name: &str) -> (ScratchDir, PathBuf) {
2052        let dir = unique_temp_dir(name);
2053        let path = dir.join("conn.json");
2054        (dir, path)
2055    }
2056
2057    fn auth_for(info: &ConnectionInfo) -> ServerAuth {
2058        ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
2059    }
2060
2061    fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
2062        let auth = auth_for(&bound.connection_info);
2063        tokio::spawn(serve_listeners(
2064            bound.listeners,
2065            Arc::new(Router::with_default_self_handler()),
2066            auth,
2067        ))
2068    }
2069
2070    fn expect_bound(outcome: Outcome) -> BoundDaemon {
2071        match outcome {
2072            Outcome::Bound(bound) => bound,
2073            Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
2074        }
2075    }
2076
2077    async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
2078        let endpoint = conn
2079            .endpoints
2080            .first()
2081            .expect("test connection file should have an endpoint");
2082        let ip: IpAddr = endpoint.host.parse().unwrap();
2083        TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
2084    }
2085
2086    fn make_connection_info(port: u16) -> ConnectionInfo {
2087        ConnectionInfo {
2088            schema: SCHEMA_VERSION,
2089            wire_version: Some(PROTOCOL_VERSION),
2090            endpoints: vec![Endpoint {
2091                host: "127.0.0.1".to_owned(),
2092                port,
2093            }],
2094            key: generate_key().unwrap(),
2095            daemon_id: generate_daemon_id().unwrap(),
2096            pid: process::id(),
2097            daemon_ver: "test-subc".to_owned(),
2098        }
2099    }
2100
2101    fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
2102        fs::write(path, contents).unwrap();
2103        #[cfg(unix)]
2104        fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
2105    }
2106
2107    fn assert_owner_only_connection_file(path: &Path) {
2108        // `path` is only inspected on Unix (mode bits); on Windows the owner-only
2109        // guarantee comes from the inherited %TEMP% ACL, nothing to assert here.
2110        #[cfg(unix)]
2111        {
2112            let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
2113            assert_eq!(mode, 0o600);
2114        }
2115        #[cfg(not(unix))]
2116        let _ = path;
2117    }
2118
2119    #[test]
2120    fn connection_file_path_uses_xdg_runtime_dir_when_set() {
2121        let _env_lock = ENV_LOCK.lock().unwrap();
2122        let runtime_dir = unique_temp_dir("xdg-runtime");
2123        let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
2124
2125        assert_eq!(
2126            connection_file_path(),
2127            runtime_dir.join(CONNECTION_FILE_NAME)
2128        );
2129    }
2130
2131    #[test]
2132    fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
2133        let runtime_dir = OsString::from("/run/user/1000");
2134
2135        let (path, source) = connection_file_path_with_source(Some(runtime_dir));
2136
2137        assert_eq!(
2138            path,
2139            PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
2140        );
2141        assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
2142    }
2143
2144    #[test]
2145    fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
2146        let _env_lock = ENV_LOCK.lock().unwrap();
2147        let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
2148
2149        assert_eq!(
2150            connection_file_path(),
2151            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2152        );
2153    }
2154
2155    #[test]
2156    fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
2157        let (path, source) = connection_file_path_with_source(None);
2158
2159        assert_eq!(
2160            path,
2161            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2162        );
2163        assert_eq!(source, ConnectionFileSource::TempDirFallback);
2164    }
2165
2166    /// Concurrent callers must derive one token. The former temp-file uid probe
2167    /// could fail transiently (same-tick name collision, fd exhaustion) and send
2168    /// the loser down the env-derived fallback with a different identity for the
2169    /// same user; this fence keeps identity independent of filesystem luck.
2170    #[test]
2171    fn user_connection_token_is_stable_under_concurrent_callers() {
2172        let expected = user_connection_token();
2173        let workers: Vec<_> = (0..32)
2174            .map(|_| {
2175                std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
2176            })
2177            .collect();
2178        for worker in workers {
2179            for token in worker.join().expect("probe thread") {
2180                assert_eq!(token, expected, "token diverged under concurrent probes");
2181            }
2182        }
2183    }
2184
2185    #[test]
2186    fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
2187        let (path, source) = connection_file_path_with_source(Some(OsString::new()));
2188
2189        assert_eq!(
2190            path,
2191            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2192        );
2193        assert_eq!(source, ConnectionFileSource::TempDirFallback);
2194    }
2195
2196    /// The shipped binary is the only caller that journals terminal exits into
2197    /// the real run directory, so its constructor must supply that path itself;
2198    /// the in-process default leaves it unset.
2199    #[test]
2200    fn daemon_binary_config_journals_and_captures_into_the_run_dir() {
2201        let _env_lock = ENV_LOCK.lock().unwrap();
2202        let root = unique_temp_dir("daemon-binary-config");
2203        let data_home = root.join("data");
2204        let _data = EnvGuard::set("XDG_DATA_HOME", &data_home);
2205        let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2206        let _port = EnvGuard::unset(SUBC_PORT_ENV);
2207
2208        let config = BootstrapConfig::from_env_for_daemon_binary().unwrap();
2209
2210        let run_dir = data_home.join("cortexkit").join("run");
2211        assert_eq!(
2212            config.terminal_journal_path,
2213            Some(run_dir.join("terminals.jsonl"))
2214        );
2215        assert_eq!(config.capture_logs_dir, Some(run_dir.join("logs")));
2216        assert_eq!(
2217            BootstrapConfig::new(root.join("connection.json"), 0).terminal_journal_path,
2218            None,
2219            "an in-process config must not journal anywhere unless asked to"
2220        );
2221    }
2222
2223    #[test]
2224    fn daemon_binary_config_refuses_a_relative_data_home() {
2225        let _env_lock = ENV_LOCK.lock().unwrap();
2226        let root = unique_temp_dir("daemon-binary-relative-data");
2227        let _data = EnvGuard::set_str("XDG_DATA_HOME", "relative-data-home");
2228        let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2229        let _port = EnvGuard::unset(SUBC_PORT_ENV);
2230
2231        let error = BootstrapConfig::from_env_for_daemon_binary()
2232            .expect_err("a relative data home must refuse the daemon binary's config");
2233        assert!(
2234            matches!(error, BootstrapError::RunDir(_)),
2235            "expected a run-directory refusal, got {error}"
2236        );
2237        assert!(error.to_string().contains("XDG_DATA_HOME"), "{error}");
2238    }
2239
2240    #[test]
2241    fn privileged_operator_prompt_is_loaded_by_both_bootstrap_config_paths() {
2242        let _env_lock = ENV_LOCK.lock().unwrap();
2243        let _port = EnvGuard::unset(SUBC_PORT_ENV);
2244        let (_dir, conn_path) = temp_connection_file_path("privileged-operator-prompt");
2245        let config_path = conn_path.with_file_name("subc.jsonc");
2246        for (doc, expected) in [
2247            (None, daemon_config::PrivilegedOperatorPrompt::Log),
2248            (
2249                Some(r#"{"version":1}"#),
2250                daemon_config::PrivilegedOperatorPrompt::Log,
2251            ),
2252            (
2253                Some(r#"{"version":1,"privileged_operator_prompt":"log"}"#),
2254                daemon_config::PrivilegedOperatorPrompt::Log,
2255            ),
2256            (
2257                Some(r#"{"version":1,"privileged_operator_prompt":"enforce"}"#),
2258                daemon_config::PrivilegedOperatorPrompt::Enforce,
2259            ),
2260        ] {
2261            if let Some(doc) = doc {
2262                fs::write(&config_path, doc).unwrap();
2263            }
2264            assert_eq!(
2265                BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2266                    .unwrap()
2267                    .privileged_operator_prompt,
2268                expected
2269            );
2270            assert_eq!(
2271                BootstrapConfig::new(&conn_path, 0)
2272                    .with_daemon_config_path(&config_path)
2273                    .unwrap()
2274                    .privileged_operator_prompt,
2275                expected
2276            );
2277        }
2278    }
2279
2280    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2281    async fn privileged_operator_prompt_is_snapshotted_by_running_daemon() {
2282        let dir = unique_temp_dir("privileged-operator-prompt-bootstrap");
2283        let connection_path = dir.join("connection.json");
2284        let config_path = dir.join("subc.jsonc");
2285        fs::write(
2286            &config_path,
2287            r#"{"version":1,"privileged_operator_prompt":"enforce"}"#,
2288        )
2289        .unwrap();
2290        let config = BootstrapConfig::new(&connection_path, 0)
2291            .with_daemon_config_path(&config_path)
2292            .unwrap();
2293        let daemon = AbortOnDrop::new(tokio::spawn(run_with_config(config)));
2294        tokio::time::timeout(Duration::from_secs(5), async {
2295            while !connection_path.exists() {
2296                assert!(
2297                    !daemon.handle.is_finished(),
2298                    "daemon exited before publishing"
2299                );
2300                tokio::task::yield_now().await;
2301            }
2302        })
2303        .await
2304        .unwrap();
2305        let info = connection_file::read(&connection_path).unwrap();
2306        let mut client = connect_from_info(&info).await.unwrap();
2307        authenticate_client(&mut client, &info, Duration::from_secs(5))
2308            .await
2309            .unwrap();
2310        for (correlation, mode, expected) in [
2311            (1, "enforce", Vec::new()),
2312            (2, "log", vec!["privileged_operator_prompt"]),
2313            (3, "log", vec!["privileged_operator_prompt"]),
2314        ] {
2315            fs::write(
2316                &config_path,
2317                serde_json::json!({"version":1,"privileged_operator_prompt":mode}).to_string(),
2318            )
2319            .unwrap();
2320            let frame = crate::Frame::build(
2321                subc_protocol::FrameType::Request,
2322                subc_protocol::Flags::new(false, subc_protocol::Priority::Interactive, false),
2323                0,
2324                0,
2325                correlation,
2326                serde_json::to_vec(&subc_control::ClientControlRequest::SupervisorRescan {
2327                    preview: true,
2328                })
2329                .unwrap(),
2330            )
2331            .unwrap();
2332            crate::write_frame(&mut client, &frame).await.unwrap();
2333            let reply =
2334                tokio::time::timeout(Duration::from_secs(5), crate::read_frame(&mut client))
2335                    .await
2336                    .unwrap()
2337                    .unwrap()
2338                    .unwrap();
2339            assert_eq!(reply.header.ty, subc_protocol::FrameType::Response);
2340            assert_eq!(reply.header.corr, correlation);
2341            let subc_control::ClientControlResponse::SupervisorRescan { result } =
2342                serde_json::from_slice(&reply.body).unwrap()
2343            else {
2344                panic!("expected a rescan response");
2345            };
2346            assert_eq!(result.restart_required, expected);
2347        }
2348    }
2349
2350    #[test]
2351    fn configured_port_uses_default_config_and_env_override() {
2352        let _env_lock = ENV_LOCK.lock().unwrap();
2353        let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
2354        let config_path = conn_path.with_file_name("subc.jsonc");
2355
2356        let _port = EnvGuard::unset(SUBC_PORT_ENV);
2357        assert_eq!(
2358            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2359                .unwrap()
2360                .port,
2361            DEFAULT_SUBC_PORT
2362        );
2363
2364        fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
2365        assert_eq!(
2366            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2367                .unwrap()
2368                .port,
2369            8123
2370        );
2371
2372        let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
2373        assert_eq!(
2374            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2375                .unwrap()
2376                .port,
2377            9012
2378        );
2379    }
2380
2381    #[tokio::test]
2382    async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
2383        let (_dir, path) = temp_connection_file_path("already-running");
2384
2385        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2386        let server = start_server(bound);
2387
2388        let second = ensure_singleton(&path, 0).await.unwrap();
2389        assert!(matches!(second, Outcome::AlreadyRunning));
2390
2391        server.abort();
2392        let _ = server.await;
2393    }
2394
2395    #[tokio::test]
2396    async fn daemon_connection_file_publishes_protocol_wire_version() {
2397        let (_dir, path) = temp_connection_file_path("wire-version");
2398        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2399        assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
2400        assert_eq!(
2401            connection_file::read(&path).unwrap().wire_version,
2402            Some(PROTOCOL_VERSION)
2403        );
2404
2405        drop(bound.listeners);
2406    }
2407
2408    #[tokio::test]
2409    async fn stale_unbound_connection_file_is_reclaimed() {
2410        let (_dir, path) = temp_connection_file_path("stale-reclaim");
2411        let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2412        let stale_port = stale.local_addr().unwrap().port();
2413        drop(stale);
2414        let stale_info = make_connection_info(stale_port);
2415        write_atomic(&path, &stale_info).unwrap();
2416
2417        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2418        assert_ne!(bound.connection_info.key, stale_info.key);
2419        drop(bound.listeners);
2420    }
2421
2422    #[cfg(unix)]
2423    #[tokio::test]
2424    async fn ensure_singleton_reclaims_insecure_connection_file() {
2425        let (_dir, path) = temp_connection_file_path("insecure-reclaim");
2426        let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2427        let stale_port = stale.local_addr().unwrap().port();
2428        drop(stale);
2429        let stale_info = make_connection_info(stale_port);
2430        write_atomic(&path, &stale_info).unwrap();
2431        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2432
2433        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2434        assert_ne!(bound.connection_info.key, stale_info.key);
2435        assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2436        assert_owner_only_connection_file(&path);
2437
2438        drop(bound.listeners);
2439    }
2440
2441    #[tokio::test]
2442    async fn ensure_singleton_reclaims_non_loopback_connection_file() {
2443        let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
2444        let mut stale_info = make_connection_info(8757);
2445        stale_info.endpoints = vec![Endpoint {
2446            host: "192.0.2.10".to_owned(),
2447            port: 8757,
2448        }];
2449        write_atomic(&path, &stale_info).unwrap();
2450
2451        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2452        assert_ne!(bound.connection_info.key, stale_info.key);
2453        assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2454        assert!(bound
2455            .connection_info
2456            .endpoints
2457            .iter()
2458            .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
2459        assert_owner_only_connection_file(&path);
2460
2461        drop(bound.listeners);
2462    }
2463
2464    #[tokio::test]
2465    async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
2466        let mut unsupported_schema = make_connection_info(8757);
2467        unsupported_schema.schema = SCHEMA_VERSION + 1;
2468
2469        let mut empty_endpoints = make_connection_info(8757);
2470        empty_endpoints.endpoints.clear();
2471
2472        let mut short_key = make_connection_info(8757);
2473        short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
2474
2475        let cases = vec![
2476            (
2477                "unsupported-schema",
2478                serde_json::to_vec(&unsupported_schema).unwrap(),
2479                Some(unsupported_schema),
2480            ),
2481            (
2482                "empty-endpoints",
2483                serde_json::to_vec(&empty_endpoints).unwrap(),
2484                Some(empty_endpoints),
2485            ),
2486            (
2487                "short-key",
2488                serde_json::to_vec(&short_key).unwrap(),
2489                Some(short_key),
2490            ),
2491            ("invalid-json", b"{not valid connection json".to_vec(), None),
2492        ];
2493
2494        for (label, contents, old_info) in cases {
2495            let (_dir, path) = temp_connection_file_path(label);
2496            write_raw_owner_only_connection_file(&path, &contents);
2497
2498            let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2499            if let Some(old_info) = old_info {
2500                assert_ne!(bound.connection_info.key, old_info.key, "{label}");
2501                assert_ne!(
2502                    bound.connection_info.daemon_id, old_info.daemon_id,
2503                    "{label}"
2504                );
2505            }
2506            assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
2507            assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
2508            assert_owner_only_connection_file(&path);
2509
2510            drop(bound.listeners);
2511        }
2512    }
2513
2514    #[tokio::test]
2515    async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
2516        let (_dir, path) = temp_connection_file_path("foreign-reclaim");
2517        let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2518        let foreign_port = foreign.local_addr().unwrap().port();
2519        write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
2520        let foreign_task = tokio::spawn(async move {
2521            if let Ok((mut stream, _)) = foreign.accept().await {
2522                let mut buf = [0u8; 64];
2523                let _ = stream.read(&mut buf).await;
2524            }
2525        });
2526
2527        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2528        assert!(bound
2529            .connection_info
2530            .endpoints
2531            .iter()
2532            .all(|endpoint| endpoint.port != foreign_port));
2533
2534        drop(bound.listeners);
2535        let _ = foreign_task.await;
2536    }
2537
2538    #[tokio::test]
2539    async fn stale_start_lock_file_is_reclaimable() {
2540        let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
2541        let lock_path = start_lock_path(&path);
2542        drop(open_owner_only_lock(&lock_path).unwrap());
2543        assert!(lock_path.is_file());
2544
2545        let lock = StartLock::acquire(&path).await.unwrap();
2546        assert!(lock_path.is_file());
2547
2548        drop(lock);
2549        assert!(lock_path.is_file());
2550    }
2551
2552    #[tokio::test]
2553    async fn held_start_lock_blocks_second_acquire_until_release() {
2554        let (_dir, path) = temp_connection_file_path("start-lock-held");
2555        let lock_path = start_lock_path(&path);
2556        let first = StartLock::acquire(&path).await.unwrap();
2557
2558        let err = match StartLock::acquire(&path).await {
2559            Ok(_) => panic!("second acquire while held must stay busy"),
2560            Err(err) => err,
2561        };
2562        assert!(matches!(
2563            err,
2564            BootstrapError::StartLockBusy {
2565                ref path,
2566                attempts: START_LOCK_RETRIES,
2567            } if path == &lock_path
2568        ));
2569
2570        drop(first);
2571
2572        let second = StartLock::acquire(&path)
2573            .await
2574            .expect("released advisory lock should be reclaimable");
2575        drop(second);
2576    }
2577
2578    #[tokio::test]
2579    async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
2580        let (_dir, path) = temp_connection_file_path("bind-conflict");
2581        let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2582        let occupied_port = occupied.local_addr().unwrap().port();
2583
2584        let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
2585        assert!(matches!(
2586            err,
2587            BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
2588        ));
2589        assert!(err.to_string().contains("set the port in config"));
2590
2591        drop(occupied);
2592    }
2593
2594    #[tokio::test]
2595    async fn ephemeral_ipv6_collision_still_serves_ipv4() {
2596        let (listeners, endpoints) = bind_loopback_with(0, |port| async move {
2597            let occupied = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await?;
2598            let result = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await;
2599            drop(occupied);
2600            result
2601        })
2602        .await
2603        .expect("an incidental IPv6 collision must not prevent an ephemeral daemon from starting");
2604        assert_eq!(endpoints.len(), 1);
2605        assert_eq!(endpoints[0].host, "127.0.0.1");
2606        TcpStream::connect((Ipv4Addr::LOCALHOST, endpoints[0].port))
2607            .await
2608            .unwrap();
2609        drop(listeners);
2610    }
2611
2612    #[tokio::test]
2613    async fn unpublished_boot_holds_singleton_lock_until_publication() {
2614        let dir = ScratchDir::new("unpublished-singleton-lock");
2615        let path = dir.join("connection.json");
2616        let bound = expect_bound(
2617            ensure_singleton_inner(BootstrapConfig::new(&path, 0), false)
2618                .await
2619                .unwrap(),
2620        );
2621        assert!(!path.exists());
2622        let contender = open_owner_only_lock(&start_lock_path(&path)).unwrap();
2623        assert!(
2624            matches!(FileExt::try_lock(&contender), Err(TryLockError::WouldBlock)),
2625            "an unpublished boot must keep its singleton claim"
2626        );
2627        drop(bound);
2628        FileExt::try_lock(&contender).unwrap();
2629    }
2630
2631    #[tokio::test]
2632    async fn start_lock_drop_releases_claim_with_a_shared_file_description() {
2633        let dir = ScratchDir::new("shared-singleton-lock");
2634        let path = dir.join("connection.json");
2635        let bound = expect_bound(
2636            ensure_singleton_inner(BootstrapConfig::new(&path, 0), false)
2637                .await
2638                .unwrap(),
2639        );
2640        // try_clone shares the open file description just as fork does,
2641        // without relying on the timing of another test's child spawn.
2642        let inherited = bound
2643            .publication_lock
2644            .as_ref()
2645            .unwrap()
2646            ._file
2647            .try_clone()
2648            .unwrap();
2649        let contender = open_owner_only_lock(&start_lock_path(&path)).unwrap();
2650        assert!(matches!(
2651            FileExt::try_lock(&contender),
2652            Err(TryLockError::WouldBlock)
2653        ));
2654        drop(bound);
2655        FileExt::try_lock(&contender).expect("dropping bootstrap releases even an inherited lock");
2656        drop(inherited);
2657    }
2658
2659    #[tokio::test]
2660    async fn readiness_probe_requires_an_authenticating_server() {
2661        let dir = ScratchDir::new("unserved-readiness");
2662        let bound = expect_bound(
2663            ensure_singleton_inner(BootstrapConfig::new(dir.join("connection.json"), 0), false)
2664                .await
2665                .unwrap(),
2666        );
2667        assert!(matches!(
2668            verify_serving(&bound.connection_info).await,
2669            Err(BootstrapError::StartupNotServing)
2670        ));
2671    }
2672
2673    #[cfg(unix)]
2674    #[tokio::test]
2675    async fn orphan_sweep_finishes_before_connection_file_publication() {
2676        use crate::live_children::{ExecutableIdentity, LiveChild};
2677        let dir = ScratchDir::new("publish-after-sweep");
2678        let path = dir.join("connection.json");
2679        let record = dir.join("live-children.json");
2680        let ready = dir.join("ready");
2681        let mut child = std::process::Command::new("/bin/sh")
2682            .args([
2683                "-c",
2684                "trap '' TERM; : > \"$0\"; read -r _",
2685                ready.to_str().unwrap(),
2686            ])
2687            .stdin(std::process::Stdio::piped())
2688            .env("XDG_DATA_HOME", dir.path())
2689            .env("XDG_RUNTIME_DIR", dir.path())
2690            .env("XDG_CONFIG_HOME", dir.path())
2691            .spawn()
2692            .unwrap();
2693        timeout(Duration::from_secs(5), async {
2694            while !ready.exists() {
2695                sleep(Duration::from_millis(5)).await;
2696            }
2697        })
2698        .await
2699        .unwrap();
2700        let observed = subc_os::Process::open(child.id())
2701            .unwrap()
2702            .unwrap()
2703            .observe()
2704            .unwrap();
2705        crate::live_children::write_record(
2706            &record,
2707            &[LiveChild {
2708                module_id: "old-sleep".into(),
2709                pid: child.id(),
2710                protocol: subc_control::ModuleProtocol::None,
2711                start_time: Some(observed.start_time),
2712                executable: observed.executable.map(ExecutableIdentity::from),
2713                cgroup_name: None,
2714            }],
2715        )
2716        .unwrap();
2717        let config = BootstrapConfig::new(&path, 0).with_live_children_record(&record);
2718        let daemon = tokio::spawn(run_with_config(config));
2719        timeout(Duration::from_secs(5), async {
2720            while !path.exists() {
2721                sleep(Duration::from_millis(5)).await;
2722            }
2723        })
2724        .await
2725        .unwrap();
2726        assert!(path.exists());
2727        let ended = child.try_wait().unwrap().is_some();
2728        if !ended {
2729            child.kill().unwrap();
2730        }
2731        child.wait().unwrap();
2732        if ended {
2733            assert!(matches!(probe_existing(&path).await.unwrap(), Probe::Live));
2734        }
2735        daemon.abort();
2736        let _ = daemon.await;
2737        assert!(
2738            ended,
2739            "the published daemon must not still owe its orphan sweep"
2740        );
2741    }
2742
2743    /// Publishing the connection file fsyncs, which can take seconds on a busy
2744    /// disk, so the daemon must do it off the async worker thread. The hook
2745    /// blocks inside the write until another task on the same single-threaded
2746    /// runtime has run. Writing on the worker thread would starve that task: the
2747    /// hook then gives up after its deadline and the test fails, as it does if
2748    /// startup publishes without going through the hooked write at all.
2749    #[tokio::test(flavor = "current_thread")]
2750    async fn connection_file_publication_leaves_the_async_runtime_free() {
2751        use std::sync::atomic::{AtomicBool, Ordering};
2752
2753        let (_dir, path) = temp_connection_file_path("publish-off-runtime");
2754        let write_started = Arc::new(AtomicBool::new(false));
2755        let (progress_tx, progress_rx) = std::sync::mpsc::channel::<()>();
2756        let (verdict_tx, verdict_rx) = std::sync::mpsc::channel::<bool>();
2757        let hook_started = Arc::clone(&write_started);
2758        connection_file_write_hook::install(&path, move || {
2759            hook_started.store(true, Ordering::SeqCst);
2760            let progressed = progress_rx.recv_timeout(Duration::from_secs(5)).is_ok();
2761            let _ = verdict_tx.send(progressed);
2762        });
2763        // Signals only after the write has started, so a message can only come
2764        // from this task running while the write is still blocked.
2765        let ticker = tokio::spawn(async move {
2766            loop {
2767                if write_started.load(Ordering::SeqCst) {
2768                    let _ = progress_tx.send(());
2769                    return;
2770                }
2771                sleep(Duration::from_millis(5)).await;
2772            }
2773        });
2774
2775        let daemon = tokio::spawn(run_with_config(BootstrapConfig::new(&path, 0)));
2776        let published = timeout(Duration::from_secs(15), async {
2777            while !path.exists() && !daemon.is_finished() {
2778                sleep(Duration::from_millis(5)).await;
2779            }
2780        })
2781        .await;
2782        let daemon_exited = daemon.is_finished();
2783        daemon.abort();
2784        let daemon_result = daemon.await;
2785        ticker.abort();
2786
2787        assert!(
2788            published.is_ok() && !daemon_exited,
2789            "the daemon must start and publish its connection file: {daemon_result:?}"
2790        );
2791        match verdict_rx.try_recv() {
2792            Ok(true) => {}
2793            Ok(false) => panic!(
2794                "no other task on the runtime ran while the connection file was being written: the write is blocking the async worker thread"
2795            ),
2796            Err(_) => panic!("startup published the connection file without the hooked write"),
2797        }
2798    }
2799
2800    /// A write that never reports back must stop startup with an error naming
2801    /// the connection file, never be taken as published.
2802    #[tokio::test]
2803    async fn a_panicking_connection_file_write_fails_startup() {
2804        let (_dir, path) = temp_connection_file_path("publish-panics");
2805        connection_file_write_hook::install(&path, || {
2806            panic!("deliberate panic inside the connection-file write")
2807        });
2808
2809        let error = timeout(
2810            Duration::from_secs(15),
2811            run_with_config(BootstrapConfig::new(&path, 0)),
2812        )
2813        .await
2814        .expect("startup must end once the write task fails")
2815        .expect_err("startup must fail when the connection-file write panics");
2816
2817        assert!(
2818            matches!(&error, BootstrapError::ConnectionFileWrite { path: failed, .. } if failed == &path),
2819            "expected a connection-file write error, got {error:?}"
2820        );
2821        let message = error.to_string();
2822        assert!(message.contains(&path.display().to_string()), "{message}");
2823        assert!(message.contains("panicked"), "{message}");
2824        assert!(!path.exists(), "no connection file may be published");
2825    }
2826
2827    #[cfg(target_os = "macos")]
2828    #[test]
2829    fn nofile_raise_worker() {
2830        let Some(path) = std::env::var_os("SUBC_NOFILE_TEST_FILE") else {
2831            return;
2832        };
2833        let (_, hard) = rlimit::Resource::NOFILE.get().unwrap();
2834        rlimit::Resource::NOFILE.set(256, hard).unwrap();
2835        raise_nofile_limit_with_ceiling(Some(10240));
2836        std::fs::write(path, rlimit::Resource::NOFILE.get().unwrap().0.to_string()).unwrap();
2837    }
2838
2839    #[cfg(target_os = "macos")]
2840    #[test]
2841    fn macos_nofile_raise_clamps_to_kernel_ceiling() {
2842        let dir = ScratchDir::new("nofile-ceiling");
2843        let result = dir.join("soft");
2844        let status = std::process::Command::new(std::env::current_exe().unwrap())
2845            .args(["--exact", "bootstrap::tests::nofile_raise_worker"])
2846            .env("SUBC_NOFILE_TEST_FILE", &result)
2847            .env("XDG_DATA_HOME", dir.path())
2848            .env("XDG_RUNTIME_DIR", dir.path())
2849            .env("XDG_CONFIG_HOME", dir.path())
2850            .status()
2851            .unwrap();
2852        assert!(status.success());
2853        let soft: u64 = std::fs::read_to_string(result).unwrap().parse().unwrap();
2854        assert!(
2855            soft > 256 && soft <= 10240,
2856            "the inherited limit must be raised within the kernel ceiling; got {soft}"
2857        );
2858    }
2859
2860    #[tokio::test]
2861    async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
2862        let (_dir, path) = temp_connection_file_path("key-rotation");
2863        let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2864        let old_info = first.connection_info.clone();
2865        let fixed_port = old_info.endpoints[0].port;
2866        drop(first.listeners);
2867
2868        let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
2869        let new_info = second.connection_info.clone();
2870        assert_ne!(old_info.key, new_info.key);
2871        assert_ne!(old_info.daemon_id, new_info.daemon_id);
2872        let server = start_server(second);
2873
2874        let mut old_stream = connect_from_info(&old_info).await.unwrap();
2875        let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
2876        assert!(
2877            old_auth.is_err(),
2878            "old key must not authenticate after restart"
2879        );
2880
2881        let reread = connection_file::read(&path).unwrap();
2882        let mut new_stream = connect_from_info(&reread).await.unwrap();
2883        authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
2884            .await
2885            .unwrap();
2886
2887        server.abort();
2888        let _ = server.await;
2889    }
2890
2891    #[cfg(unix)]
2892    #[tokio::test]
2893    async fn published_connection_file_permissions_are_owner_only() {
2894        let (_dir, path) = temp_connection_file_path("permissions");
2895        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2896
2897        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2898        assert_eq!(mode, 0o600);
2899
2900        drop(bound.listeners);
2901    }
2902}