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#[derive(Debug, Clone, Default)]
77struct AdmissionFactsConfig {
78 carrier_module_id: Option<String>,
79 targets: Option<Vec<String>>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub enum CgroupPlacementConfig {
92 #[default]
93 Disabled,
94 Current,
95 Root(PathBuf),
96}
97
98#[derive(Debug, Clone)]
99pub struct BootstrapConfig {
100 pub operator_queue_wait: Duration,
101 pub operator_timeout: Duration,
102 pub operator_stuck_grace: Duration,
103 #[cfg(feature = "test-support")]
104 pub operator_script: Option<PathBuf>,
105 #[cfg(feature = "test-support")]
106 pub operator_events: Option<PathBuf>,
107 pub connection_file_path: PathBuf,
108 pub port: u16,
109 pub daemon_ver: String,
110 configured_modules: Vec<ConfiguredModule>,
111 storage_config: Option<daemon_config::StorageConfig>,
112 admission_facts: AdmissionFactsConfig,
113 scope_authority_owners: Vec<String>,
115 activity_config: daemon_config::ActivityConfig,
116 daemon_config_path: Option<PathBuf>,
117 configured_port: Option<u16>,
118 route_bind_relay_default_ms: Option<u64>,
122 reserved_capabilities: BTreeMap<String, String>,
123 watchdog_config: DaemonSelfWatchdogConfig,
124 connection_file_source: ConnectionFileSource,
125 cgroup_placement: CgroupPlacementConfig,
128 capture_logs_dir: Option<PathBuf>,
132 terminal_journal_path: Option<PathBuf>,
140 machine_id_path: Option<PathBuf>,
146 live_children_path: Option<PathBuf>,
154 privacy_trampoline: Option<PathBuf>,
155}
156
157impl BootstrapConfig {
158 pub fn new(connection_file_path: impl Into<PathBuf>, port: u16) -> Self {
159 Self {
160 connection_file_path: connection_file_path.into(),
161 operator_queue_wait: Duration::from_secs(150),
162 operator_timeout: Duration::from_secs(120),
163 operator_stuck_grace: Duration::from_secs(10),
164 #[cfg(feature = "test-support")]
165 operator_script: None,
166 #[cfg(feature = "test-support")]
167 operator_events: None,
168 port,
169 daemon_ver: DAEMON_VERSION.to_owned(),
170 configured_modules: Vec::new(),
171 storage_config: None,
172 admission_facts: AdmissionFactsConfig::default(),
173 scope_authority_owners: daemon_config::default_scope_authority_owners(),
174 activity_config: daemon_config::ActivityConfig::default(),
175 daemon_config_path: None,
176 configured_port: None,
177 route_bind_relay_default_ms: None,
178 reserved_capabilities: BTreeMap::new(),
179 watchdog_config: DaemonSelfWatchdogConfig::default(),
180 connection_file_source: ConnectionFileSource::Explicit,
181 cgroup_placement: CgroupPlacementConfig::default(),
182 capture_logs_dir: None,
183 terminal_journal_path: None,
184 machine_id_path: None,
185 live_children_path: None,
186 privacy_trampoline: None,
187 }
188 }
189
190 pub fn with_live_children_record(mut self, path: impl Into<PathBuf>) -> Self {
194 self.live_children_path = Some(path.into());
195 self
196 }
197
198 pub fn with_privacy_trampoline(mut self, path: impl Into<PathBuf>) -> Self {
201 self.privacy_trampoline = Some(path.into());
202 self
203 }
204
205 pub fn with_machine_id_path(mut self, path: impl Into<PathBuf>) -> Self {
209 self.machine_id_path = Some(path.into());
210 self
211 }
212
213 pub fn with_cgroup_placement(mut self, placement: CgroupPlacementConfig) -> Self {
215 self.cgroup_placement = placement;
216 self
217 }
218
219 pub fn with_capture_logs_dir(mut self, dir: impl Into<PathBuf>) -> Self {
223 self.capture_logs_dir = Some(dir.into());
224 self
225 }
226
227 pub fn with_terminal_journal_path(mut self, path: impl Into<PathBuf>) -> Self {
230 self.terminal_journal_path = Some(path.into());
231 self
232 }
233
234 pub fn from_env() -> Result<Self, BootstrapError> {
235 Self::from_env_with_daemon_config_path(daemon_config::default_config_path())
236 }
237
238 pub fn from_env_for_daemon_binary() -> Result<Self, BootstrapError> {
247 let run_dir = daemon_config::daemon_run_dir().map_err(BootstrapError::RunDir)?;
248 let machine_id_path =
249 crate::machine_id::default_machine_id_path().map_err(BootstrapError::MachineId)?;
250 let config = Self::from_env()?
251 .with_capture_logs_dir(run_dir.join("logs"))
252 .with_terminal_journal_path(run_dir.join("terminals.jsonl"))
253 .with_live_children_record(crate::live_children::record_path(&run_dir))
254 .with_machine_id_path(machine_id_path);
255 #[cfg(feature = "test-support")]
256 let config = {
257 let mut config = config;
258 config.operator_script =
259 std::env::var_os("SUBC_TEST_OPERATOR_SCRIPT").map(PathBuf::from);
260 config.operator_events =
261 std::env::var_os("SUBC_TEST_OPERATOR_EVENTS").map(PathBuf::from);
262 for (name, value) in [
263 (
264 "SUBC_TEST_OPERATOR_QUEUE_WAIT_MS",
265 &mut config.operator_queue_wait,
266 ),
267 (
268 "SUBC_TEST_OPERATOR_TIMEOUT_MS",
269 &mut config.operator_timeout,
270 ),
271 (
272 "SUBC_TEST_OPERATOR_STUCK_MS",
273 &mut config.operator_stuck_grace,
274 ),
275 ] {
276 if let Ok(ms) = std::env::var(name) {
277 if let Ok(ms) = ms.parse::<u64>() {
278 *value = Duration::from_millis(ms);
279 }
280 }
281 }
282 config
283 };
284 Ok(config)
285 }
286
287 pub fn from_env_with_daemon_config_path(
288 daemon_config_path: impl AsRef<Path>,
289 ) -> Result<Self, BootstrapError> {
290 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
291 let (daemon_config, activity_config) =
292 daemon_config::load_with_activity(&daemon_config_path)
293 .map_err(BootstrapError::DaemonConfig)?
294 .map(|(config, activity)| (Some(config), activity))
295 .unwrap_or_default();
296 let config_port = daemon_config.as_ref().and_then(|config| config.port);
297 let storage_config = daemon_config
298 .as_ref()
299 .and_then(|config| config.storage.clone());
300 let admission_facts_carrier_module_id = daemon_config
301 .as_ref()
302 .and_then(|config| config.admission_facts_carrier_module_id.clone());
303 let admission_facts_targets = daemon_config
304 .as_ref()
305 .and_then(|config| config.admission_facts_targets.clone());
306 let scope_authority_owners = daemon_config
307 .as_ref()
308 .map(|config| config.scope_authority_owners.clone())
309 .unwrap_or_else(daemon_config::default_scope_authority_owners);
310 let route_bind_relay_default_ms = daemon_config
311 .as_ref()
312 .and_then(|config| config.route_bind_relay_timeout_ms);
313 let reserved_capabilities = daemon_config
314 .as_ref()
315 .map(|config| config.reserved_capabilities.clone())
316 .unwrap_or_default();
317 let configured_modules = daemon_config
318 .map(|config| config.modules)
319 .unwrap_or_default();
320
321 let port = match env::var(SUBC_PORT_ENV) {
322 Ok(raw) if !raw.trim().is_empty() => {
323 let port = raw
324 .parse::<u16>()
325 .map_err(|source| BootstrapError::InvalidPort { raw, source })?;
326 if let Some(config_port) = config_port {
327 info!(
328 env = SUBC_PORT_ENV,
329 env_port = port,
330 config_port,
331 "SUBC_PORT overrides daemon config port"
332 );
333 }
334 port
335 }
336 Ok(_) | Err(_) => config_port.unwrap_or(DEFAULT_SUBC_PORT),
337 };
338
339 let (connection_file_path, connection_file_source) =
340 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR"));
341 Ok(Self::new(connection_file_path, port)
342 .with_configured_modules(configured_modules)
343 .with_storage_config(storage_config)
344 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
345 .with_scope_authority_owners(scope_authority_owners)
346 .with_activity_config(activity_config)
347 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
348 .with_reserved_capabilities(reserved_capabilities)
349 .with_daemon_config_source(daemon_config_path, config_port)
350 .with_connection_file_source(connection_file_source))
351 }
352
353 pub fn with_daemon_config_path(
354 self,
355 daemon_config_path: impl AsRef<Path>,
356 ) -> Result<Self, BootstrapError> {
357 let daemon_config_path = daemon_config_path.as_ref().to_path_buf();
358 let (daemon_config, activity_config) =
359 daemon_config::load_with_activity(&daemon_config_path)
360 .map_err(BootstrapError::DaemonConfig)?
361 .map(|(config, activity)| (Some(config), activity))
362 .unwrap_or_default();
363 let configured_port = daemon_config.as_ref().and_then(|config| config.port);
364 let storage_config = daemon_config
365 .as_ref()
366 .and_then(|config| config.storage.clone());
367 let admission_facts_carrier_module_id = daemon_config
368 .as_ref()
369 .and_then(|config| config.admission_facts_carrier_module_id.clone());
370 let admission_facts_targets = daemon_config
371 .as_ref()
372 .and_then(|config| config.admission_facts_targets.clone());
373 let scope_authority_owners = daemon_config
374 .as_ref()
375 .map(|config| config.scope_authority_owners.clone())
376 .unwrap_or_else(daemon_config::default_scope_authority_owners);
377 let route_bind_relay_default_ms = daemon_config
378 .as_ref()
379 .and_then(|config| config.route_bind_relay_timeout_ms);
380 let reserved_capabilities = daemon_config
381 .as_ref()
382 .map(|config| config.reserved_capabilities.clone())
383 .unwrap_or_default();
384 let configured_modules = daemon_config
385 .map(|config| config.modules)
386 .unwrap_or_default();
387 Ok(self
388 .with_configured_modules(configured_modules)
389 .with_storage_config(storage_config)
390 .with_admission_facts_config(admission_facts_carrier_module_id, admission_facts_targets)
391 .with_scope_authority_owners(scope_authority_owners)
392 .with_activity_config(activity_config)
393 .with_route_bind_relay_default_ms(route_bind_relay_default_ms)
394 .with_reserved_capabilities(reserved_capabilities)
395 .with_daemon_config_source(daemon_config_path, configured_port))
396 }
397
398 pub fn with_configured_modules(
399 mut self,
400 modules: impl IntoIterator<Item = ConfiguredModule>,
401 ) -> Self {
402 self.configured_modules = modules.into_iter().collect();
403 self.configured_modules
404 .sort_by(|left, right| left.module_id.cmp(&right.module_id));
405 self
406 }
407
408 pub fn with_storage_config(
409 mut self,
410 storage_config: Option<daemon_config::StorageConfig>,
411 ) -> Self {
412 self.storage_config = storage_config;
413 self
414 }
415
416 pub fn with_admission_facts_config(
417 mut self,
418 carrier_module_id: Option<String>,
419 targets: Option<Vec<String>>,
420 ) -> Self {
421 self.admission_facts = AdmissionFactsConfig {
422 carrier_module_id,
423 targets,
424 };
425 self
426 }
427
428 pub fn with_scope_authority_owners(mut self, owners: Vec<String>) -> Self {
431 self.scope_authority_owners = owners;
432 self
433 }
434
435 pub fn with_activity_config(mut self, config: daemon_config::ActivityConfig) -> Self {
436 self.activity_config = config;
437 self
438 }
439
440 pub fn with_route_bind_relay_default_ms(mut self, ms: Option<u64>) -> Self {
445 self.route_bind_relay_default_ms = ms;
446 self
447 }
448
449 pub fn with_reserved_capabilities(
450 mut self,
451 reserved_capabilities: BTreeMap<String, String>,
452 ) -> Self {
453 self.reserved_capabilities = reserved_capabilities;
454 self
455 }
456
457 fn with_daemon_config_source(
458 mut self,
459 daemon_config_path: PathBuf,
460 configured_port: Option<u16>,
461 ) -> Self {
462 self.daemon_config_path = Some(daemon_config_path);
463 self.configured_port = configured_port;
464 self
465 }
466
467 fn with_connection_file_source(mut self, source: ConnectionFileSource) -> Self {
468 self.connection_file_source = source;
469 self
470 }
471
472 pub fn with_watchdog_config(mut self, watchdog_config: DaemonSelfWatchdogConfig) -> Self {
473 self.watchdog_config = watchdog_config;
474 self
475 }
476}
477
478#[allow(clippy::large_enum_variant)]
482#[derive(Debug)]
483pub enum Outcome {
484 AlreadyRunning,
486 Bound(BoundDaemon),
489}
490
491#[derive(Debug)]
492pub struct BoundDaemon {
493 pub listeners: Vec<TcpListener>,
494 pub connection_info: ConnectionInfo,
495 pub connection_file_path: PathBuf,
496 pub connection_file_source: ConnectionFileSource,
497 pub machine_id: Option<crate::machine_id::MachineId>,
500 run_dir_lock: Option<crate::run_dir_lock::RunDirLock>,
504 publication_lock: Option<StartLock>,
510}
511
512pub fn connection_file_path() -> PathBuf {
519 connection_file_path_with_source(non_empty_os_var("XDG_RUNTIME_DIR")).0
520}
521
522fn connection_file_path_with_source(
523 runtime_dir: Option<OsString>,
524) -> (PathBuf, ConnectionFileSource) {
525 if let Some(runtime_dir) = runtime_dir.filter(|value| !value.is_empty()) {
526 return (
527 PathBuf::from(runtime_dir).join(CONNECTION_FILE_NAME),
528 ConnectionFileSource::XdgRuntimeDir,
529 );
530 }
531
532 (
533 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token())),
534 ConnectionFileSource::TempDirFallback,
535 )
536}
537
538pub async fn run() -> Result<(), BootstrapError> {
544 run_with_config(
547 BootstrapConfig::from_env_for_daemon_binary()?
548 .with_cgroup_placement(CgroupPlacementConfig::Current),
549 )
550 .await
551}
552
553pub async fn run_with_config(config: BootstrapConfig) -> Result<(), BootstrapError> {
577 let forwarding = Arc::new(ForwardingTable::default());
578 forwarding.operator_confirms().configure_bootstrap(&config);
579 let configured_modules = config.configured_modules.clone();
580 let storage_config = config.storage_config.clone();
581 let admission_facts = config.admission_facts.clone();
582 let scope_authority_owners = config.scope_authority_owners.clone();
583 let activity_config = config.activity_config;
584 let daemon_config_path = config.daemon_config_path.clone();
585 let configured_port = config.configured_port;
586 let route_bind_relay_default_ms = config.route_bind_relay_default_ms;
587 let reserved_capabilities = config.reserved_capabilities.clone();
588 let watchdog_config = config.watchdog_config.clone();
589 let cgroup_placement_config = config.cgroup_placement.clone();
590 let capture_logs_dir = config.capture_logs_dir.clone();
591 let terminal_journal_path = config.terminal_journal_path.clone();
592 let live_children_path = config.live_children_path.clone();
593 let privacy_trampoline = config.privacy_trampoline.clone();
594 match ensure_singleton_inner(config, false).await? {
595 Outcome::AlreadyRunning => {
596 info!("subc daemon already running");
597 Ok(())
598 }
599 Outcome::Bound(bound) => {
600 #[cfg(target_os = "linux")]
601 let cgroup_placement = prepare_cgroup_placement(&cgroup_placement_config);
602 #[cfg(not(target_os = "linux"))]
603 let _ = cgroup_placement_config;
604 serve_bound_daemon(
605 bound,
606 forwarding,
607 configured_modules,
608 storage_config,
609 admission_facts,
610 scope_authority_owners,
611 activity_config,
612 daemon_config_path,
613 configured_port,
614 route_bind_relay_default_ms,
615 reserved_capabilities,
616 watchdog_config,
617 capture_logs_dir,
618 terminal_journal_path,
619 live_children_path,
620 privacy_trampoline,
621 #[cfg(target_os = "linux")]
622 cgroup_placement,
623 )
624 .await
625 }
626 }
627}
628
629#[cfg(target_os = "linux")]
630fn prepare_cgroup_placement(config: &CgroupPlacementConfig) -> Option<subc_cgroup::Placement> {
631 let result = match config {
632 CgroupPlacementConfig::Disabled => return None,
633 CgroupPlacementConfig::Current => subc_cgroup::prepare_current(),
634 CgroupPlacementConfig::Root(root) => subc_cgroup::prepare_at(root),
635 };
636
637 match result {
638 Ok(Some(placement)) => Some(placement),
639 Ok(None) => {
640 warn!(
641 placement = ?config,
642 "module cgroup placement is disabled: configured cgroup root is not delegated"
643 );
644 None
645 }
646 Err(error) => {
647 warn!(
648 placement = ?config,
649 error = %error,
650 "module cgroup placement is disabled by an unexpected cgroup probe error"
651 );
652 None
653 }
654 }
655}
656
657#[cfg(unix)]
664const NOFILE_TARGET: u64 = 65536;
665
666#[cfg(unix)]
670fn raise_nofile_limit() {
671 #[cfg(target_os = "macos")]
672 let ceiling = std::process::Command::new("/usr/sbin/sysctl")
673 .args(["-n", "kern.maxfilesperproc"])
674 .output()
675 .ok()
676 .filter(|output| output.status.success())
677 .and_then(|output| String::from_utf8(output.stdout).ok())
678 .and_then(|value| value.trim().parse::<u64>().ok())
679 .filter(|value| *value > 0);
680 #[cfg(not(target_os = "macos"))]
681 let ceiling = None;
682 raise_nofile_limit_with_ceiling(ceiling);
683}
684
685#[cfg(unix)]
686fn raise_nofile_limit_with_ceiling(kernel_ceiling: Option<u64>) {
687 match rlimit::Resource::NOFILE.get() {
688 Ok((soft, hard)) => {
689 let target = NOFILE_TARGET
693 .min(hard)
694 .min(kernel_ceiling.unwrap_or(u64::MAX));
695 if soft >= target {
696 return;
697 }
698 match rlimit::Resource::NOFILE.set(target, hard) {
699 Ok(()) => info!(
700 previous_soft = soft,
701 new_soft = target,
702 hard,
703 "raised open-file soft limit for daemon and module children"
704 ),
705 Err(err) => warn!(
706 soft,
707 hard,
708 error = %err,
709 "could not raise open-file soft limit; multi-root modules may exhaust descriptors"
710 ),
711 }
712 }
713 Err(err) => warn!(error = %err, "could not read open-file limit"),
714 }
715}
716
717#[cfg(windows)]
724fn raise_nofile_limit() {
725 const MAXSTDIO_TARGET: u32 = 8192;
726 let current = rlimit::getmaxstdio();
727 if current >= MAXSTDIO_TARGET {
728 return;
729 }
730 match rlimit::setmaxstdio(MAXSTDIO_TARGET) {
731 Ok(new_max) => info!(
732 previous = current,
733 new_max, "raised CRT stdio-stream limit for daemon"
734 ),
735 Err(err) => warn!(
736 current,
737 error = %err,
738 "could not raise CRT stdio-stream limit"
739 ),
740 }
741}
742
743#[cfg(not(any(unix, windows)))]
744fn raise_nofile_limit() {}
745
746pub async fn run_with_daemon_config_path(
747 config: BootstrapConfig,
748 daemon_config_path: impl AsRef<Path>,
749) -> Result<(), BootstrapError> {
750 run_with_config(config.with_daemon_config_path(daemon_config_path)?).await
751}
752
753#[allow(clippy::too_many_arguments)]
754async fn serve_bound_daemon(
755 bound: BoundDaemon,
756 forwarding: Arc<ForwardingTable>,
757 configured_modules: Vec<ConfiguredModule>,
758 storage_config: Option<daemon_config::StorageConfig>,
759 admission_facts: AdmissionFactsConfig,
760 scope_authority_owners: Vec<String>,
761 activity_config: daemon_config::ActivityConfig,
762 daemon_config_path: Option<PathBuf>,
763 configured_port: Option<u16>,
764 route_bind_relay_default_ms: Option<u64>,
765 reserved_capabilities: BTreeMap<String, String>,
766 watchdog_config: DaemonSelfWatchdogConfig,
767 capture_logs_dir: Option<PathBuf>,
768 terminal_journal_path: Option<PathBuf>,
769 live_children_path: Option<PathBuf>,
770 privacy_trampoline: Option<PathBuf>,
771 #[cfg(target_os = "linux")] cgroup_placement: Option<subc_cgroup::Placement>,
772) -> Result<(), BootstrapError> {
773 #[cfg(unix)]
774 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
775 .map_err(BootstrapError::Signal)?;
776 raise_nofile_limit();
779
780 info!(
781 connection_file = %bound.connection_file_path.display(),
782 connection_file_source = %bound.connection_file_source,
783 connection_file_source_reason = bound.connection_file_source.reason(),
784 endpoints = ?bound.connection_info.endpoints,
785 configured_modules = configured_modules.len(),
786 machine_id = bound.machine_id.as_ref().map(|id| id.as_str()).unwrap_or("none"),
787 "subc daemon starting"
788 );
789
790 let run_dir_lock = bound.run_dir_lock;
801 let publication_lock = bound.publication_lock;
802 if let Some(owner) = &run_dir_lock {
803 crate::live_children::sweep_orphans(
804 owner,
805 &crate::live_children::AdoptedPids::none(),
806 crate::live_children::SweepBounds::default(),
807 )
808 .await;
809 }
810
811 let registry = Arc::new(Registry::default());
812 let process_liveness = Arc::new(SupervisorProcessLiveness::new());
813 let supervisor_handle = SupervisorHandle::new();
814 let connected_clients = ConnectedClients::new();
815 let daemon_incarnation = format!(
816 "{:032x}",
817 u128::from_be_bytes(bound.connection_info.daemon_id)
818 );
819 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
820 .with_process_liveness(process_liveness.clone())
821 .with_forwarding(Arc::clone(&forwarding))
822 .with_handle(supervisor_handle.clone())
823 .with_connection_file_path(bound.connection_file_path.clone())
824 .with_daemon_incarnation(daemon_incarnation.clone());
825 let supervisor = match privacy_trampoline {
826 Some(path) => supervisor.with_privacy_trampoline(path),
827 None => supervisor,
828 };
829 let activity_run_dir = terminal_journal_path
869 .as_ref()
870 .and_then(|path| path.parent())
871 .map(Path::to_path_buf);
872 let supervisor = match terminal_journal_path {
873 Some(path) => supervisor.with_terminal_journal(path, daemon_incarnation.clone()),
874 None => supervisor,
875 };
876 let supervisor = match capture_logs_dir {
877 Some(dir) => supervisor.with_capture_logs_dir(dir),
878 None => supervisor,
879 };
880 let supervisor = match live_children_path {
881 Some(path) => supervisor.with_live_children_record(path),
882 None => supervisor,
883 };
884 #[cfg(target_os = "linux")]
885 let supervisor = supervisor.with_cgroup_placement(cgroup_placement);
886 let route_bind_relay_timeouts = configured_modules
892 .iter()
893 .filter_map(|module| {
894 module
895 .route_bind_relay_timeout_ms
896 .map(|ms| (module.module_id.clone(), Duration::from_millis(ms)))
897 })
898 .collect::<std::collections::BTreeMap<_, _>>();
899 let control_start_clock = crate::clock::StartClock::capture();
900 let mut control = ControlHandler::with_forwarding(Arc::clone(®istry), forwarding)
901 .with_process_liveness(process_liveness)
902 .with_supervisor(supervisor_handle)
903 .with_connected_clients(connected_clients.clone())
904 .with_storage_config(storage_config)
905 .with_machine_id(bound.machine_id.clone())
906 .with_admission_facts_config(admission_facts.carrier_module_id, admission_facts.targets)
907 .with_scope_authority_owners(scope_authority_owners)
908 .with_route_bind_relay_timeouts(route_bind_relay_timeouts)
909 .with_daemon_provenance(
910 bound.connection_info.pid,
911 control_start_clock.started_at_ms(),
912 std::env::current_exe().ok(),
913 normalized_build_provenance(env!("SUBC_BUILD_GIT_SHA")),
914 normalized_build_provenance(env!("SUBC_BUILD_LOCK_DIGEST")),
915 )
916 .with_daemon_start_clock(control_start_clock)
917 .with_capability_config(
918 configured_modules
919 .iter()
920 .map(|module| (module.module_id.clone(), module.enabled)),
921 reserved_capabilities,
922 );
923 if let Some(run_dir) = activity_run_dir {
924 control =
925 control.with_activity_ledger_config(&run_dir, daemon_incarnation, activity_config);
926 }
927 if let Some(ms) = route_bind_relay_default_ms {
928 control = control.with_route_bind_relay_timeout(Duration::from_millis(ms));
931 }
932 if let Some(config_path) = daemon_config_path {
933 control = control.with_supervisor_rescan(supervisor.clone(), config_path, configured_port);
934 }
935 let control = Arc::new(control);
936 let router = Arc::new(Router::with_control_handler(Arc::clone(&control)));
937 let auth = ServerAuth::new(
938 bound.connection_info.key.clone(),
939 bound.connection_info.daemon_id,
940 bound.connection_info.daemon_ver.clone(),
941 )
942 .with_connected_clients(connected_clients);
943
944 let mut registered_modules = Vec::with_capacity(configured_modules.len());
948 for configured in configured_modules {
949 let enabled = configured.enabled;
950 let health = configured.health.clone();
951 let module_id = configured.module_id.clone();
952 let registered = match supervisor.register_configured_with_health(
953 configured.module_spec(),
954 enabled,
955 health.clone(),
956 configured.drain_timeout_ms,
957 configured.restart,
958 ) {
959 Ok(registered) => registered,
960 Err(err) => {
961 error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
962 continue;
963 }
964 };
965 registered_modules.push(registered);
966 let default_threshold = HealthConfig::default().failure_threshold;
969 if enabled && health.failure_threshold > default_threshold {
970 warn!(
971 module_id = %module_id,
972 failure_threshold = health.failure_threshold,
973 default_threshold,
974 tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
975 "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
976 );
977 }
978 info!(module_id = %module_id, enabled, "configured module registered");
979 }
980 control.refresh_capability_requirements();
981
982 let mut serve_task =
983 AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
984 verify_serving(&bound.connection_info).await?;
985 let (published, publication_lock) = publish_connection_file(
992 &bound.connection_file_path,
993 &bound.connection_info,
994 publication_lock,
995 )
996 .await;
997 published?;
998 #[cfg(test)]
999 startup_publication_hook::run(&bound.connection_file_path).await;
1000 drop(publication_lock);
1001 let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
1002 #[cfg(target_os = "linux")]
1004 let _kill_mode_check = AbortOnDrop::new(tokio::spawn(
1005 crate::systemd_kill_mode::warn_if_kill_mode_defeats_ordered_shutdown(),
1006 ));
1007 let _watchdog_task = AbortOnDrop::new(
1008 DaemonSelfWatchdog::new(
1009 bound.connection_info.clone(),
1010 bound.connection_file_path.clone(),
1011 )
1012 .with_config(watchdog_config)
1013 .spawn(),
1014 );
1015
1016 for registered in registered_modules {
1017 match supervisor.start_registered_configured(registered, None) {
1018 Ok(module) => {
1019 info!(module_id = %module.module_id(), "configured module supervised");
1020 }
1021 Err(err) => {
1022 error!(error = %err, "failed to start configured module; continuing daemon startup");
1023 }
1024 }
1025 }
1026
1027 control.refresh_capability_requirements();
1028 Arc::clone(&control).spawn_capability_deadline_loop();
1029 Arc::clone(&control).spawn_scope_expiry_loop();
1030
1031 #[cfg(unix)]
1032 {
1033 tokio::select! {
1034 result = serve_task.join() => {
1035 return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
1036 }
1037 _ = terminate.recv() => {}
1038 }
1039 supervisor.begin_daemon_shutdown();
1044 drop(_watchdog_task);
1048 drop(serve_task);
1051 let escalated = tokio::select! {
1052 biased;
1053 _ = terminate.recv() => {
1054 info!("second SIGTERM: abandoning daemon shutdown wait");
1055 true
1056 }
1057 result = supervisor.drain_for_daemon_shutdown() => {
1058 if let Err(error) = result {
1059 warn!(%error, "daemon shutdown drain failed; exiting anyway");
1060 }
1061 false
1062 }
1063 };
1064 supervisor
1071 .end_children_for_daemon_shutdown(escalated, async {
1072 terminate.recv().await;
1073 })
1074 .await;
1075 Ok(())
1076 }
1077 #[cfg(not(unix))]
1078 serve_task
1079 .join()
1080 .await
1081 .map_err(BootstrapError::ServeJoin)?
1082 .map_err(BootstrapError::Serve)
1083}
1084
1085fn normalized_build_provenance(value: &str) -> Option<String> {
1086 match value.trim() {
1087 "" | "unavailable" => None,
1088 value => Some(value.to_string()),
1089 }
1090}
1091
1092pub async fn ensure_singleton(
1100 connection_file_path: impl AsRef<Path>,
1101 port: u16,
1102) -> Result<Outcome, BootstrapError> {
1103 ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
1104}
1105
1106pub async fn ensure_singleton_with_config(
1107 config: BootstrapConfig,
1108) -> Result<Outcome, BootstrapError> {
1109 ensure_singleton_inner(config, true).await
1110}
1111
1112async fn ensure_singleton_inner(
1113 config: BootstrapConfig,
1114 publish: bool,
1115) -> Result<Outcome, BootstrapError> {
1116 let path = config.connection_file_path;
1117
1118 if matches!(probe_existing(&path).await?, Probe::Live) {
1119 return Ok(Outcome::AlreadyRunning);
1120 }
1121
1122 let lock = StartLock::acquire(&path).await?;
1123
1124 if matches!(probe_existing(&path).await?, Probe::Live) {
1128 return Ok(Outcome::AlreadyRunning);
1129 }
1130
1131 let run_dir_lock = config
1137 .live_children_path
1138 .as_deref()
1139 .map(crate::run_dir_lock::RunDirLock::acquire)
1140 .transpose()?;
1141
1142 remove_stale_connection_file_if_present(&path)?;
1143
1144 let (machine_id, lock, run_dir_lock) = match config.machine_id_path {
1152 None => (None, lock, run_dir_lock),
1153 Some(machine_id_path) => {
1154 let task_path = machine_id_path.clone();
1155 match run_blocking_holding((lock, run_dir_lock), move || {
1156 crate::machine_id::load_or_mint(&task_path)
1157 })
1158 .await
1159 {
1160 Ok((Ok(id), (lock, run_dir_lock))) => (Some(id), lock, run_dir_lock),
1161 Ok((Err(error), _locks)) => return Err(BootstrapError::MachineId(error)),
1162 Err(join_error) => {
1163 return Err(BootstrapError::MachineId(
1164 crate::machine_id::MachineIdFileError::Write {
1165 path: machine_id_path,
1166 source: io::Error::other(describe_blocking_task_failure(
1167 "loading or minting the machine id",
1168 &join_error,
1169 )),
1170 },
1171 ))
1172 }
1173 }
1174 }
1175 };
1176
1177 let (listeners, endpoints) = bind_loopback(config.port).await?;
1178 let connection_info = ConnectionInfo {
1179 schema: SCHEMA_VERSION,
1180 wire_version: Some(PROTOCOL_VERSION),
1181 endpoints,
1182 key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
1183 daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
1184 pid: process::id(),
1185 daemon_ver: config.daemon_ver,
1186 };
1187
1188 let publication_lock = if publish {
1192 let (published, lock) = publish_connection_file(&path, &connection_info, lock).await;
1193 if let Err(error) = published {
1194 drop(listeners);
1195 return Err(error);
1196 }
1197 drop(lock);
1198 None
1199 } else {
1200 Some(lock)
1201 };
1202
1203 Ok(Outcome::Bound(BoundDaemon {
1204 listeners,
1205 connection_info,
1206 connection_file_path: path,
1207 connection_file_source: config.connection_file_source,
1208 machine_id,
1209 run_dir_lock,
1210 publication_lock,
1211 }))
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1215enum Probe {
1216 Live,
1217 StaleOrAbsent,
1218}
1219
1220async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
1221 let info = match connection_file::read(path) {
1222 Ok(info) => info,
1223 Err(source) if is_absent_or_stale_connection_file(&source) => {
1224 return Ok(Probe::StaleOrAbsent)
1225 }
1226 Err(source) => {
1227 return Err(BootstrapError::ConnectionFileRead {
1228 path: path.to_path_buf(),
1229 source,
1230 })
1231 }
1232 };
1233
1234 for endpoint in &info.endpoints {
1235 if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
1236 return Ok(Probe::Live);
1237 }
1238 }
1239
1240 Ok(Probe::StaleOrAbsent)
1241}
1242
1243async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
1244 let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
1245 return Probe::StaleOrAbsent;
1246 };
1247 if !ip.is_loopback() {
1248 return Probe::StaleOrAbsent;
1249 }
1250 let addr = SocketAddr::new(ip, endpoint.port);
1251
1252 let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
1253 Ok(Ok(stream)) => stream,
1254 Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
1255 };
1256
1257 match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
1258 Ok(()) => Probe::Live,
1259 Err(AuthError::DaemonIdMismatch)
1260 | Err(AuthError::InvalidServerProof)
1261 | Err(AuthError::UnexpectedEof { .. })
1262 | Err(AuthError::Timeout { .. })
1263 | Err(AuthError::JsonEncode { .. })
1264 | Err(AuthError::JsonDecode { .. })
1265 | Err(AuthError::Io { .. })
1266 | Err(AuthError::MessageTooLarge { .. })
1267 | Err(AuthError::KeyTooShort { .. })
1268 | Err(AuthError::Random(_))
1269 | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
1270 }
1271}
1272
1273async fn verify_serving(info: &ConnectionInfo) -> Result<(), BootstrapError> {
1274 if let Some(endpoint) = info.endpoints.first() {
1278 if matches!(probe_endpoint(info, endpoint).await, Probe::Live) {
1279 return Ok(());
1280 }
1281 }
1282 Err(BootstrapError::StartupNotServing)
1283}
1284
1285async fn run_blocking_holding<G, T>(
1300 guards: G,
1301 work: impl FnOnce() -> T + Send + 'static,
1302) -> Result<(T, G), JoinError>
1303where
1304 G: Send + 'static,
1305 T: Send + 'static,
1306{
1307 tokio::task::spawn_blocking(move || {
1308 let output = work();
1309 (output, guards)
1310 })
1311 .await
1312}
1313
1314fn describe_blocking_task_failure(work: &str, join_error: &JoinError) -> String {
1316 if join_error.is_panic() {
1317 format!("the background thread {work} panicked before finishing: {join_error}")
1318 } else {
1319 format!(
1320 "the background thread {work} was cancelled before finishing, because the async runtime is shutting down: {join_error}"
1321 )
1322 }
1323}
1324
1325async fn publish_connection_file<G: Send + 'static>(
1335 path: &Path,
1336 info: &ConnectionInfo,
1337 guard: G,
1338) -> (Result<(), BootstrapError>, Option<G>) {
1339 let task_path = path.to_path_buf();
1340 let task_info = info.clone();
1341 let outcome =
1342 run_blocking_holding(guard, move || write_connection_file(&task_path, &task_info)).await;
1343 match outcome {
1344 Ok((Ok(()), guard)) => (Ok(()), Some(guard)),
1345 Ok((Err(source), guard)) => (
1346 Err(BootstrapError::ConnectionFileWrite {
1347 path: path.to_path_buf(),
1348 source,
1349 }),
1350 Some(guard),
1351 ),
1352 Err(join_error) => (
1353 Err(BootstrapError::ConnectionFileWrite {
1354 path: path.to_path_buf(),
1355 source: ConnectionFileError::Io {
1356 op: "write",
1357 path: path.to_path_buf(),
1358 source: io::Error::other(describe_blocking_task_failure(
1359 "writing the connection file",
1360 &join_error,
1361 )),
1362 },
1363 }),
1364 None,
1365 ),
1366 }
1367}
1368
1369fn write_connection_file(path: &Path, info: &ConnectionInfo) -> Result<(), ConnectionFileError> {
1372 #[cfg(test)]
1373 connection_file_write_hook::run(path);
1374 write_atomic(path, info)
1375}
1376
1377#[cfg(test)]
1380mod connection_file_write_hook {
1381 use std::{
1382 path::{Path, PathBuf},
1383 sync::{Mutex, PoisonError},
1384 };
1385
1386 type Hook = Box<dyn FnOnce() + Send>;
1387
1388 static HOOKS: Mutex<Vec<(PathBuf, Hook)>> = Mutex::new(Vec::new());
1391
1392 pub(super) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) {
1394 HOOKS
1395 .lock()
1396 .unwrap_or_else(PoisonError::into_inner)
1397 .push((path.to_path_buf(), Box::new(hook)));
1398 }
1399
1400 pub(super) fn run(path: &Path) {
1401 let hook = {
1402 let mut hooks = HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
1403 hooks
1404 .iter()
1405 .position(|(hooked, _)| hooked == path)
1406 .map(|index| hooks.swap_remove(index).1)
1407 };
1408 if let Some(hook) = hook {
1409 hook();
1410 }
1411 }
1412}
1413
1414#[cfg(test)]
1417mod startup_publication_hook {
1418 use std::{
1419 path::{Path, PathBuf},
1420 sync::{Mutex, PoisonError},
1421 };
1422 use tokio::sync::oneshot;
1423
1424 type Barrier = (oneshot::Sender<()>, oneshot::Receiver<()>);
1425 static BARRIERS: Mutex<Vec<(PathBuf, Barrier)>> = Mutex::new(Vec::new());
1426
1427 pub(super) fn install(path: &Path) -> (oneshot::Receiver<()>, oneshot::Sender<()>) {
1428 let (entered_tx, entered_rx) = oneshot::channel();
1429 let (release_tx, release_rx) = oneshot::channel();
1430 BARRIERS
1431 .lock()
1432 .unwrap_or_else(PoisonError::into_inner)
1433 .push((path.to_path_buf(), (entered_tx, release_rx)));
1434 (entered_rx, release_tx)
1435 }
1436
1437 pub(super) async fn run(path: &Path) {
1438 let barrier = {
1439 let mut barriers = BARRIERS.lock().unwrap_or_else(PoisonError::into_inner);
1440 barriers
1441 .iter()
1442 .position(|(hooked, _)| hooked == path)
1443 .map(|index| barriers.swap_remove(index).1)
1444 };
1445 if let Some((entered, release)) = barrier {
1446 let _ = entered.send(());
1447 let _ = release.await;
1448 }
1449 }
1450}
1451
1452fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
1453 match err {
1454 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
1455 ConnectionFileError::JsonRead { .. }
1456 | ConnectionFileError::UnsupportedSchema { .. }
1457 | ConnectionFileError::Invalid { .. }
1458 | ConnectionFileError::KeyTooShort { .. }
1459 | ConnectionFileError::InsecurePermissions { .. } => true,
1463 ConnectionFileError::MissingParent { .. }
1464 | ConnectionFileError::MissingFileName { .. }
1465 | ConnectionFileError::InsecureParentDirectory { .. }
1469 | ConnectionFileError::Io { .. }
1470 | ConnectionFileError::JsonWrite { .. }
1471 | ConnectionFileError::Random(_)
1472 | ConnectionFileError::WireVersionMismatch { .. } => false,
1475 }
1476}
1477
1478fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
1479 match fs::remove_file(path) {
1480 Ok(()) => Ok(()),
1481 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1482 Err(source) => Err(BootstrapError::RemoveStale {
1483 path: path.to_path_buf(),
1484 source,
1485 }),
1486 }
1487}
1488
1489async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
1490 bind_loopback_with(port, |port| TcpListener::bind((Ipv6Addr::LOCALHOST, port))).await
1491}
1492
1493async fn bind_loopback_with<F, Fut>(
1494 port: u16,
1495 mut bind_v6: F,
1496) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError>
1497where
1498 F: FnMut(u16) -> Fut,
1499 Fut: std::future::Future<Output = io::Result<TcpListener>>,
1500{
1501 let v4_host = Ipv4Addr::LOCALHOST;
1502 let v4 = TcpListener::bind((v4_host, port))
1503 .await
1504 .map_err(|source| BootstrapError::Bind {
1505 host: v4_host.to_string(),
1506 port,
1507 source,
1508 })?;
1509 let actual_port = v4
1510 .local_addr()
1511 .map_err(|source| BootstrapError::LocalAddr {
1512 host: v4_host.to_string(),
1513 source,
1514 })?
1515 .port();
1516
1517 let mut listeners = vec![v4];
1518 let mut endpoints = vec![Endpoint {
1519 host: v4_host.to_string(),
1520 port: actual_port,
1521 }];
1522
1523 let v6_host = Ipv6Addr::LOCALHOST;
1524 match bind_v6(actual_port).await {
1525 Ok(v6) => {
1526 listeners.push(v6);
1527 endpoints.push(Endpoint {
1528 host: v6_host.to_string(),
1529 port: actual_port,
1530 });
1531 }
1532 Err(err)
1533 if ipv6_loopback_unavailable(&err)
1534 || (port == 0 && err.kind() == io::ErrorKind::AddrInUse) =>
1535 {
1536 warn!(
1537 port = actual_port,
1538 error = %err,
1539 "IPv6 loopback unavailable; serving only IPv4 loopback"
1540 );
1541 }
1542 Err(source) => {
1543 drop(listeners);
1544 return Err(BootstrapError::Bind {
1545 host: v6_host.to_string(),
1546 port: actual_port,
1547 source,
1548 });
1549 }
1550 }
1551
1552 Ok((listeners, endpoints))
1553}
1554
1555fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
1556 matches!(
1557 err.kind(),
1558 io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1559 ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1560}
1561
1562struct AbortOnDrop<T> {
1563 handle: JoinHandle<T>,
1564}
1565
1566impl<T> AbortOnDrop<T> {
1567 fn new(handle: JoinHandle<T>) -> Self {
1568 Self { handle }
1569 }
1570
1571 async fn join(&mut self) -> Result<T, JoinError> {
1572 (&mut self.handle).await
1573 }
1574}
1575
1576impl<T> Drop for AbortOnDrop<T> {
1577 fn drop(&mut self) {
1578 if !self.handle.is_finished() {
1579 self.handle.abort();
1580 }
1581 }
1582}
1583
1584#[derive(Debug)]
1585struct StartLock {
1586 _file: fs::File,
1589}
1590
1591impl StartLock {
1592 async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1593 let path = start_lock_path(connection_file_path);
1594 for _ in 0..START_LOCK_RETRIES {
1595 let file = match open_owner_only_lock(&path) {
1596 Ok(file) => file,
1597 Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1598 };
1599 match FileExt::try_lock(&file) {
1600 Ok(()) => return Ok(Self { _file: file }),
1601 Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1602 Err(TryLockError::Error(source)) => {
1603 return Err(BootstrapError::StartLockCreate { path, source });
1604 }
1605 }
1606 }
1607
1608 Err(BootstrapError::StartLockBusy {
1609 path,
1610 attempts: START_LOCK_RETRIES,
1611 })
1612 }
1613}
1614
1615pub(crate) fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1616 let mut options = fs::OpenOptions::new();
1617 options.read(true).write(true).create(true);
1618 #[cfg(unix)]
1619 {
1620 use std::os::unix::fs::OpenOptionsExt;
1621 options.mode(0o600);
1622 }
1623 options.open(path)
1624}
1625
1626fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1627 let file_name = connection_file_path
1628 .file_name()
1629 .map(|name| name.to_string_lossy())
1630 .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1631 let lock_name = format!("{file_name}.start-lock");
1632 connection_file_path
1633 .parent()
1634 .filter(|parent| !parent.as_os_str().is_empty())
1635 .unwrap_or_else(|| Path::new("."))
1636 .join(lock_name)
1637}
1638
1639fn non_empty_os_var(key: &str) -> Option<OsString> {
1640 let value = env::var_os(key)?;
1641 if value.is_empty() {
1642 None
1643 } else {
1644 Some(value)
1645 }
1646}
1647
1648#[derive(Debug)]
1651pub enum BootstrapError {
1652 StartupNotServing,
1654 #[cfg(unix)]
1655 Signal(io::Error),
1656 InvalidPort {
1657 raw: String,
1658 source: std::num::ParseIntError,
1659 },
1660 ConnectionFileRead {
1661 path: PathBuf,
1662 source: ConnectionFileError,
1663 },
1664 ConnectionFileWrite {
1665 path: PathBuf,
1666 source: ConnectionFileError,
1667 },
1668 GenerateConnectionFile(ConnectionFileError),
1669 StartLockCreate {
1670 path: PathBuf,
1671 source: io::Error,
1672 },
1673 StartLockBusy {
1674 path: PathBuf,
1675 attempts: usize,
1676 },
1677 RunDirLockCreate {
1679 path: PathBuf,
1680 source: io::Error,
1681 },
1682 RunDirBusy {
1687 path: PathBuf,
1688 holder_pid: Option<u32>,
1689 },
1690 RemoveStale {
1691 path: PathBuf,
1692 source: io::Error,
1693 },
1694 Bind {
1695 host: String,
1696 port: u16,
1697 source: io::Error,
1698 },
1699 LocalAddr {
1700 host: String,
1701 source: io::Error,
1702 },
1703 DaemonConfig(DaemonConfigError),
1704 MachineId(crate::machine_id::MachineIdFileError),
1707 RunDir(daemon_config::DaemonRunDirError),
1710 Serve(ServerError),
1711 ServeJoin(tokio::task::JoinError),
1712}
1713
1714impl fmt::Display for BootstrapError {
1715 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1716 match self {
1717 Self::StartupNotServing => write!(f, "bound daemon did not authenticate its readiness probe; connection file was not published"),
1718 #[cfg(unix)]
1719 Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1720 Self::InvalidPort { raw, source } => {
1721 write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1722 }
1723 Self::ConnectionFileRead { path, source } => write!(
1724 f,
1725 "failed to read connection file {}: {source}",
1726 path.display()
1727 ),
1728 Self::ConnectionFileWrite { path, source } => write!(
1729 f,
1730 "failed to publish connection file {}: {source}",
1731 path.display()
1732 ),
1733 Self::GenerateConnectionFile(err) => {
1734 write!(f, "failed to generate connection-file auth material: {err}")
1735 }
1736 Self::StartLockCreate { path, source } => {
1737 write!(
1738 f,
1739 "failed to create start lock {}: {source}",
1740 path.display()
1741 )
1742 }
1743 Self::StartLockBusy { path, attempts } => write!(
1744 f,
1745 "start lock {} remained busy after {attempts} attempts",
1746 path.display()
1747 ),
1748 Self::RunDirLockCreate { path, source } => write!(
1749 f,
1750 "refusing to start: failed to lock run directory via {}: {source}",
1751 path.display()
1752 ),
1753 Self::RunDirBusy { path, holder_pid } => {
1754 write!(
1755 f,
1756 "refusing to start: run directory lock {} is held by another daemon",
1757 path.display()
1758 )?;
1759 if let Some(pid) = holder_pid {
1760 write!(f, " (pid {pid})")?;
1761 }
1762 write!(
1763 f,
1764 "; a live daemon owns this data home's run state (typically one started with a different XDG_RUNTIME_DIR)"
1765 )
1766 }
1767 Self::RemoveStale { path, source } => write!(
1768 f,
1769 "failed to remove stale connection file {}: {source}",
1770 path.display()
1771 ),
1772 Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1773 write!(
1774 f,
1775 "port {port} in use on loopback {host}: {source}; set the port in config"
1776 )
1777 }
1778 Self::Bind { host, port, source } => {
1779 write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1780 }
1781 Self::LocalAddr { host, source } => {
1782 write!(f, "failed to read local address for {host}: {source}")
1783 }
1784 Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1785 Self::MachineId(err) => write!(f, "refusing to start: {err}"),
1786 Self::RunDir(err) => write!(f, "refusing to start: {err}"),
1787 Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1788 Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1789 }
1790 }
1791}
1792
1793impl Error for BootstrapError {
1794 fn source(&self) -> Option<&(dyn Error + 'static)> {
1795 match self {
1796 Self::StartupNotServing => None,
1797 #[cfg(unix)]
1798 Self::Signal(source) => Some(source),
1799 Self::InvalidPort { source, .. } => Some(source),
1800 Self::ConnectionFileRead { source, .. }
1801 | Self::ConnectionFileWrite { source, .. }
1802 | Self::GenerateConnectionFile(source) => Some(source),
1803 Self::StartLockCreate { source, .. }
1804 | Self::RunDirLockCreate { source, .. }
1805 | Self::RemoveStale { source, .. }
1806 | Self::Bind { source, .. }
1807 | Self::LocalAddr { source, .. } => Some(source),
1808 Self::DaemonConfig(err) => Some(err),
1809 Self::MachineId(err) => Some(err),
1810 Self::RunDir(err) => Some(err),
1811 Self::Serve(err) => Some(err),
1812 Self::ServeJoin(err) => Some(err),
1813 Self::StartLockBusy { .. } | Self::RunDirBusy { .. } => None,
1814 }
1815 }
1816}
1817
1818#[cfg(test)]
1819mod tests {
1820 mod activity {
1821 include!("activity_bootstrap_tests.rs");
1822 }
1823 mod startup_order {
1824 include!("startup_order_tests.rs");
1825 }
1826 use super::*;
1827 use crate::server::ServerAuth;
1828 use cortexkit_test_support::ScratchDir;
1829 #[cfg(target_os = "linux")]
1830 use std::collections::BTreeSet;
1831 use std::sync::Mutex;
1832 #[cfg(target_os = "linux")]
1833 use subc_control::ModuleProtocol;
1834 use subc_transport::MIN_KEY_LEN;
1835 use tokio::io::AsyncReadExt;
1836 use tokio::task::JoinHandle;
1837
1838 #[cfg(unix)]
1839 use std::os::unix::fs::PermissionsExt;
1840
1841 static ENV_LOCK: Mutex<()> = Mutex::new(());
1842
1843 #[test]
1844 fn normalized_build_provenance_preserves_real_values() {
1845 assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1846 }
1847
1848 #[test]
1849 fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1850 assert_eq!(normalized_build_provenance("unavailable"), None);
1851 assert_eq!(normalized_build_provenance(""), None);
1852 }
1853
1854 #[cfg(target_os = "linux")]
1855 fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1856 let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1857 let relative = cgroups
1858 .lines()
1859 .find_map(|line| line.strip_prefix("0::"))
1860 .ok_or_else(|| {
1861 io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1862 })?;
1863 Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1864 }
1865
1866 #[cfg(target_os = "linux")]
1867 fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1868 let modules = current_cgroup_path_for_test()?.join("subc-modules");
1869 let entries = match fs::read_dir(modules) {
1870 Ok(entries) => entries,
1871 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1872 Err(error) => return Err(error),
1873 };
1874 let mut directories = BTreeSet::new();
1875 for entry in entries {
1876 let entry = entry?;
1877 if entry.file_type()?.is_dir() {
1878 directories.insert(entry.file_name());
1879 }
1880 }
1881 Ok(directories)
1882 }
1883
1884 #[cfg(target_os = "linux")]
1885 async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1886 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1887 while !path.exists() && tokio::time::Instant::now() < deadline {
1888 assert!(
1889 !task.is_finished(),
1890 "daemon exited before creating {}",
1891 path.display()
1892 );
1893 sleep(Duration::from_millis(10)).await;
1894 }
1895 assert!(path.exists(), "daemon did not create {}", path.display());
1896 }
1897
1898 #[cfg(target_os = "linux")]
1903 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1904 async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1905 let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1906 let module_id = format!("cgroup-isolation-probe-{}", process::id());
1907 let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1908 assert!(
1909 !before.contains(&OsString::from(&module_id)),
1910 "isolation probe cgroup already exists before this daemon starts"
1911 );
1912 let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1913 let module = ConfiguredModule {
1914 module_id,
1915 program: PathBuf::from("sh"),
1916 args: vec!["-c".to_string(), "sleep 30".to_string()],
1917 env: Vec::new(),
1918 log: None,
1919 enabled: true,
1920 reserved: false,
1921 reserved_prefixes: Vec::new(),
1922 protocol: ModuleProtocol::None,
1923 overlap: Default::default(),
1924 health: HealthConfig::default(),
1925 drain_timeout_ms: None,
1926 route_bind_relay_timeout_ms: None,
1927 restart: RestartPolicy::default(),
1928 };
1929 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1930 .with_configured_modules([module])
1931 .with_capture_logs_dir(temp.join("logs"))
1932 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1933 let task = tokio::spawn(run_with_config(config));
1934
1935 wait_for_path(&capture, &task).await;
1936 let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1937
1938 task.abort();
1939 assert!(task
1940 .await
1941 .expect_err("aborted daemon task must cancel")
1942 .is_cancelled());
1943 assert_eq!(
1944 before, after,
1945 "an in-process daemon must not create or reconcile ambient module cgroups"
1946 );
1947 }
1948
1949 #[cfg(target_os = "linux")]
1950 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1951 async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1952 let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1953 let cgroup_root = temp.join("cgroup");
1954 fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1955 fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1956 let modules = cgroup_root.join("subc-modules");
1957 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1958 .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1959 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1960 let task = tokio::spawn(run_with_config(config));
1961
1962 wait_for_path(&modules, &task).await;
1963
1964 task.abort();
1965 assert!(task
1966 .await
1967 .expect_err("aborted daemon task must cancel")
1968 .is_cancelled());
1969 assert!(
1970 fs::read_dir(&modules)
1971 .expect("read prepared modules directory")
1972 .next()
1973 .is_none(),
1974 "the delegation probe must clean up after itself"
1975 );
1976 }
1977
1978 struct EnvGuard {
1979 key: &'static str,
1980 previous: Option<OsString>,
1981 }
1982
1983 impl EnvGuard {
1984 fn set(key: &'static str, value: &Path) -> Self {
1985 let previous = env::var_os(key);
1986 env::set_var(key, value);
1987 Self { key, previous }
1988 }
1989
1990 fn set_str(key: &'static str, value: &str) -> Self {
1991 let previous = env::var_os(key);
1992 env::set_var(key, value);
1993 Self { key, previous }
1994 }
1995
1996 fn unset(key: &'static str) -> Self {
1997 let previous = env::var_os(key);
1998 env::remove_var(key);
1999 Self { key, previous }
2000 }
2001 }
2002
2003 impl Drop for EnvGuard {
2004 fn drop(&mut self) {
2005 match &self.previous {
2006 Some(value) => env::set_var(self.key, value),
2007 None => env::remove_var(self.key),
2008 }
2009 }
2010 }
2011
2012 fn unique_temp_dir(name: &str) -> ScratchDir {
2013 ScratchDir::new(name)
2014 }
2015
2016 fn temp_connection_file_path(name: &str) -> (ScratchDir, PathBuf) {
2017 let dir = unique_temp_dir(name);
2018 let path = dir.join("conn.json");
2019 (dir, path)
2020 }
2021
2022 fn auth_for(info: &ConnectionInfo) -> ServerAuth {
2023 ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
2024 }
2025
2026 fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
2027 let auth = auth_for(&bound.connection_info);
2028 tokio::spawn(serve_listeners(
2029 bound.listeners,
2030 Arc::new(Router::with_default_self_handler()),
2031 auth,
2032 ))
2033 }
2034
2035 fn expect_bound(outcome: Outcome) -> BoundDaemon {
2036 match outcome {
2037 Outcome::Bound(bound) => bound,
2038 Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
2039 }
2040 }
2041
2042 async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
2043 let endpoint = conn
2044 .endpoints
2045 .first()
2046 .expect("test connection file should have an endpoint");
2047 let ip: IpAddr = endpoint.host.parse().unwrap();
2048 TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
2049 }
2050
2051 fn make_connection_info(port: u16) -> ConnectionInfo {
2052 ConnectionInfo {
2053 schema: SCHEMA_VERSION,
2054 wire_version: Some(PROTOCOL_VERSION),
2055 endpoints: vec![Endpoint {
2056 host: "127.0.0.1".to_owned(),
2057 port,
2058 }],
2059 key: generate_key().unwrap(),
2060 daemon_id: generate_daemon_id().unwrap(),
2061 pid: process::id(),
2062 daemon_ver: "test-subc".to_owned(),
2063 }
2064 }
2065
2066 fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
2067 fs::write(path, contents).unwrap();
2068 #[cfg(unix)]
2069 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
2070 }
2071
2072 fn assert_owner_only_connection_file(path: &Path) {
2073 #[cfg(unix)]
2076 {
2077 let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
2078 assert_eq!(mode, 0o600);
2079 }
2080 #[cfg(not(unix))]
2081 let _ = path;
2082 }
2083
2084 #[test]
2085 fn connection_file_path_uses_xdg_runtime_dir_when_set() {
2086 let _env_lock = ENV_LOCK.lock().unwrap();
2087 let runtime_dir = unique_temp_dir("xdg-runtime");
2088 let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
2089
2090 assert_eq!(
2091 connection_file_path(),
2092 runtime_dir.join(CONNECTION_FILE_NAME)
2093 );
2094 }
2095
2096 #[test]
2097 fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
2098 let runtime_dir = OsString::from("/run/user/1000");
2099
2100 let (path, source) = connection_file_path_with_source(Some(runtime_dir));
2101
2102 assert_eq!(
2103 path,
2104 PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
2105 );
2106 assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
2107 }
2108
2109 #[test]
2110 fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
2111 let _env_lock = ENV_LOCK.lock().unwrap();
2112 let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
2113
2114 assert_eq!(
2115 connection_file_path(),
2116 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2117 );
2118 }
2119
2120 #[test]
2121 fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
2122 let (path, source) = connection_file_path_with_source(None);
2123
2124 assert_eq!(
2125 path,
2126 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2127 );
2128 assert_eq!(source, ConnectionFileSource::TempDirFallback);
2129 }
2130
2131 #[test]
2136 fn user_connection_token_is_stable_under_concurrent_callers() {
2137 let expected = user_connection_token();
2138 let workers: Vec<_> = (0..32)
2139 .map(|_| {
2140 std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
2141 })
2142 .collect();
2143 for worker in workers {
2144 for token in worker.join().expect("probe thread") {
2145 assert_eq!(token, expected, "token diverged under concurrent probes");
2146 }
2147 }
2148 }
2149
2150 #[test]
2151 fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
2152 let (path, source) = connection_file_path_with_source(Some(OsString::new()));
2153
2154 assert_eq!(
2155 path,
2156 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2157 );
2158 assert_eq!(source, ConnectionFileSource::TempDirFallback);
2159 }
2160
2161 #[test]
2165 fn daemon_binary_config_journals_and_captures_into_the_run_dir() {
2166 let _env_lock = ENV_LOCK.lock().unwrap();
2167 let root = unique_temp_dir("daemon-binary-config");
2168 let data_home = root.join("data");
2169 let _data = EnvGuard::set("XDG_DATA_HOME", &data_home);
2170 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2171 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2172
2173 let config = BootstrapConfig::from_env_for_daemon_binary().unwrap();
2174
2175 let run_dir = data_home.join("cortexkit").join("run");
2176 assert_eq!(
2177 config.terminal_journal_path,
2178 Some(run_dir.join("terminals.jsonl"))
2179 );
2180 assert_eq!(config.capture_logs_dir, Some(run_dir.join("logs")));
2181 assert_eq!(
2182 BootstrapConfig::new(root.join("connection.json"), 0).terminal_journal_path,
2183 None,
2184 "an in-process config must not journal anywhere unless asked to"
2185 );
2186 }
2187
2188 #[test]
2189 fn daemon_binary_config_refuses_a_relative_data_home() {
2190 let _env_lock = ENV_LOCK.lock().unwrap();
2191 let root = unique_temp_dir("daemon-binary-relative-data");
2192 let _data = EnvGuard::set_str("XDG_DATA_HOME", "relative-data-home");
2193 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2194 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2195
2196 let error = BootstrapConfig::from_env_for_daemon_binary()
2197 .expect_err("a relative data home must refuse the daemon binary's config");
2198 assert!(
2199 matches!(error, BootstrapError::RunDir(_)),
2200 "expected a run-directory refusal, got {error}"
2201 );
2202 assert!(error.to_string().contains("XDG_DATA_HOME"), "{error}");
2203 }
2204
2205 #[test]
2206 fn configured_port_uses_default_config_and_env_override() {
2207 let _env_lock = ENV_LOCK.lock().unwrap();
2208 let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
2209 let config_path = conn_path.with_file_name("subc.jsonc");
2210
2211 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2212 assert_eq!(
2213 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2214 .unwrap()
2215 .port,
2216 DEFAULT_SUBC_PORT
2217 );
2218
2219 fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
2220 assert_eq!(
2221 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2222 .unwrap()
2223 .port,
2224 8123
2225 );
2226
2227 let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
2228 assert_eq!(
2229 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2230 .unwrap()
2231 .port,
2232 9012
2233 );
2234 }
2235
2236 #[tokio::test]
2237 async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
2238 let (_dir, path) = temp_connection_file_path("already-running");
2239
2240 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2241 let server = start_server(bound);
2242
2243 let second = ensure_singleton(&path, 0).await.unwrap();
2244 assert!(matches!(second, Outcome::AlreadyRunning));
2245
2246 server.abort();
2247 let _ = server.await;
2248 }
2249
2250 #[tokio::test]
2251 async fn daemon_connection_file_publishes_protocol_wire_version() {
2252 let (_dir, path) = temp_connection_file_path("wire-version");
2253 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2254 assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
2255 assert_eq!(
2256 connection_file::read(&path).unwrap().wire_version,
2257 Some(PROTOCOL_VERSION)
2258 );
2259
2260 drop(bound.listeners);
2261 }
2262
2263 #[tokio::test]
2264 async fn stale_unbound_connection_file_is_reclaimed() {
2265 let (_dir, path) = temp_connection_file_path("stale-reclaim");
2266 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2267 let stale_port = stale.local_addr().unwrap().port();
2268 drop(stale);
2269 let stale_info = make_connection_info(stale_port);
2270 write_atomic(&path, &stale_info).unwrap();
2271
2272 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2273 assert_ne!(bound.connection_info.key, stale_info.key);
2274 drop(bound.listeners);
2275 }
2276
2277 #[cfg(unix)]
2278 #[tokio::test]
2279 async fn ensure_singleton_reclaims_insecure_connection_file() {
2280 let (_dir, path) = temp_connection_file_path("insecure-reclaim");
2281 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2282 let stale_port = stale.local_addr().unwrap().port();
2283 drop(stale);
2284 let stale_info = make_connection_info(stale_port);
2285 write_atomic(&path, &stale_info).unwrap();
2286 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2287
2288 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2289 assert_ne!(bound.connection_info.key, stale_info.key);
2290 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2291 assert_owner_only_connection_file(&path);
2292
2293 drop(bound.listeners);
2294 }
2295
2296 #[tokio::test]
2297 async fn ensure_singleton_reclaims_non_loopback_connection_file() {
2298 let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
2299 let mut stale_info = make_connection_info(8757);
2300 stale_info.endpoints = vec![Endpoint {
2301 host: "192.0.2.10".to_owned(),
2302 port: 8757,
2303 }];
2304 write_atomic(&path, &stale_info).unwrap();
2305
2306 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2307 assert_ne!(bound.connection_info.key, stale_info.key);
2308 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2309 assert!(bound
2310 .connection_info
2311 .endpoints
2312 .iter()
2313 .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
2314 assert_owner_only_connection_file(&path);
2315
2316 drop(bound.listeners);
2317 }
2318
2319 #[tokio::test]
2320 async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
2321 let mut unsupported_schema = make_connection_info(8757);
2322 unsupported_schema.schema = SCHEMA_VERSION + 1;
2323
2324 let mut empty_endpoints = make_connection_info(8757);
2325 empty_endpoints.endpoints.clear();
2326
2327 let mut short_key = make_connection_info(8757);
2328 short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
2329
2330 let cases = vec![
2331 (
2332 "unsupported-schema",
2333 serde_json::to_vec(&unsupported_schema).unwrap(),
2334 Some(unsupported_schema),
2335 ),
2336 (
2337 "empty-endpoints",
2338 serde_json::to_vec(&empty_endpoints).unwrap(),
2339 Some(empty_endpoints),
2340 ),
2341 (
2342 "short-key",
2343 serde_json::to_vec(&short_key).unwrap(),
2344 Some(short_key),
2345 ),
2346 ("invalid-json", b"{not valid connection json".to_vec(), None),
2347 ];
2348
2349 for (label, contents, old_info) in cases {
2350 let (_dir, path) = temp_connection_file_path(label);
2351 write_raw_owner_only_connection_file(&path, &contents);
2352
2353 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2354 if let Some(old_info) = old_info {
2355 assert_ne!(bound.connection_info.key, old_info.key, "{label}");
2356 assert_ne!(
2357 bound.connection_info.daemon_id, old_info.daemon_id,
2358 "{label}"
2359 );
2360 }
2361 assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
2362 assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
2363 assert_owner_only_connection_file(&path);
2364
2365 drop(bound.listeners);
2366 }
2367 }
2368
2369 #[tokio::test]
2370 async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
2371 let (_dir, path) = temp_connection_file_path("foreign-reclaim");
2372 let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2373 let foreign_port = foreign.local_addr().unwrap().port();
2374 write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
2375 let foreign_task = tokio::spawn(async move {
2376 if let Ok((mut stream, _)) = foreign.accept().await {
2377 let mut buf = [0u8; 64];
2378 let _ = stream.read(&mut buf).await;
2379 }
2380 });
2381
2382 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2383 assert!(bound
2384 .connection_info
2385 .endpoints
2386 .iter()
2387 .all(|endpoint| endpoint.port != foreign_port));
2388
2389 drop(bound.listeners);
2390 let _ = foreign_task.await;
2391 }
2392
2393 #[tokio::test]
2394 async fn stale_start_lock_file_is_reclaimable() {
2395 let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
2396 let lock_path = start_lock_path(&path);
2397 drop(open_owner_only_lock(&lock_path).unwrap());
2398 assert!(lock_path.is_file());
2399
2400 let lock = StartLock::acquire(&path).await.unwrap();
2401 assert!(lock_path.is_file());
2402
2403 drop(lock);
2404 assert!(lock_path.is_file());
2405 }
2406
2407 #[tokio::test]
2408 async fn held_start_lock_blocks_second_acquire_until_release() {
2409 let (_dir, path) = temp_connection_file_path("start-lock-held");
2410 let lock_path = start_lock_path(&path);
2411 let first = StartLock::acquire(&path).await.unwrap();
2412
2413 let err = match StartLock::acquire(&path).await {
2414 Ok(_) => panic!("second acquire while held must stay busy"),
2415 Err(err) => err,
2416 };
2417 assert!(matches!(
2418 err,
2419 BootstrapError::StartLockBusy {
2420 ref path,
2421 attempts: START_LOCK_RETRIES,
2422 } if path == &lock_path
2423 ));
2424
2425 drop(first);
2426
2427 let second = StartLock::acquire(&path)
2428 .await
2429 .expect("released advisory lock should be reclaimable");
2430 drop(second);
2431 }
2432
2433 #[tokio::test]
2434 async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
2435 let (_dir, path) = temp_connection_file_path("bind-conflict");
2436 let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2437 let occupied_port = occupied.local_addr().unwrap().port();
2438
2439 let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
2440 assert!(matches!(
2441 err,
2442 BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
2443 ));
2444 assert!(err.to_string().contains("set the port in config"));
2445
2446 drop(occupied);
2447 }
2448
2449 #[tokio::test]
2450 async fn ephemeral_ipv6_collision_still_serves_ipv4() {
2451 let (listeners, endpoints) = bind_loopback_with(0, |port| async move {
2452 let occupied = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await?;
2453 let result = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await;
2454 drop(occupied);
2455 result
2456 })
2457 .await
2458 .expect("an incidental IPv6 collision must not prevent an ephemeral daemon from starting");
2459 assert_eq!(endpoints.len(), 1);
2460 assert_eq!(endpoints[0].host, "127.0.0.1");
2461 TcpStream::connect((Ipv4Addr::LOCALHOST, endpoints[0].port))
2462 .await
2463 .unwrap();
2464 drop(listeners);
2465 }
2466
2467 #[tokio::test]
2468 async fn unpublished_boot_holds_singleton_lock_until_publication() {
2469 let dir = ScratchDir::new("unpublished-singleton-lock");
2470 let path = dir.join("connection.json");
2471 let bound = expect_bound(
2472 ensure_singleton_inner(BootstrapConfig::new(&path, 0), false)
2473 .await
2474 .unwrap(),
2475 );
2476 assert!(!path.exists());
2477 let contender = open_owner_only_lock(&start_lock_path(&path)).unwrap();
2478 assert!(
2479 matches!(FileExt::try_lock(&contender), Err(TryLockError::WouldBlock)),
2480 "an unpublished boot must keep its singleton claim"
2481 );
2482 drop(bound);
2483 FileExt::try_lock(&contender).unwrap();
2484 }
2485
2486 #[tokio::test]
2487 async fn readiness_probe_requires_an_authenticating_server() {
2488 let dir = ScratchDir::new("unserved-readiness");
2489 let bound = expect_bound(
2490 ensure_singleton_inner(BootstrapConfig::new(dir.join("connection.json"), 0), false)
2491 .await
2492 .unwrap(),
2493 );
2494 assert!(matches!(
2495 verify_serving(&bound.connection_info).await,
2496 Err(BootstrapError::StartupNotServing)
2497 ));
2498 }
2499
2500 #[cfg(unix)]
2501 #[tokio::test]
2502 async fn orphan_sweep_finishes_before_connection_file_publication() {
2503 use crate::live_children::{ExecutableIdentity, LiveChild};
2504 let dir = ScratchDir::new("publish-after-sweep");
2505 let path = dir.join("connection.json");
2506 let record = dir.join("live-children.json");
2507 let ready = dir.join("ready");
2508 let mut child = std::process::Command::new("/bin/sh")
2509 .args([
2510 "-c",
2511 "trap '' TERM; : > \"$0\"; read -r _",
2512 ready.to_str().unwrap(),
2513 ])
2514 .stdin(std::process::Stdio::piped())
2515 .env("XDG_DATA_HOME", dir.path())
2516 .env("XDG_RUNTIME_DIR", dir.path())
2517 .env("XDG_CONFIG_HOME", dir.path())
2518 .spawn()
2519 .unwrap();
2520 timeout(Duration::from_secs(5), async {
2521 while !ready.exists() {
2522 sleep(Duration::from_millis(5)).await;
2523 }
2524 })
2525 .await
2526 .unwrap();
2527 let observed = subc_os::Process::open(child.id())
2528 .unwrap()
2529 .unwrap()
2530 .observe()
2531 .unwrap();
2532 crate::live_children::write_record(
2533 &record,
2534 &[LiveChild {
2535 module_id: "old-sleep".into(),
2536 pid: child.id(),
2537 protocol: subc_control::ModuleProtocol::None,
2538 start_time: Some(observed.start_time),
2539 executable: observed.executable.map(ExecutableIdentity::from),
2540 cgroup_name: None,
2541 }],
2542 )
2543 .unwrap();
2544 let config = BootstrapConfig::new(&path, 0).with_live_children_record(&record);
2545 let daemon = tokio::spawn(run_with_config(config));
2546 timeout(Duration::from_secs(5), async {
2547 while !path.exists() {
2548 sleep(Duration::from_millis(5)).await;
2549 }
2550 })
2551 .await
2552 .unwrap();
2553 assert!(path.exists());
2554 let ended = child.try_wait().unwrap().is_some();
2555 if !ended {
2556 child.kill().unwrap();
2557 }
2558 child.wait().unwrap();
2559 if ended {
2560 assert!(matches!(probe_existing(&path).await.unwrap(), Probe::Live));
2561 }
2562 daemon.abort();
2563 let _ = daemon.await;
2564 assert!(
2565 ended,
2566 "the published daemon must not still owe its orphan sweep"
2567 );
2568 }
2569
2570 #[tokio::test(flavor = "current_thread")]
2577 async fn connection_file_publication_leaves_the_async_runtime_free() {
2578 use std::sync::atomic::{AtomicBool, Ordering};
2579
2580 let (_dir, path) = temp_connection_file_path("publish-off-runtime");
2581 let write_started = Arc::new(AtomicBool::new(false));
2582 let (progress_tx, progress_rx) = std::sync::mpsc::channel::<()>();
2583 let (verdict_tx, verdict_rx) = std::sync::mpsc::channel::<bool>();
2584 let hook_started = Arc::clone(&write_started);
2585 connection_file_write_hook::install(&path, move || {
2586 hook_started.store(true, Ordering::SeqCst);
2587 let progressed = progress_rx.recv_timeout(Duration::from_secs(5)).is_ok();
2588 let _ = verdict_tx.send(progressed);
2589 });
2590 let ticker = tokio::spawn(async move {
2593 loop {
2594 if write_started.load(Ordering::SeqCst) {
2595 let _ = progress_tx.send(());
2596 return;
2597 }
2598 sleep(Duration::from_millis(5)).await;
2599 }
2600 });
2601
2602 let daemon = tokio::spawn(run_with_config(BootstrapConfig::new(&path, 0)));
2603 let published = timeout(Duration::from_secs(15), async {
2604 while !path.exists() && !daemon.is_finished() {
2605 sleep(Duration::from_millis(5)).await;
2606 }
2607 })
2608 .await;
2609 let daemon_exited = daemon.is_finished();
2610 daemon.abort();
2611 let daemon_result = daemon.await;
2612 ticker.abort();
2613
2614 assert!(
2615 published.is_ok() && !daemon_exited,
2616 "the daemon must start and publish its connection file: {daemon_result:?}"
2617 );
2618 match verdict_rx.try_recv() {
2619 Ok(true) => {}
2620 Ok(false) => panic!(
2621 "no other task on the runtime ran while the connection file was being written: the write is blocking the async worker thread"
2622 ),
2623 Err(_) => panic!("startup published the connection file without the hooked write"),
2624 }
2625 }
2626
2627 #[tokio::test]
2630 async fn a_panicking_connection_file_write_fails_startup() {
2631 let (_dir, path) = temp_connection_file_path("publish-panics");
2632 connection_file_write_hook::install(&path, || {
2633 panic!("deliberate panic inside the connection-file write")
2634 });
2635
2636 let error = timeout(
2637 Duration::from_secs(15),
2638 run_with_config(BootstrapConfig::new(&path, 0)),
2639 )
2640 .await
2641 .expect("startup must end once the write task fails")
2642 .expect_err("startup must fail when the connection-file write panics");
2643
2644 assert!(
2645 matches!(&error, BootstrapError::ConnectionFileWrite { path: failed, .. } if failed == &path),
2646 "expected a connection-file write error, got {error:?}"
2647 );
2648 let message = error.to_string();
2649 assert!(message.contains(&path.display().to_string()), "{message}");
2650 assert!(message.contains("panicked"), "{message}");
2651 assert!(!path.exists(), "no connection file may be published");
2652 }
2653
2654 #[cfg(target_os = "macos")]
2655 #[test]
2656 fn nofile_raise_worker() {
2657 let Some(path) = std::env::var_os("SUBC_NOFILE_TEST_FILE") else {
2658 return;
2659 };
2660 let (_, hard) = rlimit::Resource::NOFILE.get().unwrap();
2661 rlimit::Resource::NOFILE.set(256, hard).unwrap();
2662 raise_nofile_limit_with_ceiling(Some(10240));
2663 std::fs::write(path, rlimit::Resource::NOFILE.get().unwrap().0.to_string()).unwrap();
2664 }
2665
2666 #[cfg(target_os = "macos")]
2667 #[test]
2668 fn macos_nofile_raise_clamps_to_kernel_ceiling() {
2669 let dir = ScratchDir::new("nofile-ceiling");
2670 let result = dir.join("soft");
2671 let status = std::process::Command::new(std::env::current_exe().unwrap())
2672 .args(["--exact", "bootstrap::tests::nofile_raise_worker"])
2673 .env("SUBC_NOFILE_TEST_FILE", &result)
2674 .env("XDG_DATA_HOME", dir.path())
2675 .env("XDG_RUNTIME_DIR", dir.path())
2676 .env("XDG_CONFIG_HOME", dir.path())
2677 .status()
2678 .unwrap();
2679 assert!(status.success());
2680 let soft: u64 = std::fs::read_to_string(result).unwrap().parse().unwrap();
2681 assert!(
2682 soft > 256 && soft <= 10240,
2683 "the inherited limit must be raised within the kernel ceiling; got {soft}"
2684 );
2685 }
2686
2687 #[tokio::test]
2688 async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
2689 let (_dir, path) = temp_connection_file_path("key-rotation");
2690 let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2691 let old_info = first.connection_info.clone();
2692 let fixed_port = old_info.endpoints[0].port;
2693 drop(first.listeners);
2694
2695 let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
2696 let new_info = second.connection_info.clone();
2697 assert_ne!(old_info.key, new_info.key);
2698 assert_ne!(old_info.daemon_id, new_info.daemon_id);
2699 let server = start_server(second);
2700
2701 let mut old_stream = connect_from_info(&old_info).await.unwrap();
2702 let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
2703 assert!(
2704 old_auth.is_err(),
2705 "old key must not authenticate after restart"
2706 );
2707
2708 let reread = connection_file::read(&path).unwrap();
2709 let mut new_stream = connect_from_info(&reread).await.unwrap();
2710 authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
2711 .await
2712 .unwrap();
2713
2714 server.abort();
2715 let _ = server.await;
2716 }
2717
2718 #[cfg(unix)]
2719 #[tokio::test]
2720 async fn published_connection_file_permissions_are_owner_only() {
2721 let (_dir, path) = temp_connection_file_path("permissions");
2722 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2723
2724 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2725 assert_eq!(mode, 0o600);
2726
2727 drop(bound.listeners);
2728 }
2729}