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