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 connection_file_path: PathBuf,
101    pub port: u16,
102    pub daemon_ver: String,
103    configured_modules: Vec<ConfiguredModule>,
104    storage_config: Option<daemon_config::StorageConfig>,
105    admission_facts: AdmissionFactsConfig,
106    daemon_config_path: Option<PathBuf>,
107    configured_port: Option<u16>,
108    /// Daemon-wide route.bind relay budget in milliseconds (the fallback for
109    /// any module without a per-module override). `None` = built-in default
110    /// (12s — see `control::DEFAULT_ROUTE_BIND_RELAY_TIMEOUT`).
111    route_bind_relay_default_ms: Option<u64>,
112    reserved_capabilities: BTreeMap<String, String>,
113    watchdog_config: DaemonSelfWatchdogConfig,
114    connection_file_source: ConnectionFileSource,
115    /// Where module cgroups are prepared. Disabled unless a caller explicitly
116    /// opts in, because `Current` derives a host location from `/proc/self/cgroup`.
117    cgroup_placement: CgroupPlacementConfig,
118    /// Directory the supervisor writes per-module stdout/stderr capture files
119    /// into. `None` disables capture; the shipped binary supplies its real run
120    /// directory explicitly.
121    capture_logs_dir: Option<PathBuf>,
122    /// File the supervisor appends every module's terminal exits to, so exit
123    /// history survives a daemon restart. `None` keeps terminal history in
124    /// memory only (each module's ring). Absent by default for the same reason
125    /// as `capture_logs_dir`: an in-process daemon booted by a test must not
126    /// append its exits to the operator's real `terminals.jsonl`, where they
127    /// would show up in `ck module terminals`. The shipped binary supplies
128    /// `<run dir>/terminals.jsonl` explicitly.
129    terminal_journal_path: Option<PathBuf>,
130    /// Where the machine id is read from, or minted into when absent. `None`
131    /// serves no machine id. Absent by default for the same reason as
132    /// `capture_logs_dir`: an in-process daemon booted by a test must never
133    /// derive the operator's real data home and mint into it. The shipped binary
134    /// supplies `<data home>/cortexkit/machine-id` explicitly.
135    machine_id_path: Option<PathBuf>,
136    /// The live-children record: every supervised process this daemon has
137    /// running, kept so the next daemon can end the ones a crash left behind.
138    /// At startup, before any module is spawned, the previous daemon's record
139    /// here is swept. `None` keeps no record and sweeps nothing, for the same
140    /// reason as `capture_logs_dir`: an in-process daemon booted by a test
141    /// must never signal processes listed in the operator's real run
142    /// directory. The shipped binary supplies `<run dir>/live-children.json`.
143    live_children_path: Option<PathBuf>,
144}
145
146impl BootstrapConfig {
147    pub fn new(connection_file_path: impl Into<PathBuf>, port: u16) -> Self {
148        Self {
149            connection_file_path: connection_file_path.into(),
150            port,
151            daemon_ver: DAEMON_VERSION.to_owned(),
152            configured_modules: Vec::new(),
153            storage_config: None,
154            admission_facts: AdmissionFactsConfig::default(),
155            daemon_config_path: None,
156            configured_port: None,
157            route_bind_relay_default_ms: None,
158            reserved_capabilities: BTreeMap::new(),
159            watchdog_config: DaemonSelfWatchdogConfig::default(),
160            connection_file_source: ConnectionFileSource::Explicit,
161            cgroup_placement: CgroupPlacementConfig::default(),
162            capture_logs_dir: None,
163            terminal_journal_path: None,
164            machine_id_path: None,
165            live_children_path: None,
166        }
167    }
168
169    /// Keep the live-children record at `path`, and at startup end the
170    /// processes a previous daemon recorded there that are still running.
171    /// Embedding daemons and tests pass a path inside their own fixture tree.
172    pub fn with_live_children_record(mut self, path: impl Into<PathBuf>) -> Self {
173        self.live_children_path = Some(path.into());
174        self
175    }
176
177    /// Serve the machine id stored at `path`, minting it there at startup when
178    /// the file is absent. Embedding daemons and tests pass a path inside their
179    /// own fixture tree.
180    pub fn with_machine_id_path(mut self, path: impl Into<PathBuf>) -> Self {
181        self.machine_id_path = Some(path.into());
182        self
183    }
184
185    /// Selects module cgroup placement. The default is disabled.
186    pub fn with_cgroup_placement(mut self, placement: CgroupPlacementConfig) -> Self {
187        self.cgroup_placement = placement;
188        self
189    }
190
191    /// Redirects per-module stdout/stderr capture files out of the real run
192    /// directory. Tests that start an in-process daemon must call this with a
193    /// path inside their fixture tree.
194    pub fn with_capture_logs_dir(mut self, dir: impl Into<PathBuf>) -> Self {
195        self.capture_logs_dir = Some(dir.into());
196        self
197    }
198
199    /// Redirects the daemon-private journal, allowing embedded daemons and tests
200    /// to keep their observations out of the operator's live run directory.
201    pub fn with_terminal_journal_path(mut self, path: impl Into<PathBuf>) -> Self {
202        self.terminal_journal_path = Some(path.into());
203        self
204    }
205
206    pub fn from_env() -> Result<Self, BootstrapError> {
207        Self::from_env_with_daemon_config_path(daemon_config::default_config_path())
208    }
209
210    /// The SHIPPED BINARY's config, which is the only caller that should capture
211    /// child output into the operator's real run directory.
212    ///
213    /// Kept separate from `from_env` deliberately: see `capture_logs_dir` and
214    /// `terminal_journal_path` on this struct for why an absent value must mean
215    /// NO CAPTURE and NO JOURNAL rather than the operator's real run directory.
216    /// A run directory that cannot be resolved (a relative data home) refuses
217    /// startup instead of landing under the working directory.
218    pub fn from_env_for_daemon_binary() -> Result<Self, BootstrapError> {
219        let run_dir = daemon_config::daemon_run_dir().map_err(BootstrapError::RunDir)?;
220        let machine_id_path =
221            crate::machine_id::default_machine_id_path().map_err(BootstrapError::MachineId)?;
222        Ok(Self::from_env()?
223            .with_capture_logs_dir(run_dir.join("logs"))
224            .with_terminal_journal_path(run_dir.join("terminals.jsonl"))
225            .with_live_children_record(crate::live_children::record_path(&run_dir))
226            .with_machine_id_path(machine_id_path))
227    }
228
229    pub fn from_env_with_daemon_config_path(
230        daemon_config_path: impl AsRef<Path>,
231    ) -> Result<Self, BootstrapError> {
232        let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
233        let daemon_config =
234            daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
235        let config_port = daemon_config.as_ref().and_then(|config| config.port);
236        let storage_config = daemon_config
237            .as_ref()
238            .and_then(|config| config.storage.clone());
239        let admission_facts_carrier_module_id = daemon_config
240            .as_ref()
241            .and_then(|config| config.admission_facts_carrier_module_id.clone());
242        let admission_facts_targets = daemon_config
243            .as_ref()
244            .and_then(|config| config.admission_facts_targets.clone());
245        let route_bind_relay_default_ms = daemon_config
246            .as_ref()
247            .and_then(|config| config.route_bind_relay_timeout_ms);
248        let reserved_capabilities = daemon_config
249            .as_ref()
250            .map(|config| config.reserved_capabilities.clone())
251            .unwrap_or_default();
252        let configured_modules = daemon_config
253            .map(|config| config.modules)
254            .unwrap_or_default();
255
256        let port = match env::var(SUBC_PORT_ENV) {
257            Ok(raw) if !raw.trim().is_empty() => {
258                let port = raw
259                    .parse::<u16>()
260                    .map_err(|source| BootstrapError::InvalidPort { raw, source })?;
261                if let Some(config_port) = config_port {
262                    info!(
263                        env = SUBC_PORT_ENV,
264                        env_port = port,
265                        config_port,
266                        "SUBC_PORT overrides daemon config port"
267                    );
268                }
269                port
270            }
271            Ok(_) | Err(_) => config_port.unwrap_or(DEFAULT_SUBC_PORT),
272        };
273
274        let (connection_file_path, connection_file_source) =
275            connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR"));
276        Ok(Self::new(connection_file_path, port)
277            .with_configured_modules(configured_modules)
278            .with_storage_config(storage_config)
279            .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
280            .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
281            .with_reserved_capabilities(reserved_capabilities)
282            .with_daemon_config_source(daemon_config_path, config_port)
283            .with_connection_file_source(connection_file_source))
284    }
285
286    pub fn with_daemon_config_path(
287        self,
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 =
292            daemon_config::load(&daemon_config_path).map_err(BootstrapError::DaemonConfig)?;
293        let configured_port = daemon_config.as_ref().and_then(|config| config.port);
294        let storage_config = daemon_config
295            .as_ref()
296            .and_then(|config| config.storage.clone());
297        let admission_facts_carrier_module_id = daemon_config
298            .as_ref()
299            .and_then(|config| config.admission_facts_carrier_module_id.clone());
300        let admission_facts_targets = daemon_config
301            .as_ref()
302            .and_then(|config| config.admission_facts_targets.clone());
303        let route_bind_relay_default_ms = daemon_config
304            .as_ref()
305            .and_then(|config| config.route_bind_relay_timeout_ms);
306        let reserved_capabilities = daemon_config
307            .as_ref()
308            .map(|config| config.reserved_capabilities.clone())
309            .unwrap_or_default();
310        let configured_modules = daemon_config
311            .map(|config| config.modules)
312            .unwrap_or_default();
313        Ok(self
314            .with_configured_modules(configured_modules)
315            .with_storage_config(storage_config)
316            .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
317            .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
318            .with_reserved_capabilities(reserved_capabilities)
319            .with_daemon_config_source(daemon_config_path, configured_port))
320    }
321
322    pub fn with_configured_modules(
323        mut self,
324        modules: impl IntoIterator<Item = ConfiguredModule>,
325    ) -> Self {
326        self.configured_modules = modules.into_iter().collect();
327        self.configured_modules
328            .sort_by(|left, right| left.module_id.cmp(&right.module_id));
329        self
330    }
331
332    pub fn with_storage_config(
333        mut self,
334        storage_config: Option<daemon_config::StorageConfig>,
335    ) -> Self {
336        self.storage_config = storage_config;
337        self
338    }
339
340    pub fn with_admission_facts_config(
341        mut self,
342        carrier_module_id: Option<String>,
343        targets: Option<Vec<String>>,
344    ) -> Self {
345        self.admission_facts = AdmissionFactsConfig {
346            carrier_module_id,
347            targets,
348        };
349        self
350    }
351
352    /// Set the daemon-wide route.bind relay default (the fallback for any
353    /// module without a per-module override). `None` preserves the built-in
354    /// default (12s). `serve_bound_daemon` reads this at startup and threads
355    /// it into the control handler's daemon-wide field.
356    pub fn with_route_bind_relay_default_ms(mut self, ms: Option<u64>) -> Self {
357        self.route_bind_relay_default_ms = ms;
358        self
359    }
360
361    pub fn with_reserved_capabilities(
362        mut self,
363        reserved_capabilities: BTreeMap<String, String>,
364    ) -> Self {
365        self.reserved_capabilities = reserved_capabilities;
366        self
367    }
368
369    fn with_daemon_config_source(
370        mut self,
371        daemon_config_path: PathBuf,
372        configured_port: Option<u16>,
373    ) -> Self {
374        self.daemon_config_path = Some(daemon_config_path);
375        self.configured_port = configured_port;
376        self
377    }
378
379    fn with_connection_file_source(mut self, source: ConnectionFileSource) -> Self {
380        self.connection_file_source = source;
381        self
382    }
383
384    pub fn with_watchdog_config(mut self, watchdog_config: DaemonSelfWatchdogConfig) -> Self {
385        self.watchdog_config = watchdog_config;
386        self
387    }
388}
389
390/// Result of singleton discovery.
391// Built once per daemon start and immediately matched, so the size gap between
392// the variants costs nothing worth a box on the public shape.
393#[allow(clippy::large_enum_variant)]
394#[derive(Debug)]
395pub enum Outcome {
396    /// A live daemon authenticated from the connection file; this invocation should exit 0.
397    AlreadyRunning,
398    /// This process won the singleton race, owns bound loopback listener(s), and
399    /// has published a fresh connection file.
400    Bound(BoundDaemon),
401}
402
403#[derive(Debug)]
404pub struct BoundDaemon {
405    pub listeners: Vec<TcpListener>,
406    pub connection_info: ConnectionInfo,
407    pub connection_file_path: PathBuf,
408    pub connection_file_source: ConnectionFileSource,
409    /// The machine id this daemon serves, established before any connection is
410    /// accepted. `None` when the config named no machine id path.
411    pub machine_id: Option<crate::machine_id::MachineId>,
412    /// Ownership of the run directory, held until the daemon exits so no other
413    /// daemon sweeps or writes this one's run state. `None` when the config
414    /// keeps no live-children record, and so has no run directory to own.
415    run_dir_lock: Option<crate::run_dir_lock::RunDirLock>,
416}
417
418/// Resolve subc's per-user TCP connection-file path.
419///
420/// `$XDG_RUNTIME_DIR/subc-connection.json` is preferred because the runtime
421/// directory is already per-user on Unix desktops. Without it, subc falls back
422/// to the system temp dir with a per-user token in the filename so different OS
423/// users do not collide on shared temp directories.
424pub fn connection_file_path() -> PathBuf {
425    connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR")).0
426}
427
428fn connection_file_path_with_source(
429    runtime_dir: Option<OsString>,
430) -> (PathBuf, ConnectionFileSource) {
431    if let Some(runtime_dir) = runtime_dir.filter(|value| !value.is_empty()) {
432        return (
433            PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
434            ConnectionFileSource::XdgRuntimeDir,
435        );
436    }
437
438    (
439        env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token())),
440        ConnectionFileSource::TempDirFallback,
441    )
442}
443
444/// Resolve, claim, and serve the per-user daemon singleton.
445///
446/// A second invocation is successful: if a live daemon authenticates from the
447/// existing connection file, this returns `Ok(())` after logging and the caller
448/// exits with status 0.
449pub async fn run() -> Result<(), BootstrapError> {
450    // `run` is a binary entry point, so it opts into the production cgroup and
451    // child-log locations. In-process callers keep both features disabled by default.
452    run_with_config(
453        BootstrapConfig::from_env_for_daemon_binary()?
454            .with_cgroup_placement(CgroupPlacementConfig::Current),
455    )
456    .await
457}
458
459/// Serve a daemon from an explicit config. This is the entry point the twelve
460/// sibling repos use to boot an in-process daemon in their integration tests.
461///
462/// # THIS INSTALLS NO TRACING SUBSCRIBER, SO THE DAEMON IS SILENT BY DEFAULT
463///
464/// The daemon's own diagnostics go through `tracing`, and `tracing` DISCARDS
465/// every event when no subscriber is installed. The shipped binary installs one
466/// in `main` (`init_tracing`); this function deliberately does not, because a
467/// library that installs a global subscriber fights with whatever the host
468/// process already set up.
469///
470/// The consequence for a test harness is not "less verbose": it is that the
471/// daemon has NOTHING TO SAY about any failure, and a missing instrument reads
472/// exactly like a clean one. PLEX found this on 2026-09-18 while trying to
473/// capture daemon logs beside an intermittent bind failure, and discovered the
474/// daemon had been silent in every conformance run that repo had ever done --
475/// so the one client-side error string was all the evidence that could exist,
476/// and they had spent a real investigation on a failure whose second source was
477/// never being recorded.
478///
479/// Install one in the harness before calling this, and assert it did something
480/// (they measured 236 daemon lines with the subscriber installed, 0 with the
481/// call commented out) -- otherwise the fix is itself unverified.
482pub async fn run_with_config(config: BootstrapConfig) -> Result<(), BootstrapError> {
483    let configured_modules = config.configured_modules.clone();
484    let storage_config = config.storage_config.clone();
485    let admission_facts = config.admission_facts.clone();
486    let daemon_config_path = config.daemon_config_path.clone();
487    let configured_port = config.configured_port;
488    let route_bind_relay_default_ms = config.route_bind_relay_default_ms;
489    let reserved_capabilities = config.reserved_capabilities.clone();
490    let watchdog_config = config.watchdog_config.clone();
491    let cgroup_placement_config = config.cgroup_placement.clone();
492    let capture_logs_dir = config.capture_logs_dir.clone();
493    let terminal_journal_path = config.terminal_journal_path.clone();
494    let live_children_path = config.live_children_path.clone();
495    match ensure_singleton_with_config(config).await? {
496        Outcome::AlreadyRunning => {
497            info!("subc daemon already running");
498            Ok(())
499        }
500        Outcome::Bound(bound) => {
501            #[cfg(target_os = "linux")]
502            let cgroup_placement = prepare_cgroup_placement(&cgroup_placement_config);
503            #[cfg(not(target_os = "linux"))]
504            let _ = cgroup_placement_config;
505            serve_bound_daemon(
506                bound,
507                configured_modules,
508                storage_config,
509                admission_facts,
510                daemon_config_path,
511                configured_port,
512                route_bind_relay_default_ms,
513                reserved_capabilities,
514                watchdog_config,
515                capture_logs_dir,
516                terminal_journal_path,
517                live_children_path,
518                #[cfg(target_os = "linux")]
519                cgroup_placement,
520            )
521            .await
522        }
523    }
524}
525
526#[cfg(target_os = "linux")]
527fn prepare_cgroup_placement(config: &CgroupPlacementConfig) -> Option<subc_cgroup::Placement> {
528    let result = match config {
529        CgroupPlacementConfig::Disabled => return None,
530        CgroupPlacementConfig::Current => subc_cgroup::prepare_current(),
531        CgroupPlacementConfig::Root(root) => subc_cgroup::prepare_at(root),
532    };
533
534    match result {
535        Ok(Some(placement)) => Some(placement),
536        Ok(None) => {
537            warn!(
538                placement = ?config,
539                "module cgroup placement is disabled: configured cgroup root is not delegated"
540            );
541            None
542        }
543        Err(error) => {
544            warn!(
545                placement = ?config,
546                error = %error,
547                "module cgroup placement is disabled by an unexpected cgroup probe error"
548            );
549            None
550        }
551    }
552}
553
554/// Target soft limit for open file descriptors, applied to the daemon before any
555/// module is spawned so children inherit it. Multi-root modules (one process
556/// aggregating every project root's sqlite stores, index caches, watchers, and
557/// LSP pipes) trivially exceed the macOS default soft limit of 256; a launchd
558/// user agent does not pass login-shell ulimits through, so the raise must
559/// happen in-process.
560#[cfg(unix)]
561const NOFILE_TARGET: u64 = 65536;
562
563/// Raise RLIMIT_NOFILE to `NOFILE_TARGET` (clamped to the hard limit).
564/// Best-effort: failure is logged and never fatal, since the daemon can run
565/// under the inherited limit — modules with few roots just have less headroom.
566#[cfg(unix)]
567fn raise_nofile_limit() {
568    match rlimit::Resource::NOFILE.get() {
569        Ok((soft, hard)) => {
570            if soft >= NOFILE_TARGET {
571                return;
572            }
573            let target = NOFILE_TARGET.min(hard);
574            match rlimit::Resource::NOFILE.set(target, hard) {
575                Ok(()) => info!(
576                    previous_soft = soft,
577                    new_soft = target,
578                    hard,
579                    "raised open-file soft limit for daemon and module children"
580                ),
581                Err(err) => warn!(
582                    soft,
583                    hard,
584                    error = %err,
585                    "could not raise open-file soft limit; multi-root modules may exhaust descriptors"
586                ),
587            }
588        }
589        Err(err) => warn!(error = %err, "could not read open-file limit"),
590    }
591}
592
593/// CRT stdio-stream target on Windows (the `_setmaxstdio` maximum). Win32
594/// HANDLEs — what Rust `File`, tokio sockets, and SQLite's Win32 VFS actually
595/// consume — have a per-process quota in the millions and need no raise; the
596/// C-runtime stream table (default 512) is the only low ceiling, and it is
597/// per-process rather than inherited, so supervised modules linking the CRT
598/// must raise their own. Raising it here covers the daemon itself.
599#[cfg(windows)]
600fn raise_nofile_limit() {
601    const MAXSTDIO_TARGET: u32 = 8192;
602    let current = rlimit::getmaxstdio();
603    if current >= MAXSTDIO_TARGET {
604        return;
605    }
606    match rlimit::setmaxstdio(MAXSTDIO_TARGET) {
607        Ok(new_max) => info!(
608            previous = current,
609            new_max, "raised CRT stdio-stream limit for daemon"
610        ),
611        Err(err) => warn!(
612            current,
613            error = %err,
614            "could not raise CRT stdio-stream limit"
615        ),
616    }
617}
618
619#[cfg(not(any(unix, windows)))]
620fn raise_nofile_limit() {}
621
622pub async fn run_with_daemon_config_path(
623    config: BootstrapConfig,
624    daemon_config_path: impl AsRef<Path>,
625) -> Result<(), BootstrapError> {
626    run_with_config(config.with_daemon_config_path(daemon_config_path)?).await
627}
628
629#[allow(clippy::too_many_arguments)]
630async fn serve_bound_daemon(
631    bound: BoundDaemon,
632    configured_modules: Vec<ConfiguredModule>,
633    storage_config: Option<daemon_config::StorageConfig>,
634    admission_facts: AdmissionFactsConfig,
635    daemon_config_path: Option<PathBuf>,
636    configured_port: Option<u16>,
637    route_bind_relay_default_ms: Option<u64>,
638    reserved_capabilities: BTreeMap<String, String>,
639    watchdog_config: DaemonSelfWatchdogConfig,
640    capture_logs_dir: Option<PathBuf>,
641    terminal_journal_path: Option<PathBuf>,
642    live_children_path: Option<PathBuf>,
643    #[cfg(target_os = "linux")] cgroup_placement: Option<subc_cgroup::Placement>,
644) -> Result<(), BootstrapError> {
645    #[cfg(unix)]
646    let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
647        .map_err(BootstrapError::Signal)?;
648    // Windows has no SIGTERM. Ctrl-C is a different event, not an equivalent
649    // service-stop contract, so this shutdown handler is intentionally Unix-only.
650    raise_nofile_limit();
651
652    info!(
653        connection_file = %bound.connection_file_path.display(),
654        connection_file_source = %bound.connection_file_source,
655        connection_file_source_reason = bound.connection_file_source.reason(),
656        endpoints = ?bound.connection_info.endpoints,
657        configured_modules = configured_modules.len(),
658        machine_id = bound.machine_id.as_ref().map(|id| id.as_str()).unwrap_or("none"),
659        "subc daemon starting"
660    );
661
662    // Before anything can spawn a module (the configured modules below, or a
663    // client's start request once the listeners are served): a previous daemon
664    // that died without its shutdown stop may have left children running, and
665    // a fresh copy beside one would fight it for its port and stores. The
666    // record is not a running daemon's because this daemon holds the lock on
667    // the run directory the record lives in, and a running daemon holds that
668    // lock for its whole life. (Claiming the singleton is not enough: it is
669    // keyed on the connection file, which can live in a different runtime
670    // directory from the run directory.) The lock stays held until this
671    // function returns, which is when the daemon exits.
672    let run_dir_lock = bound.run_dir_lock;
673    if let Some(owner) = &run_dir_lock {
674        crate::live_children::sweep_orphans(
675            owner,
676            &crate::live_children::AdoptedPids::none(),
677            crate::live_children::SweepBounds::default(),
678        )
679        .await;
680    }
681
682    let registry = Arc::new(Registry::default());
683    let process_liveness = Arc::new(SupervisorProcessLiveness::new());
684    let supervisor_handle = SupervisorHandle::new();
685    let connected_clients = ConnectedClients::new();
686    let forwarding = Arc::new(ForwardingTable::default());
687    let daemon_incarnation = format!(
688        "{:032x}",
689        u128::from_be_bytes(bound.connection_info.daemon_id)
690    );
691    let supervisor = Supervisor::new(Arc::clone(&registry), RestartPolicy::default())
692        .with_process_liveness(process_liveness.clone())
693        .with_forwarding(Arc::clone(&forwarding))
694        .with_handle(supervisor_handle.clone())
695        .with_connection_file_path(bound.connection_file_path.clone())
696        .with_daemon_incarnation(daemon_incarnation.clone());
697    // ABSENT MEANS NO CAPTURE AND NO JOURNAL, NOT "THE REAL RUN DIRECTORY", and
698    // the difference is a production-corruption hazard rather than a preference.
699    // Both fields below follow the same rule: `None` for `capture_logs_dir`
700    // means supervised output is not captured, and `None` for
701    // `terminal_journal_path` means terminal history lives only in each
702    // module's in-memory ring (`supervisor.terminals` still answers, with the
703    // journal counters at zero).
704    //
705    // The terminal journal used to fall back to
706    // `daemon_run_dir().join("terminals.jsonl")`, so an in-process test daemon
707    // appended its fixture exits to the operator's journal, where they then
708    // appeared in `ck module terminals`.
709    //
710    // The capture line used to be `unwrap_or_else(|| daemon_run_dir().join("logs"))`,
711    // so ANY caller that did not set the field captured supervised children into
712    // the operator's live `~/.local/share/cortexkit/run/logs/`. That is twelve
713    // sibling repos whose integration tests boot an in-process daemon through
714    // `run_with_config` -- none of which asked for it, and none of which can see
715    // it from their side.
716    //
717    // Harmless while fixture module ids are fixture-shaped: this host carries 20
718    // zero-byte files from subc's own tests (good-aft, missing-aft,
719    // preview-consumer...). THE HAZARD IS A COLLISION. A fixture named "broca"
720    // or "aft" appends to a PRODUCTION capture file that operators read
721    // forensically and that placement gates count lines in -- with no residue to
722    // notice, because the file legitimately exists and legitimately grows.
723    //
724    // Found by BROCA (2026-09-19) from the other side: their rigs spawn the
725    // SHIPPED ck-subc and set XDG_CONFIG_HOME + XDG_RUNTIME_DIR but not
726    // XDG_DATA_HOME, so every local rig run supervised a module named "broca"
727    // and captured it into production's broca.stderr.log -- the same file I
728    // count seal lines in before and after placing their binaries.
729    //
730    // The supervisor already treats `None` as no-capture and no-journal, so
731    // this only removes invented defaults. The binary keeps capturing and
732    // journaling via `BootstrapConfig::from_env_for_daemon_binary`.
733    let supervisor = match terminal_journal_path {
734        Some(path) => supervisor.with_terminal_journal(path, daemon_incarnation),
735        None => supervisor,
736    };
737    let supervisor = match capture_logs_dir {
738        Some(dir) => supervisor.with_capture_logs_dir(dir),
739        None => supervisor,
740    };
741    let supervisor = match live_children_path {
742        Some(path) => supervisor.with_live_children_record(path),
743        None => supervisor,
744    };
745    #[cfg(target_os = "linux")]
746    let supervisor = supervisor.with_cgroup_placement(cgroup_placement);
747    // Collect per-module route.bind relay overrides BEFORE handing the
748    // `configured_modules` vector to the supervisor (which only needs each
749    // module's `drain_timeout_ms`). Each entry was filled in by parse-time
750    // resolution (per-module > daemon-wide > absent), so modules with no
751    // override are absent from this map and the daemon-wide default applies.
752    let route_bind_relay_timeouts = configured_modules
753        .iter()
754        .filter_map(|module| {
755            module
756                .route_bind_relay_timeout_ms
757                .map(|ms| (module.module_id.clone(), Duration::from_millis(ms)))
758        })
759        .collect::<std::collections::BTreeMap<_, _>>();
760    let control_start_clock = crate::clock::StartClock::capture();
761    let mut control = ControlHandler::with_forwarding(Arc::clone(&registry), forwarding)
762        .with_process_liveness(process_liveness)
763        .with_supervisor(supervisor_handle)
764        .with_connected_clients(connected_clients.clone())
765        .with_storage_config(storage_config)
766        .with_machine_id(bound.machine_id.clone())
767        .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets)
768        .with_route_bind_relay_timeouts(route_bind_relay_timeouts)
769        .with_daemon_provenance(
770            bound.connection_info.pid,
771            control_start_clock.started_at_ms(),
772            std::env::current_exe().ok(),
773            normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")),
774            normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")),
775        )
776        .with_daemon_start_clock(control_start_clock)
777        .with_capability_config(
778            configured_modules
779                .iter()
780                .map(|module| (module.module_id.clone(), module.enabled)),
781            reserved_capabilities,
782        );
783    if let Some(ms) = route_bind_relay_default_ms {
784        // A daemon-wide config value overrides the built-in default; a
785        // `None` here leaves the ControlHandler's 12s default in place.
786        control = control.with_route_bind_relay_timeout(Duration::from_millis(ms));
787    }
788    if let Some(config_path) = daemon_config_path {
789        control = control.with_supervisor_rescan(supervisor.clone(), config_path, configured_port);
790    }
791    let control = Arc::new(control);
792    let router = Arc::new(Router::with_control_handler(Arc::clone(&control)));
793    let auth = ServerAuth::new(
794        bound.connection_info.key.clone(),
795        bound.connection_info.daemon_id,
796        bound.connection_info.daemon_ver.clone(),
797    )
798    .with_connected_clients(connected_clients);
799
800    let mut serve_task =
801        AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
802    tokio::task::yield_now().await;
803    let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
804    // Off the startup path: it runs `systemctl`, and only ever logs.
805    #[cfg(target_os = "linux")]
806    let _kill_mode_check = AbortOnDrop::new(tokio::spawn(
807        crate::systemd_kill_mode::warn_if_kill_mode_defeats_ordered_shutdown(),
808    ));
809    let _watchdog_task = AbortOnDrop::new(
810        DaemonSelfWatchdog::new(
811            bound.connection_info.clone(),
812            bound.connection_file_path.clone(),
813        )
814        .with_config(watchdog_config)
815        .spawn(),
816    );
817
818    for configured in configured_modules {
819        let enabled = configured.enabled;
820        let health = configured.health;
821        let module_id = configured.module_id.clone();
822        match supervisor.supervise_configured_with_health(
823            configured.module_spec(),
824            enabled,
825            health,
826            configured.drain_timeout_ms,
827            configured.restart,
828        ) {
829            Ok(_) => {
830                // A raised failure threshold is normally a temporary allowance for a
831                // drive that deliberately stops a module, and it widens the window in
832                // which a genuinely wedged module looks fine. It is only ever noticed
833                // when someone thinks to re-read the config, so a relaxation outlives
834                // its reason silently: a rig ran five days at 240s of tolerance against
835                // a 90s default because a comment promising a revert was mistaken for
836                // the revert. Saying so on every boot costs one line and removes the
837                // need for anyone to remember.
838                let default_threshold = HealthConfig::default().failure_threshold;
839                if enabled && health.failure_threshold > default_threshold {
840                    warn!(
841                        module_id = %module_id,
842                        failure_threshold = health.failure_threshold,
843                        default_threshold,
844                        tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
845                        "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
846                    );
847                }
848                info!(module_id = %module_id, enabled, "configured module supervised");
849            }
850            Err(err) => {
851                error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
852            }
853        }
854    }
855
856    control.refresh_capability_requirements();
857    Arc::clone(&control).spawn_capability_deadline_loop();
858
859    #[cfg(unix)]
860    {
861        tokio::select! {
862            result = serve_task.join() => {
863                return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
864            }
865            _ = terminate.recv() => {}
866        }
867        // First, before the notice, the drain, or any connection close: from
868        // here on a module exit is recorded as `daemon_shutdown` and never
869        // respawned. Also before allowing a second signal to cut the bounded
870        // wait short, so the journal marker is always written.
871        supervisor.begin_daemon_shutdown();
872        // Stop the self-watchdog before closing the listener. Its next tick would
873        // connect to that listener, fail, and log an ERROR indistinguishable from
874        // a wedged daemon, once for every interval a planned stop lasts.
875        drop(_watchdog_task);
876        // Dropping the listener stops new accepts, not established connections:
877        // their detached tasks must remain live throughout notice and drain.
878        drop(serve_task);
879        let escalated = tokio::select! {
880            biased;
881            _ = terminate.recv() => {
882                info!("second SIGTERM: abandoning daemon shutdown wait");
883                true
884            }
885            result = supervisor.drain_for_daemon_shutdown() => {
886                if let Err(error) = result {
887                    warn!(%error, "daemon shutdown drain failed; exiting anyway");
888                }
889                false
890            }
891        };
892        // Supervised modules lead their own process groups, so a service
893        // manager's kill of this process's group does not reach them. (A
894        // systemd unit with KillMode=control-group kills by cgroup instead and
895        // does reach them; see `systemd_kill_mode`.) The daemon ends them
896        // itself: EOF (or SIGTERM for a protocol none child) first, then
897        // signals at each child's own drain deadline.
898        supervisor
899            .end_children_for_daemon_shutdown(escalated, async {
900                terminate.recv().await;
901            })
902            .await;
903        Ok(())
904    }
905    #[cfg(not(unix))]
906    serve_task
907        .join()
908        .await
909        .map_err(BootstrapError::ServeJoin)?
910        .map_err(BootstrapError::Serve)
911}
912
913fn normalized_build_provenance(value: &str) -> Option<String> {
914    match value.trim() {
915        "" | "unavailable" => None,
916        value => Some(value.to_string()),
917    }
918}
919
920/// Find an existing daemon or atomically bind loopback TCP for this daemon.
921///
922/// The algorithm is intentionally connect-first: an endpoint from the connection
923/// file is treated as live only after the TCP+key server-proof authenticates for
924/// that file's key and daemon_id. Stale or foreign connection files are reclaimed
925/// only while holding the per-user start lock; the TCP port is never the
926/// singleton primitive.
927pub async fn ensure_singleton(
928    connection_file_path: impl AsRef<Path>,
929    port: u16,
930) -> Result<Outcome, BootstrapError> {
931    ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
932}
933
934pub async fn ensure_singleton_with_config(
935    config: BootstrapConfig,
936) -> Result<Outcome, BootstrapError> {
937    let path = config.connection_file_path;
938
939    if matches!(probe_existing(&path).await?, Probe::Live) {
940        return Ok(Outcome::AlreadyRunning);
941    }
942
943    let _lock = StartLock::acquire(&path).await?;
944
945    // Re-probe after acquiring the start lock so a peer that won the race between
946    // our first failed probe and the lock acquisition is observed instead of
947    // overwritten.
948    if matches!(probe_existing(&path).await?, Probe::Live) {
949        return Ok(Outcome::AlreadyRunning);
950    }
951
952    // The start lock and the probe above only rule out a daemon using this
953    // connection file. A daemon started with another runtime directory but
954    // the same data home shares the run directory while passing both, so the
955    // run directory needs its own owner: refuse at once if a live daemon holds
956    // it, before anything below reads or writes run or data-home state.
957    let run_dir_lock = config
958        .live_children_path
959        .as_deref()
960        .map(crate::run_dir_lock::RunDirLock::acquire)
961        .transpose()?;
962
963    remove_stale_connection_file_if_present(&path)?;
964
965    // Established under the start lock, and under the run-directory lock when
966    // there is one, and before binding: a corrupt file stops boot before
967    // anything is published, and two daemons racing to start cannot both mint.
968    let machine_id = config
969        .machine_id_path
970        .as_deref()
971        .map(crate::machine_id::load_or_mint)
972        .transpose()
973        .map_err(BootstrapError::MachineId)?;
974
975    let (listeners, endpoints) = bind_loopback(config.port).await?;
976    let connection_info = ConnectionInfo {
977        schema: SCHEMA_VERSION,
978        wire_version: Some(PROTOCOL_VERSION),
979        endpoints,
980        key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
981        daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
982        pid: process::id(),
983        daemon_ver: config.daemon_ver,
984    };
985
986    if let Err(source) = write_atomic(&path, &connection_info) {
987        drop(listeners);
988        return Err(BootstrapError::ConnectionFileWrite { path, source });
989    }
990
991    Ok(Outcome::Bound(BoundDaemon {
992        listeners,
993        connection_info,
994        connection_file_path: path,
995        connection_file_source: config.connection_file_source,
996        machine_id,
997        run_dir_lock,
998    }))
999}
1000
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1002enum Probe {
1003    Live,
1004    StaleOrAbsent,
1005}
1006
1007async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
1008    let info = match connection_file::read(path) {
1009        Ok(info) => info,
1010        Err(source) if is_absent_or_stale_connection_file(&source) => {
1011            return Ok(Probe::StaleOrAbsent)
1012        }
1013        Err(source) => {
1014            return Err(BootstrapError::ConnectionFileRead {
1015                path: path.to_path_buf(),
1016                source,
1017            })
1018        }
1019    };
1020
1021    for endpoint in &info.endpoints {
1022        if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
1023            return Ok(Probe::Live);
1024        }
1025    }
1026
1027    Ok(Probe::StaleOrAbsent)
1028}
1029
1030async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
1031    let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
1032        return Probe::StaleOrAbsent;
1033    };
1034    if !ip.is_loopback() {
1035        return Probe::StaleOrAbsent;
1036    }
1037    let addr = SocketAddr::new(ip, endpoint.port);
1038
1039    let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
1040        Ok(Ok(stream)) => stream,
1041        Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
1042    };
1043
1044    match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
1045        Ok(()) => Probe::Live,
1046        Err(AuthError::DaemonIdMismatch)
1047        | Err(AuthError::InvalidServerProof)
1048        | Err(AuthError::UnexpectedEof { .. })
1049        | Err(AuthError::Timeout { .. })
1050        | Err(AuthError::JsonEncode { .. })
1051        | Err(AuthError::JsonDecode { .. })
1052        | Err(AuthError::Io { .. })
1053        | Err(AuthError::MessageTooLarge { .. })
1054        | Err(AuthError::KeyTooShort { .. })
1055        | Err(AuthError::Random(_))
1056        | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
1057    }
1058}
1059
1060fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
1061    match err {
1062        ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
1063        ConnectionFileError::JsonRead { .. }
1064        | ConnectionFileError::UnsupportedSchema { .. }
1065        | ConnectionFileError::Invalid { .. }
1066        | ConnectionFileError::KeyTooShort { .. }
1067        // A live daemon always publishes the file owner-only (0600), so a file
1068        // with insecure permissions is never a daemon we should defer to: treat it
1069        // as stale and take over (which republishes a correct 0600 file).
1070        | ConnectionFileError::InsecurePermissions { .. } => true,
1071        ConnectionFileError::MissingParent { .. }
1072        | ConnectionFileError::MissingFileName { .. }
1073        // A writable ancestor is an operator misconfiguration, never evidence
1074        // about whether a daemon is live. Reclaiming the file would republish key
1075        // material into the same directory the refusal is about.
1076        | ConnectionFileError::InsecureParentDirectory { .. }
1077        | ConnectionFileError::Io { .. }
1078        | ConnectionFileError::JsonWrite { .. }
1079        | ConnectionFileError::Random(_)
1080        // A wire mismatch may identify a newer live daemon, so never reclaim its
1081        // connection file merely because this binary cannot speak its envelope.
1082        | ConnectionFileError::WireVersionMismatch { .. } => false,
1083    }
1084}
1085
1086fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
1087    match fs::remove_file(path) {
1088        Ok(()) => Ok(()),
1089        Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1090        Err(source) => Err(BootstrapError::RemoveStale {
1091            path: path.to_path_buf(),
1092            source,
1093        }),
1094    }
1095}
1096
1097async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
1098    let v4_host = Ipv4Addr::LOCALHOST;
1099    let v4 = TcpListener::bind((v4_host, port))
1100        .await
1101        .map_err(|source| BootstrapError::Bind {
1102            host: v4_host.to_string(),
1103            port,
1104            source,
1105        })?;
1106    let actual_port = v4
1107        .local_addr()
1108        .map_err(|source| BootstrapError::LocalAddr {
1109            host: v4_host.to_string(),
1110            source,
1111        })?
1112        .port();
1113
1114    let mut listeners = vec![v4];
1115    let mut endpoints = vec![Endpoint {
1116        host: v4_host.to_string(),
1117        port: actual_port,
1118    }];
1119
1120    let v6_host = Ipv6Addr::LOCALHOST;
1121    match TcpListener::bind((v6_host, actual_port)).await {
1122        Ok(v6) => {
1123            listeners.push(v6);
1124            endpoints.push(Endpoint {
1125                host: v6_host.to_string(),
1126                port: actual_port,
1127            });
1128        }
1129        Err(err) if ipv6_loopback_unavailable(&err) => {
1130            warn!(
1131                port = actual_port,
1132                error = %err,
1133                "IPv6 loopback unavailable; serving only IPv4 loopback"
1134            );
1135        }
1136        Err(source) => {
1137            drop(listeners);
1138            return Err(BootstrapError::Bind {
1139                host: v6_host.to_string(),
1140                port: actual_port,
1141                source,
1142            });
1143        }
1144    }
1145
1146    Ok((listeners, endpoints))
1147}
1148
1149fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
1150    matches!(
1151        err.kind(),
1152        io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1153    ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1154}
1155
1156struct AbortOnDrop<T> {
1157    handle: JoinHandle<T>,
1158}
1159
1160impl<T> AbortOnDrop<T> {
1161    fn new(handle: JoinHandle<T>) -> Self {
1162        Self { handle }
1163    }
1164
1165    async fn join(&mut self) -> Result<T, JoinError> {
1166        (&mut self.handle).await
1167    }
1168}
1169
1170impl<T> Drop for AbortOnDrop<T> {
1171    fn drop(&mut self) {
1172        if !self.handle.is_finished() {
1173            self.handle.abort();
1174        }
1175    }
1176}
1177
1178struct StartLock {
1179    // Keep the locked file handle alive for the duration of bootstrap; closing
1180    // it releases the advisory lock while leaving the stable path in place.
1181    _file: fs::File,
1182}
1183
1184impl StartLock {
1185    async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1186        let path = start_lock_path(connection_file_path);
1187        for _ in 0..START_LOCK_RETRIES {
1188            let file = match open_owner_only_lock(&path) {
1189                Ok(file) => file,
1190                Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1191            };
1192            match FileExt::try_lock(&file) {
1193                Ok(()) => return Ok(Self { _file: file }),
1194                Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1195                Err(TryLockError::Error(source)) => {
1196                    return Err(BootstrapError::StartLockCreate { path, source });
1197                }
1198            }
1199        }
1200
1201        Err(BootstrapError::StartLockBusy {
1202            path,
1203            attempts: START_LOCK_RETRIES,
1204        })
1205    }
1206}
1207
1208pub(crate) fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1209    let mut options = fs::OpenOptions::new();
1210    options.read(true).write(true).create(true);
1211    #[cfg(unix)]
1212    {
1213        use std::os::unix::fs::OpenOptionsExt;
1214        options.mode(0o600);
1215    }
1216    options.open(path)
1217}
1218
1219fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1220    let file_name = connection_file_path
1221        .file_name()
1222        .map(|name| name.to_string_lossy())
1223        .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1224    let lock_name = format!("{file_name}.start-lock");
1225    connection_file_path
1226        .parent()
1227        .filter(|parent| !parent.as_os_str().is_empty())
1228        .unwrap_or_else(|| Path::new("."))
1229        .join(lock_name)
1230}
1231
1232fn non_empty_os_var(key: &str) -> Option<OsString> {
1233    let value = env::var_os(key)?;
1234    if value.is_empty() {
1235        None
1236    } else {
1237        Some(value)
1238    }
1239}
1240
1241/// Bootstrap-layer errors are deliberately typed so startup never panics for
1242/// ordinary daemon-discovery races or stale filesystem state.
1243#[derive(Debug)]
1244pub enum BootstrapError {
1245    #[cfg(unix)]
1246    Signal(io::Error),
1247    InvalidPort {
1248        raw: String,
1249        source: std::num::ParseIntError,
1250    },
1251    ConnectionFileRead {
1252        path: PathBuf,
1253        source: ConnectionFileError,
1254    },
1255    ConnectionFileWrite {
1256        path: PathBuf,
1257        source: ConnectionFileError,
1258    },
1259    GenerateConnectionFile(ConnectionFileError),
1260    StartLockCreate {
1261        path: PathBuf,
1262        source: io::Error,
1263    },
1264    StartLockBusy {
1265        path: PathBuf,
1266        attempts: usize,
1267    },
1268    /// The run-directory lock file could not be created, opened or locked.
1269    RunDirLockCreate {
1270        path: PathBuf,
1271        source: io::Error,
1272    },
1273    /// Another live process, almost certainly a daemon started with a
1274    /// different runtime directory over the same data home, owns the run
1275    /// directory. The daemon does not start. `holder_pid` is read from the
1276    /// lock file when possible and is informational only.
1277    RunDirBusy {
1278        path: PathBuf,
1279        holder_pid: Option<u32>,
1280    },
1281    RemoveStale {
1282        path: PathBuf,
1283        source: io::Error,
1284    },
1285    Bind {
1286        host: String,
1287        port: u16,
1288        source: io::Error,
1289    },
1290    LocalAddr {
1291        host: String,
1292        source: io::Error,
1293    },
1294    DaemonConfig(DaemonConfigError),
1295    /// The machine id could not be established: its file is corrupt, unreadable
1296    /// or unwritable, or the data home is relative. The daemon does not start.
1297    MachineId(crate::machine_id::MachineIdFileError),
1298    /// The daemon run directory could not be resolved because the data home is
1299    /// relative. The daemon does not start.
1300    RunDir(daemon_config::DaemonRunDirError),
1301    Serve(ServerError),
1302    ServeJoin(tokio::task::JoinError),
1303}
1304
1305impl fmt::Display for BootstrapError {
1306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1307        match self {
1308            #[cfg(unix)]
1309            Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1310            Self::InvalidPort { raw, source } => {
1311                write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1312            }
1313            Self::ConnectionFileRead { path, source } => write!(
1314                f,
1315                "failed to read connection file {}: {source}",
1316                path.display()
1317            ),
1318            Self::ConnectionFileWrite { path, source } => write!(
1319                f,
1320                "failed to publish connection file {}: {source}",
1321                path.display()
1322            ),
1323            Self::GenerateConnectionFile(err) => {
1324                write!(f, "failed to generate connection-file auth material: {err}")
1325            }
1326            Self::StartLockCreate { path, source } => {
1327                write!(
1328                    f,
1329                    "failed to create start lock {}: {source}",
1330                    path.display()
1331                )
1332            }
1333            Self::StartLockBusy { path, attempts } => write!(
1334                f,
1335                "start lock {} remained busy after {attempts} attempts",
1336                path.display()
1337            ),
1338            Self::RunDirLockCreate { path, source } => write!(
1339                f,
1340                "refusing to start: failed to lock run directory via {}: {source}",
1341                path.display()
1342            ),
1343            Self::RunDirBusy { path, holder_pid } => {
1344                write!(
1345                    f,
1346                    "refusing to start: run directory lock {} is held by another daemon",
1347                    path.display()
1348                )?;
1349                if let Some(pid) = holder_pid {
1350                    write!(f, " (pid {pid})")?;
1351                }
1352                write!(
1353                    f,
1354                    "; a live daemon owns this data home's run state (typically one started with a different XDG_RUNTIME_DIR)"
1355                )
1356            }
1357            Self::RemoveStale { path, source } => write!(
1358                f,
1359                "failed to remove stale connection file {}: {source}",
1360                path.display()
1361            ),
1362            Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1363                write!(
1364                    f,
1365                    "port {port} in use on loopback {host}: {source}; set the port in config"
1366                )
1367            }
1368            Self::Bind { host, port, source } => {
1369                write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1370            }
1371            Self::LocalAddr { host, source } => {
1372                write!(f, "failed to read local address for {host}: {source}")
1373            }
1374            Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1375            Self::MachineId(err) => write!(f, "refusing to start: {err}"),
1376            Self::RunDir(err) => write!(f, "refusing to start: {err}"),
1377            Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1378            Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1379        }
1380    }
1381}
1382
1383impl Error for BootstrapError {
1384    fn source(&self) -> Option<&(dyn Error + 'static)> {
1385        match self {
1386            #[cfg(unix)]
1387            Self::Signal(source) => Some(source),
1388            Self::InvalidPort { source, .. } => Some(source),
1389            Self::ConnectionFileRead { source, .. }
1390            | Self::ConnectionFileWrite { source, .. }
1391            | Self::GenerateConnectionFile(source) => Some(source),
1392            Self::StartLockCreate { source, .. }
1393            | Self::RunDirLockCreate { source, .. }
1394            | Self::RemoveStale { source, .. }
1395            | Self::Bind { source, .. }
1396            | Self::LocalAddr { source, .. } => Some(source),
1397            Self::DaemonConfig(err) => Some(err),
1398            Self::MachineId(err) => Some(err),
1399            Self::RunDir(err) => Some(err),
1400            Self::Serve(err) => Some(err),
1401            Self::ServeJoin(err) => Some(err),
1402            Self::StartLockBusy { .. } | Self::RunDirBusy { .. } => None,
1403        }
1404    }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use crate::server::ServerAuth;
1411    #[cfg(target_os = "linux")]
1412    use std::collections::BTreeSet;
1413    use std::sync::Mutex;
1414    #[cfg(target_os = "linux")]
1415    use subc_control::ModuleProtocol;
1416    use subc_test_support::TestTempDir;
1417    use subc_transport::MIN_KEY_LEN;
1418    use tokio::io::AsyncReadExt;
1419    use tokio::task::JoinHandle;
1420
1421    #[cfg(unix)]
1422    use std::os::unix::fs::PermissionsExt;
1423
1424    static ENV_LOCK: Mutex<()> = Mutex::new(());
1425
1426    #[test]
1427    fn normalized_build_provenance_preserves_real_values() {
1428        assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1429    }
1430
1431    #[test]
1432    fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1433        assert_eq!(normalized_build_provenance("unavailable"), None);
1434        assert_eq!(normalized_build_provenance(""), None);
1435    }
1436
1437    #[cfg(target_os = "linux")]
1438    fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1439        let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1440        let relative = cgroups
1441            .lines()
1442            .find_map(|line| line.strip_prefix("0::"))
1443            .ok_or_else(|| {
1444                io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1445            })?;
1446        Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1447    }
1448
1449    #[cfg(target_os = "linux")]
1450    fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1451        let modules = current_cgroup_path_for_test()?.join("subc-modules");
1452        let entries = match fs::read_dir(modules) {
1453            Ok(entries) => entries,
1454            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1455            Err(error) => return Err(error),
1456        };
1457        let mut directories = BTreeSet::new();
1458        for entry in entries {
1459            let entry = entry?;
1460            if entry.file_type()?.is_dir() {
1461                directories.insert(entry.file_name());
1462            }
1463        }
1464        Ok(directories)
1465    }
1466
1467    #[cfg(target_os = "linux")]
1468    async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1469        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1470        while !path.exists() && tokio::time::Instant::now() < deadline {
1471            assert!(
1472                !task.is_finished(),
1473                "daemon exited before creating {}",
1474                path.display()
1475            );
1476            sleep(Duration::from_millis(10)).await;
1477        }
1478        assert!(path.exists(), "daemon did not create {}", path.display());
1479    }
1480
1481    /// An in-process daemon with the default (disabled) cgroup placement must
1482    /// leave the host's live module cgroup tree exactly as it found it.
1483    /// Red-checking this test (making it fail) creates a
1484    /// `cgroup-isolation-probe-*` directory in the live cgroup tree, which must be removed by hand.
1485    #[cfg(target_os = "linux")]
1486    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1487    async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1488        let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1489        let module_id = format!("cgroup-isolation-probe-{}", process::id());
1490        let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1491        assert!(
1492            !before.contains(&OsString::from(&module_id)),
1493            "isolation probe cgroup already exists before this daemon starts"
1494        );
1495        let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1496        let module = ConfiguredModule {
1497            module_id,
1498            program: PathBuf::from("sh"),
1499            args: vec!["-c".to_string(), "sleep 30".to_string()],
1500            env: Vec::new(),
1501            log: None,
1502            enabled: true,
1503            reserved: false,
1504            reserved_prefixes: Vec::new(),
1505            protocol: ModuleProtocol::None,
1506            overlap: Default::default(),
1507            health: HealthConfig::default(),
1508            drain_timeout_ms: None,
1509            route_bind_relay_timeout_ms: None,
1510            restart: RestartPolicy::default(),
1511        };
1512        let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1513            .with_configured_modules([module])
1514            .with_capture_logs_dir(temp.join("logs"))
1515            .with_terminal_journal_path(temp.join("terminals.jsonl"));
1516        let task = tokio::spawn(run_with_config(config));
1517
1518        wait_for_path(&capture, &task).await;
1519        let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1520
1521        task.abort();
1522        assert!(task
1523            .await
1524            .expect_err("aborted daemon task must cancel")
1525            .is_cancelled());
1526        assert_eq!(
1527            before, after,
1528            "an in-process daemon must not create or reconcile ambient module cgroups"
1529        );
1530    }
1531
1532    #[cfg(target_os = "linux")]
1533    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1534    async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1535        let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1536        let cgroup_root = temp.join("cgroup");
1537        fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1538        fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1539        let modules = cgroup_root.join("subc-modules");
1540        let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1541            .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1542            .with_terminal_journal_path(temp.join("terminals.jsonl"));
1543        let task = tokio::spawn(run_with_config(config));
1544
1545        wait_for_path(&modules, &task).await;
1546
1547        task.abort();
1548        assert!(task
1549            .await
1550            .expect_err("aborted daemon task must cancel")
1551            .is_cancelled());
1552        assert!(
1553            fs::read_dir(&modules)
1554                .expect("read prepared modules directory")
1555                .next()
1556                .is_none(),
1557            "the delegation probe must clean up after itself"
1558        );
1559    }
1560
1561    struct EnvGuard {
1562        key: &'static str,
1563        previous: Option<OsString>,
1564    }
1565
1566    impl EnvGuard {
1567        fn set(key: &'static str, value: &Path) -> Self {
1568            let previous = env::var_os(key);
1569            env::set_var(key, value);
1570            Self { key, previous }
1571        }
1572
1573        fn set_str(key: &'static str, value: &str) -> Self {
1574            let previous = env::var_os(key);
1575            env::set_var(key, value);
1576            Self { key, previous }
1577        }
1578
1579        fn unset(key: &'static str) -> Self {
1580            let previous = env::var_os(key);
1581            env::remove_var(key);
1582            Self { key, previous }
1583        }
1584    }
1585
1586    impl Drop for EnvGuard {
1587        fn drop(&mut self) {
1588            match &self.previous {
1589                Some(value) => env::set_var(self.key, value),
1590                None => env::remove_var(self.key),
1591            }
1592        }
1593    }
1594
1595    fn unique_temp_dir(name: &str) -> TestTempDir {
1596        TestTempDir::new(name)
1597    }
1598
1599    fn temp_connection_file_path(name: &str) -> (TestTempDir, PathBuf) {
1600        let dir = unique_temp_dir(name);
1601        let path = dir.join("conn.json");
1602        (dir, path)
1603    }
1604
1605    fn auth_for(info: &ConnectionInfo) -> ServerAuth {
1606        ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
1607    }
1608
1609    fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
1610        let auth = auth_for(&bound.connection_info);
1611        tokio::spawn(serve_listeners(
1612            bound.listeners,
1613            Arc::new(Router::with_default_self_handler()),
1614            auth,
1615        ))
1616    }
1617
1618    fn expect_bound(outcome: Outcome) -> BoundDaemon {
1619        match outcome {
1620            Outcome::Bound(bound) => bound,
1621            Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
1622        }
1623    }
1624
1625    async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
1626        let endpoint = conn
1627            .endpoints
1628            .first()
1629            .expect("test connection file should have an endpoint");
1630        let ip: IpAddr = endpoint.host.parse().unwrap();
1631        TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
1632    }
1633
1634    fn make_connection_info(port: u16) -> ConnectionInfo {
1635        ConnectionInfo {
1636            schema: SCHEMA_VERSION,
1637            wire_version: Some(PROTOCOL_VERSION),
1638            endpoints: vec![Endpoint {
1639                host: "127.0.0.1".to_owned(),
1640                port,
1641            }],
1642            key: generate_key().unwrap(),
1643            daemon_id: generate_daemon_id().unwrap(),
1644            pid: process::id(),
1645            daemon_ver: "test-subc".to_owned(),
1646        }
1647    }
1648
1649    fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
1650        fs::write(path, contents).unwrap();
1651        #[cfg(unix)]
1652        fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
1653    }
1654
1655    fn assert_owner_only_connection_file(path: &Path) {
1656        // `path` is only inspected on Unix (mode bits); on Windows the owner-only
1657        // guarantee comes from the inherited %TEMP% ACL, nothing to assert here.
1658        #[cfg(unix)]
1659        {
1660            let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
1661            assert_eq!(mode, 0o600);
1662        }
1663        #[cfg(not(unix))]
1664        let _ = path;
1665    }
1666
1667    #[test]
1668    fn connection_file_path_uses_xdg_runtime_dir_when_set() {
1669        let _env_lock = ENV_LOCK.lock().unwrap();
1670        let runtime_dir = unique_temp_dir("xdg-runtime");
1671        let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
1672
1673        assert_eq!(
1674            connection_file_path(),
1675            runtime_dir.join(CONNECTION_FILE_NAME)
1676        );
1677    }
1678
1679    #[test]
1680    fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
1681        let runtime_dir = OsString::from("/run/user/1000");
1682
1683        let (path, source) = connection_file_path_with_source(Some(runtime_dir));
1684
1685        assert_eq!(
1686            path,
1687            PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
1688        );
1689        assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
1690    }
1691
1692    #[test]
1693    fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
1694        let _env_lock = ENV_LOCK.lock().unwrap();
1695        let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
1696
1697        assert_eq!(
1698            connection_file_path(),
1699            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1700        );
1701    }
1702
1703    #[test]
1704    fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
1705        let (path, source) = connection_file_path_with_source(None);
1706
1707        assert_eq!(
1708            path,
1709            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1710        );
1711        assert_eq!(source, ConnectionFileSource::TempDirFallback);
1712    }
1713
1714    /// Concurrent callers must derive one token. The former temp-file uid probe
1715    /// could fail transiently (same-tick name collision, fd exhaustion) and send
1716    /// the loser down the env-derived fallback with a different identity for the
1717    /// same user; this fence keeps identity independent of filesystem luck.
1718    #[test]
1719    fn user_connection_token_is_stable_under_concurrent_callers() {
1720        let expected = user_connection_token();
1721        let workers: Vec<_> = (0..32)
1722            .map(|_| {
1723                std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
1724            })
1725            .collect();
1726        for worker in workers {
1727            for token in worker.join().expect("probe thread") {
1728                assert_eq!(token, expected, "token diverged under concurrent probes");
1729            }
1730        }
1731    }
1732
1733    #[test]
1734    fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
1735        let (path, source) = connection_file_path_with_source(Some(OsString::new()));
1736
1737        assert_eq!(
1738            path,
1739            env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
1740        );
1741        assert_eq!(source, ConnectionFileSource::TempDirFallback);
1742    }
1743
1744    /// The shipped binary is the only caller that journals terminal exits into
1745    /// the real run directory, so its constructor must supply that path itself;
1746    /// the in-process default leaves it unset.
1747    #[test]
1748    fn daemon_binary_config_journals_and_captures_into_the_run_dir() {
1749        let _env_lock = ENV_LOCK.lock().unwrap();
1750        let root = unique_temp_dir("daemon-binary-config");
1751        let data_home = root.join("data");
1752        let _data = EnvGuard::set("XDG_DATA_HOME", &data_home);
1753        let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
1754        let _port = EnvGuard::unset(SUBC_PORT_ENV);
1755
1756        let config = BootstrapConfig::from_env_for_daemon_binary().unwrap();
1757
1758        let run_dir = data_home.join("cortexkit").join("run");
1759        assert_eq!(
1760            config.terminal_journal_path,
1761            Some(run_dir.join("terminals.jsonl"))
1762        );
1763        assert_eq!(config.capture_logs_dir, Some(run_dir.join("logs")));
1764        assert_eq!(
1765            BootstrapConfig::new(root.join("connection.json"), 0).terminal_journal_path,
1766            None,
1767            "an in-process config must not journal anywhere unless asked to"
1768        );
1769    }
1770
1771    #[test]
1772    fn daemon_binary_config_refuses_a_relative_data_home() {
1773        let _env_lock = ENV_LOCK.lock().unwrap();
1774        let root = unique_temp_dir("daemon-binary-relative-data");
1775        let _data = EnvGuard::set_str("XDG_DATA_HOME", "relative-data-home");
1776        let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
1777        let _port = EnvGuard::unset(SUBC_PORT_ENV);
1778
1779        let error = BootstrapConfig::from_env_for_daemon_binary()
1780            .expect_err("a relative data home must refuse the daemon binary's config");
1781        assert!(
1782            matches!(error, BootstrapError::RunDir(_)),
1783            "expected a run-directory refusal, got {error}"
1784        );
1785        assert!(error.to_string().contains("XDG_DATA_HOME"), "{error}");
1786    }
1787
1788    #[test]
1789    fn configured_port_uses_default_config_and_env_override() {
1790        let _env_lock = ENV_LOCK.lock().unwrap();
1791        let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
1792        let config_path = conn_path.with_file_name("subc.jsonc");
1793
1794        let _port = EnvGuard::unset(SUBC_PORT_ENV);
1795        assert_eq!(
1796            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1797                .unwrap()
1798                .port,
1799            DEFAULT_SUBC_PORT
1800        );
1801
1802        fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
1803        assert_eq!(
1804            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1805                .unwrap()
1806                .port,
1807            8123
1808        );
1809
1810        let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
1811        assert_eq!(
1812            BootstrapConfig::from_env_with_daemon_config_path(&config_path)
1813                .unwrap()
1814                .port,
1815            9012
1816        );
1817    }
1818
1819    #[tokio::test]
1820    async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
1821        let (_dir, path) = temp_connection_file_path("already-running");
1822
1823        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1824        let server = start_server(bound);
1825
1826        let second = ensure_singleton(&path, 0).await.unwrap();
1827        assert!(matches!(second, Outcome::AlreadyRunning));
1828
1829        server.abort();
1830        let _ = server.await;
1831    }
1832
1833    #[tokio::test]
1834    async fn daemon_connection_file_publishes_protocol_wire_version() {
1835        let (_dir, path) = temp_connection_file_path("wire-version");
1836        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1837        assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
1838        assert_eq!(
1839            connection_file::read(&path).unwrap().wire_version,
1840            Some(PROTOCOL_VERSION)
1841        );
1842
1843        drop(bound.listeners);
1844    }
1845
1846    #[tokio::test]
1847    async fn stale_unbound_connection_file_is_reclaimed() {
1848        let (_dir, path) = temp_connection_file_path("stale-reclaim");
1849        let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1850        let stale_port = stale.local_addr().unwrap().port();
1851        drop(stale);
1852        let stale_info = make_connection_info(stale_port);
1853        write_atomic(&path, &stale_info).unwrap();
1854
1855        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1856        assert_ne!(bound.connection_info.key, stale_info.key);
1857        drop(bound.listeners);
1858    }
1859
1860    #[cfg(unix)]
1861    #[tokio::test]
1862    async fn ensure_singleton_reclaims_insecure_connection_file() {
1863        let (_dir, path) = temp_connection_file_path("insecure-reclaim");
1864        let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1865        let stale_port = stale.local_addr().unwrap().port();
1866        drop(stale);
1867        let stale_info = make_connection_info(stale_port);
1868        write_atomic(&path, &stale_info).unwrap();
1869        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1870
1871        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1872        assert_ne!(bound.connection_info.key, stale_info.key);
1873        assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1874        assert_owner_only_connection_file(&path);
1875
1876        drop(bound.listeners);
1877    }
1878
1879    #[tokio::test]
1880    async fn ensure_singleton_reclaims_non_loopback_connection_file() {
1881        let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
1882        let mut stale_info = make_connection_info(8757);
1883        stale_info.endpoints = vec![Endpoint {
1884            host: "192.0.2.10".to_owned(),
1885            port: 8757,
1886        }];
1887        write_atomic(&path, &stale_info).unwrap();
1888
1889        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1890        assert_ne!(bound.connection_info.key, stale_info.key);
1891        assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
1892        assert!(bound
1893            .connection_info
1894            .endpoints
1895            .iter()
1896            .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
1897        assert_owner_only_connection_file(&path);
1898
1899        drop(bound.listeners);
1900    }
1901
1902    #[tokio::test]
1903    async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
1904        let mut unsupported_schema = make_connection_info(8757);
1905        unsupported_schema.schema = SCHEMA_VERSION + 1;
1906
1907        let mut empty_endpoints = make_connection_info(8757);
1908        empty_endpoints.endpoints.clear();
1909
1910        let mut short_key = make_connection_info(8757);
1911        short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
1912
1913        let cases = vec![
1914            (
1915                "unsupported-schema",
1916                serde_json::to_vec(&unsupported_schema).unwrap(),
1917                Some(unsupported_schema),
1918            ),
1919            (
1920                "empty-endpoints",
1921                serde_json::to_vec(&empty_endpoints).unwrap(),
1922                Some(empty_endpoints),
1923            ),
1924            (
1925                "short-key",
1926                serde_json::to_vec(&short_key).unwrap(),
1927                Some(short_key),
1928            ),
1929            ("invalid-json", b"{not valid connection json".to_vec(), None),
1930        ];
1931
1932        for (label, contents, old_info) in cases {
1933            let (_dir, path) = temp_connection_file_path(label);
1934            write_raw_owner_only_connection_file(&path, &contents);
1935
1936            let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1937            if let Some(old_info) = old_info {
1938                assert_ne!(bound.connection_info.key, old_info.key, "{label}");
1939                assert_ne!(
1940                    bound.connection_info.daemon_id, old_info.daemon_id,
1941                    "{label}"
1942                );
1943            }
1944            assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
1945            assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
1946            assert_owner_only_connection_file(&path);
1947
1948            drop(bound.listeners);
1949        }
1950    }
1951
1952    #[tokio::test]
1953    async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
1954        let (_dir, path) = temp_connection_file_path("foreign-reclaim");
1955        let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
1956        let foreign_port = foreign.local_addr().unwrap().port();
1957        write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
1958        let foreign_task = tokio::spawn(async move {
1959            if let Ok((mut stream, _)) = foreign.accept().await {
1960                let mut buf = [0u8; 64];
1961                let _ = stream.read(&mut buf).await;
1962            }
1963        });
1964
1965        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
1966        assert!(bound
1967            .connection_info
1968            .endpoints
1969            .iter()
1970            .all(|endpoint| endpoint.port != foreign_port));
1971
1972        drop(bound.listeners);
1973        let _ = foreign_task.await;
1974    }
1975
1976    #[tokio::test]
1977    async fn stale_start_lock_file_is_reclaimable() {
1978        let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
1979        let lock_path = start_lock_path(&path);
1980        drop(open_owner_only_lock(&lock_path).unwrap());
1981        assert!(lock_path.is_file());
1982
1983        let lock = StartLock::acquire(&path).await.unwrap();
1984        assert!(lock_path.is_file());
1985
1986        drop(lock);
1987        assert!(lock_path.is_file());
1988    }
1989
1990    #[tokio::test]
1991    async fn held_start_lock_blocks_second_acquire_until_release() {
1992        let (_dir, path) = temp_connection_file_path("start-lock-held");
1993        let lock_path = start_lock_path(&path);
1994        let first = StartLock::acquire(&path).await.unwrap();
1995
1996        let err = match StartLock::acquire(&path).await {
1997            Ok(_) => panic!("second acquire while held must stay busy"),
1998            Err(err) => err,
1999        };
2000        assert!(matches!(
2001            err,
2002            BootstrapError::StartLockBusy {
2003                ref path,
2004                attempts: START_LOCK_RETRIES,
2005            } if path == &lock_path
2006        ));
2007
2008        drop(first);
2009
2010        let second = StartLock::acquire(&path)
2011            .await
2012            .expect("released advisory lock should be reclaimable");
2013        drop(second);
2014    }
2015
2016    #[tokio::test]
2017    async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
2018        let (_dir, path) = temp_connection_file_path("bind-conflict");
2019        let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2020        let occupied_port = occupied.local_addr().unwrap().port();
2021
2022        let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
2023        assert!(matches!(
2024            err,
2025            BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
2026        ));
2027        assert!(err.to_string().contains("set the port in config"));
2028
2029        drop(occupied);
2030    }
2031
2032    #[tokio::test]
2033    async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
2034        let (_dir, path) = temp_connection_file_path("key-rotation");
2035        let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2036        let old_info = first.connection_info.clone();
2037        let fixed_port = old_info.endpoints[0].port;
2038        drop(first.listeners);
2039
2040        let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
2041        let new_info = second.connection_info.clone();
2042        assert_ne!(old_info.key, new_info.key);
2043        assert_ne!(old_info.daemon_id, new_info.daemon_id);
2044        let server = start_server(second);
2045
2046        let mut old_stream = connect_from_info(&old_info).await.unwrap();
2047        let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
2048        assert!(
2049            old_auth.is_err(),
2050            "old key must not authenticate after restart"
2051        );
2052
2053        let reread = connection_file::read(&path).unwrap();
2054        let mut new_stream = connect_from_info(&reread).await.unwrap();
2055        authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
2056            .await
2057            .unwrap();
2058
2059        server.abort();
2060        let _ = server.await;
2061    }
2062
2063    #[cfg(unix)]
2064    #[tokio::test]
2065    async fn published_connection_file_permissions_are_owner_only() {
2066        let (_dir, path) = temp_connection_file_path("permissions");
2067        let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2068
2069        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2070        assert_eq!(mode, 0o600);
2071
2072        drop(bound.listeners);
2073    }
2074}