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