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 serve_task =
945 AbortOnDrop::new(tokio::spawn(serve_listeners(bound.listeners, router, auth)));
946 verify_serving(&bound.connection_info).await?;
947 let (published, publication_lock) = publish_connection_file(
954 &bound.connection_file_path,
955 &bound.connection_info,
956 publication_lock,
957 )
958 .await;
959 published?;
960 drop(publication_lock);
961 let _clock_step_task = AbortOnDrop::new(crate::watchdog::spawn_clock_step_monitor());
962 #[cfg(target_os = "linux")]
964 let _kill_mode_check = AbortOnDrop::new(tokio::spawn(
965 crate::systemd_kill_mode::warn_if_kill_mode_defeats_ordered_shutdown(),
966 ));
967 let _watchdog_task = AbortOnDrop::new(
968 DaemonSelfWatchdog::new(
969 bound.connection_info.clone(),
970 bound.connection_file_path.clone(),
971 )
972 .with_config(watchdog_config)
973 .spawn(),
974 );
975
976 for configured in configured_modules {
977 let enabled = configured.enabled;
978 let health = configured.health.clone();
979 let module_id = configured.module_id.clone();
980 match supervisor.supervise_configured_with_health(
981 configured.module_spec(),
982 enabled,
983 health.clone(),
984 configured.drain_timeout_ms,
985 configured.restart,
986 ) {
987 Ok(_) => {
988 let default_threshold = HealthConfig::default().failure_threshold;
997 if enabled && health.failure_threshold > default_threshold {
998 warn!(
999 module_id = %module_id,
1000 failure_threshold = health.failure_threshold,
1001 default_threshold,
1002 tolerance_secs = health.cadence.as_secs() * u64::from(health.failure_threshold),
1003 "health failure threshold is relaxed above the default; a wedged module stays unflagged for longer"
1004 );
1005 }
1006 info!(module_id = %module_id, enabled, "configured module supervised");
1007 }
1008 Err(err) => {
1009 error!(module_id = %module_id, error = %err, "failed to supervise configured module; continuing daemon startup");
1010 }
1011 }
1012 }
1013
1014 control.refresh_capability_requirements();
1015 Arc::clone(&control).spawn_capability_deadline_loop();
1016 Arc::clone(&control).spawn_scope_expiry_loop();
1017
1018 #[cfg(unix)]
1019 {
1020 tokio::select! {
1021 result = serve_task.join() => {
1022 return result.map_err(BootstrapError::ServeJoin)?.map_err(BootstrapError::Serve);
1023 }
1024 _ = terminate.recv() => {}
1025 }
1026 supervisor.begin_daemon_shutdown();
1031 drop(_watchdog_task);
1035 drop(serve_task);
1038 let escalated = tokio::select! {
1039 biased;
1040 _ = terminate.recv() => {
1041 info!("second SIGTERM: abandoning daemon shutdown wait");
1042 true
1043 }
1044 result = supervisor.drain_for_daemon_shutdown() => {
1045 if let Err(error) = result {
1046 warn!(%error, "daemon shutdown drain failed; exiting anyway");
1047 }
1048 false
1049 }
1050 };
1051 supervisor
1058 .end_children_for_daemon_shutdown(escalated, async {
1059 terminate.recv().await;
1060 })
1061 .await;
1062 Ok(())
1063 }
1064 #[cfg(not(unix))]
1065 serve_task
1066 .join()
1067 .await
1068 .map_err(BootstrapError::ServeJoin)?
1069 .map_err(BootstrapError::Serve)
1070}
1071
1072fn normalized_build_provenance(value: &str) -> Option<String> {
1073 match value.trim() {
1074 "" | "unavailable" => None,
1075 value => Some(value.to_string()),
1076 }
1077}
1078
1079pub async fn ensure_singleton(
1087 connection_file_path: impl AsRef<Path>,
1088 port: u16,
1089) -> Result<Outcome, BootstrapError> {
1090 ensure_singleton_with_config(BootstrapConfig::new(connection_file_path.as_ref(), port)).await
1091}
1092
1093pub async fn ensure_singleton_with_config(
1094 config: BootstrapConfig,
1095) -> Result<Outcome, BootstrapError> {
1096 ensure_singleton_inner(config, true).await
1097}
1098
1099async fn ensure_singleton_inner(
1100 config: BootstrapConfig,
1101 publish: bool,
1102) -> Result<Outcome, BootstrapError> {
1103 let path = config.connection_file_path;
1104
1105 if matches!(probe_existing(&path).await?, Probe::Live) {
1106 return Ok(Outcome::AlreadyRunning);
1107 }
1108
1109 let lock = StartLock::acquire(&path).await?;
1110
1111 if matches!(probe_existing(&path).await?, Probe::Live) {
1115 return Ok(Outcome::AlreadyRunning);
1116 }
1117
1118 let run_dir_lock = config
1124 .live_children_path
1125 .as_deref()
1126 .map(crate::run_dir_lock::RunDirLock::acquire)
1127 .transpose()?;
1128
1129 remove_stale_connection_file_if_present(&path)?;
1130
1131 let (machine_id, lock, run_dir_lock) = match config.machine_id_path {
1139 None => (None, lock, run_dir_lock),
1140 Some(machine_id_path) => {
1141 let task_path = machine_id_path.clone();
1142 match run_blocking_holding((lock, run_dir_lock), move || {
1143 crate::machine_id::load_or_mint(&task_path)
1144 })
1145 .await
1146 {
1147 Ok((Ok(id), (lock, run_dir_lock))) => (Some(id), lock, run_dir_lock),
1148 Ok((Err(error), _locks)) => return Err(BootstrapError::MachineId(error)),
1149 Err(join_error) => {
1150 return Err(BootstrapError::MachineId(
1151 crate::machine_id::MachineIdFileError::Write {
1152 path: machine_id_path,
1153 source: io::Error::other(describe_blocking_task_failure(
1154 "loading or minting the machine id",
1155 &join_error,
1156 )),
1157 },
1158 ))
1159 }
1160 }
1161 }
1162 };
1163
1164 let (listeners, endpoints) = bind_loopback(config.port).await?;
1165 let connection_info = ConnectionInfo {
1166 schema: SCHEMA_VERSION,
1167 wire_version: Some(PROTOCOL_VERSION),
1168 endpoints,
1169 key: generate_key().map_err(BootstrapError::GenerateConnectionFile)?,
1170 daemon_id: generate_daemon_id().map_err(BootstrapError::GenerateConnectionFile)?,
1171 pid: process::id(),
1172 daemon_ver: config.daemon_ver,
1173 };
1174
1175 let publication_lock = if publish {
1179 let (published, lock) = publish_connection_file(&path, &connection_info, lock).await;
1180 if let Err(error) = published {
1181 drop(listeners);
1182 return Err(error);
1183 }
1184 drop(lock);
1185 None
1186 } else {
1187 Some(lock)
1188 };
1189
1190 Ok(Outcome::Bound(BoundDaemon {
1191 listeners,
1192 connection_info,
1193 connection_file_path: path,
1194 connection_file_source: config.connection_file_source,
1195 machine_id,
1196 run_dir_lock,
1197 publication_lock,
1198 }))
1199}
1200
1201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1202enum Probe {
1203 Live,
1204 StaleOrAbsent,
1205}
1206
1207async fn probe_existing(path: &Path) -> Result<Probe, BootstrapError> {
1208 let info = match connection_file::read(path) {
1209 Ok(info) => info,
1210 Err(source) if is_absent_or_stale_connection_file(&source) => {
1211 return Ok(Probe::StaleOrAbsent)
1212 }
1213 Err(source) => {
1214 return Err(BootstrapError::ConnectionFileRead {
1215 path: path.to_path_buf(),
1216 source,
1217 })
1218 }
1219 };
1220
1221 for endpoint in &info.endpoints {
1222 if matches!(probe_endpoint(&info, endpoint).await, Probe::Live) {
1223 return Ok(Probe::Live);
1224 }
1225 }
1226
1227 Ok(Probe::StaleOrAbsent)
1228}
1229
1230async fn probe_endpoint(info: &ConnectionInfo, endpoint: &Endpoint) -> Probe {
1231 let Ok(ip) = endpoint.host.parse::<IpAddr>() else {
1232 return Probe::StaleOrAbsent;
1233 };
1234 if !ip.is_loopback() {
1235 return Probe::StaleOrAbsent;
1236 }
1237 let addr = SocketAddr::new(ip, endpoint.port);
1238
1239 let mut stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
1240 Ok(Ok(stream)) => stream,
1241 Ok(Err(_)) | Err(_) => return Probe::StaleOrAbsent,
1242 };
1243
1244 match authenticate_client(&mut stream, info, PROBE_AUTH_DEADLINE).await {
1245 Ok(()) => Probe::Live,
1246 Err(AuthError::DaemonIdMismatch)
1247 | Err(AuthError::InvalidServerProof)
1248 | Err(AuthError::UnexpectedEof { .. })
1249 | Err(AuthError::Timeout { .. })
1250 | Err(AuthError::JsonEncode { .. })
1251 | Err(AuthError::JsonDecode { .. })
1252 | Err(AuthError::Io { .. })
1253 | Err(AuthError::MessageTooLarge { .. })
1254 | Err(AuthError::KeyTooShort { .. })
1255 | Err(AuthError::Random(_))
1256 | Err(AuthError::InvalidClientAuth) => Probe::StaleOrAbsent,
1257 }
1258}
1259
1260async fn verify_serving(info: &ConnectionInfo) -> Result<(), BootstrapError> {
1261 if let Some(endpoint) = info.endpoints.first() {
1265 if matches!(probe_endpoint(info, endpoint).await, Probe::Live) {
1266 return Ok(());
1267 }
1268 }
1269 Err(BootstrapError::StartupNotServing)
1270}
1271
1272async fn run_blocking_holding<G, T>(
1287 guards: G,
1288 work: impl FnOnce() -> T + Send + 'static,
1289) -> Result<(T, G), JoinError>
1290where
1291 G: Send + 'static,
1292 T: Send + 'static,
1293{
1294 tokio::task::spawn_blocking(move || {
1295 let output = work();
1296 (output, guards)
1297 })
1298 .await
1299}
1300
1301fn describe_blocking_task_failure(work: &str, join_error: &JoinError) -> String {
1303 if join_error.is_panic() {
1304 format!("the background thread {work} panicked before finishing: {join_error}")
1305 } else {
1306 format!(
1307 "the background thread {work} was cancelled before finishing, because the async runtime is shutting down: {join_error}"
1308 )
1309 }
1310}
1311
1312async fn publish_connection_file<G: Send + 'static>(
1322 path: &Path,
1323 info: &ConnectionInfo,
1324 guard: G,
1325) -> (Result<(), BootstrapError>, Option<G>) {
1326 let task_path = path.to_path_buf();
1327 let task_info = info.clone();
1328 let outcome =
1329 run_blocking_holding(guard, move || write_connection_file(&task_path, &task_info)).await;
1330 match outcome {
1331 Ok((Ok(()), guard)) => (Ok(()), Some(guard)),
1332 Ok((Err(source), guard)) => (
1333 Err(BootstrapError::ConnectionFileWrite {
1334 path: path.to_path_buf(),
1335 source,
1336 }),
1337 Some(guard),
1338 ),
1339 Err(join_error) => (
1340 Err(BootstrapError::ConnectionFileWrite {
1341 path: path.to_path_buf(),
1342 source: ConnectionFileError::Io {
1343 op: "write",
1344 path: path.to_path_buf(),
1345 source: io::Error::other(describe_blocking_task_failure(
1346 "writing the connection file",
1347 &join_error,
1348 )),
1349 },
1350 }),
1351 None,
1352 ),
1353 }
1354}
1355
1356fn write_connection_file(path: &Path, info: &ConnectionInfo) -> Result<(), ConnectionFileError> {
1359 #[cfg(test)]
1360 connection_file_write_hook::run(path);
1361 write_atomic(path, info)
1362}
1363
1364#[cfg(test)]
1367mod connection_file_write_hook {
1368 use std::{
1369 path::{Path, PathBuf},
1370 sync::{Mutex, PoisonError},
1371 };
1372
1373 type Hook = Box<dyn FnOnce() + Send>;
1374
1375 static HOOKS: Mutex<Vec<(PathBuf, Hook)>> = Mutex::new(Vec::new());
1378
1379 pub(super) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) {
1381 HOOKS
1382 .lock()
1383 .unwrap_or_else(PoisonError::into_inner)
1384 .push((path.to_path_buf(), Box::new(hook)));
1385 }
1386
1387 pub(super) fn run(path: &Path) {
1388 let hook = {
1389 let mut hooks = HOOKS.lock().unwrap_or_else(PoisonError::into_inner);
1390 hooks
1391 .iter()
1392 .position(|(hooked, _)| hooked == path)
1393 .map(|index| hooks.swap_remove(index).1)
1394 };
1395 if let Some(hook) = hook {
1396 hook();
1397 }
1398 }
1399}
1400
1401fn is_absent_or_stale_connection_file(err: &ConnectionFileError) -> bool {
1402 match err {
1403 ConnectionFileError::Io { source, .. } if source.kind() == io::ErrorKind::NotFound => true,
1404 ConnectionFileError::JsonRead { .. }
1405 | ConnectionFileError::UnsupportedSchema { .. }
1406 | ConnectionFileError::Invalid { .. }
1407 | ConnectionFileError::KeyTooShort { .. }
1408 | ConnectionFileError::InsecurePermissions { .. } => true,
1412 ConnectionFileError::MissingParent { .. }
1413 | ConnectionFileError::MissingFileName { .. }
1414 | ConnectionFileError::InsecureParentDirectory { .. }
1418 | ConnectionFileError::Io { .. }
1419 | ConnectionFileError::JsonWrite { .. }
1420 | ConnectionFileError::Random(_)
1421 | ConnectionFileError::WireVersionMismatch { .. } => false,
1424 }
1425}
1426
1427fn remove_stale_connection_file_if_present(path: &Path) -> Result<(), BootstrapError> {
1428 match fs::remove_file(path) {
1429 Ok(()) => Ok(()),
1430 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
1431 Err(source) => Err(BootstrapError::RemoveStale {
1432 path: path.to_path_buf(),
1433 source,
1434 }),
1435 }
1436}
1437
1438async fn bind_loopback(port: u16) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError> {
1439 bind_loopback_with(port, |port| TcpListener::bind((Ipv6Addr::LOCALHOST, port))).await
1440}
1441
1442async fn bind_loopback_with<F, Fut>(
1443 port: u16,
1444 mut bind_v6: F,
1445) -> Result<(Vec<TcpListener>, Vec<Endpoint>), BootstrapError>
1446where
1447 F: FnMut(u16) -> Fut,
1448 Fut: std::future::Future<Output = io::Result<TcpListener>>,
1449{
1450 let v4_host = Ipv4Addr::LOCALHOST;
1451 let v4 = TcpListener::bind((v4_host, port))
1452 .await
1453 .map_err(|source| BootstrapError::Bind {
1454 host: v4_host.to_string(),
1455 port,
1456 source,
1457 })?;
1458 let actual_port = v4
1459 .local_addr()
1460 .map_err(|source| BootstrapError::LocalAddr {
1461 host: v4_host.to_string(),
1462 source,
1463 })?
1464 .port();
1465
1466 let mut listeners = vec![v4];
1467 let mut endpoints = vec![Endpoint {
1468 host: v4_host.to_string(),
1469 port: actual_port,
1470 }];
1471
1472 let v6_host = Ipv6Addr::LOCALHOST;
1473 match bind_v6(actual_port).await {
1474 Ok(v6) => {
1475 listeners.push(v6);
1476 endpoints.push(Endpoint {
1477 host: v6_host.to_string(),
1478 port: actual_port,
1479 });
1480 }
1481 Err(err)
1482 if ipv6_loopback_unavailable(&err)
1483 || (port == 0 && err.kind() == io::ErrorKind::AddrInUse) =>
1484 {
1485 warn!(
1486 port = actual_port,
1487 error = %err,
1488 "IPv6 loopback unavailable; serving only IPv4 loopback"
1489 );
1490 }
1491 Err(source) => {
1492 drop(listeners);
1493 return Err(BootstrapError::Bind {
1494 host: v6_host.to_string(),
1495 port: actual_port,
1496 source,
1497 });
1498 }
1499 }
1500
1501 Ok((listeners, endpoints))
1502}
1503
1504fn ipv6_loopback_unavailable(err: &io::Error) -> bool {
1505 matches!(
1506 err.kind(),
1507 io::ErrorKind::AddrNotAvailable | io::ErrorKind::Unsupported
1508 ) || matches!(err.raw_os_error(), Some(47) | Some(49) | Some(97))
1509}
1510
1511struct AbortOnDrop<T> {
1512 handle: JoinHandle<T>,
1513}
1514
1515impl<T> AbortOnDrop<T> {
1516 fn new(handle: JoinHandle<T>) -> Self {
1517 Self { handle }
1518 }
1519
1520 async fn join(&mut self) -> Result<T, JoinError> {
1521 (&mut self.handle).await
1522 }
1523}
1524
1525impl<T> Drop for AbortOnDrop<T> {
1526 fn drop(&mut self) {
1527 if !self.handle.is_finished() {
1528 self.handle.abort();
1529 }
1530 }
1531}
1532
1533#[derive(Debug)]
1534struct StartLock {
1535 _file: fs::File,
1538}
1539
1540impl StartLock {
1541 async fn acquire(connection_file_path: &Path) -> Result<Self, BootstrapError> {
1542 let path = start_lock_path(connection_file_path);
1543 for _ in 0..START_LOCK_RETRIES {
1544 let file = match open_owner_only_lock(&path) {
1545 Ok(file) => file,
1546 Err(source) => return Err(BootstrapError::StartLockCreate { path, source }),
1547 };
1548 match FileExt::try_lock(&file) {
1549 Ok(()) => return Ok(Self { _file: file }),
1550 Err(TryLockError::WouldBlock) => sleep(START_LOCK_RETRY_DELAY).await,
1551 Err(TryLockError::Error(source)) => {
1552 return Err(BootstrapError::StartLockCreate { path, source });
1553 }
1554 }
1555 }
1556
1557 Err(BootstrapError::StartLockBusy {
1558 path,
1559 attempts: START_LOCK_RETRIES,
1560 })
1561 }
1562}
1563
1564pub(crate) fn open_owner_only_lock(path: &Path) -> io::Result<fs::File> {
1565 let mut options = fs::OpenOptions::new();
1566 options.read(true).write(true).create(true);
1567 #[cfg(unix)]
1568 {
1569 use std::os::unix::fs::OpenOptionsExt;
1570 options.mode(0o600);
1571 }
1572 options.open(path)
1573}
1574
1575fn start_lock_path(connection_file_path: &Path) -> PathBuf {
1576 let file_name = connection_file_path
1577 .file_name()
1578 .map(|name| name.to_string_lossy())
1579 .unwrap_or_else(|| CONNECTION_FILE_NAME.into());
1580 let lock_name = format!("{file_name}.start-lock");
1581 connection_file_path
1582 .parent()
1583 .filter(|parent| !parent.as_os_str().is_empty())
1584 .unwrap_or_else(|| Path::new("."))
1585 .join(lock_name)
1586}
1587
1588fn non_empty_os_var(key: &str) -> Option<OsString> {
1589 let value = env::var_os(key)?;
1590 if value.is_empty() {
1591 None
1592 } else {
1593 Some(value)
1594 }
1595}
1596
1597#[derive(Debug)]
1600pub enum BootstrapError {
1601 StartupNotServing,
1603 #[cfg(unix)]
1604 Signal(io::Error),
1605 InvalidPort {
1606 raw: String,
1607 source: std::num::ParseIntError,
1608 },
1609 ConnectionFileRead {
1610 path: PathBuf,
1611 source: ConnectionFileError,
1612 },
1613 ConnectionFileWrite {
1614 path: PathBuf,
1615 source: ConnectionFileError,
1616 },
1617 GenerateConnectionFile(ConnectionFileError),
1618 StartLockCreate {
1619 path: PathBuf,
1620 source: io::Error,
1621 },
1622 StartLockBusy {
1623 path: PathBuf,
1624 attempts: usize,
1625 },
1626 RunDirLockCreate {
1628 path: PathBuf,
1629 source: io::Error,
1630 },
1631 RunDirBusy {
1636 path: PathBuf,
1637 holder_pid: Option<u32>,
1638 },
1639 RemoveStale {
1640 path: PathBuf,
1641 source: io::Error,
1642 },
1643 Bind {
1644 host: String,
1645 port: u16,
1646 source: io::Error,
1647 },
1648 LocalAddr {
1649 host: String,
1650 source: io::Error,
1651 },
1652 DaemonConfig(DaemonConfigError),
1653 MachineId(crate::machine_id::MachineIdFileError),
1656 RunDir(daemon_config::DaemonRunDirError),
1659 Serve(ServerError),
1660 ServeJoin(tokio::task::JoinError),
1661}
1662
1663impl fmt::Display for BootstrapError {
1664 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1665 match self {
1666 Self::StartupNotServing => write!(f, "bound daemon did not authenticate its readiness probe; connection file was not published"),
1667 #[cfg(unix)]
1668 Self::Signal(error) => write!(f, "failed to register SIGTERM handler: {error}"),
1669 Self::InvalidPort { raw, source } => {
1670 write!(f, "invalid {SUBC_PORT_ENV} value '{raw}': {source}")
1671 }
1672 Self::ConnectionFileRead { path, source } => write!(
1673 f,
1674 "failed to read connection file {}: {source}",
1675 path.display()
1676 ),
1677 Self::ConnectionFileWrite { path, source } => write!(
1678 f,
1679 "failed to publish connection file {}: {source}",
1680 path.display()
1681 ),
1682 Self::GenerateConnectionFile(err) => {
1683 write!(f, "failed to generate connection-file auth material: {err}")
1684 }
1685 Self::StartLockCreate { path, source } => {
1686 write!(
1687 f,
1688 "failed to create start lock {}: {source}",
1689 path.display()
1690 )
1691 }
1692 Self::StartLockBusy { path, attempts } => write!(
1693 f,
1694 "start lock {} remained busy after {attempts} attempts",
1695 path.display()
1696 ),
1697 Self::RunDirLockCreate { path, source } => write!(
1698 f,
1699 "refusing to start: failed to lock run directory via {}: {source}",
1700 path.display()
1701 ),
1702 Self::RunDirBusy { path, holder_pid } => {
1703 write!(
1704 f,
1705 "refusing to start: run directory lock {} is held by another daemon",
1706 path.display()
1707 )?;
1708 if let Some(pid) = holder_pid {
1709 write!(f, " (pid {pid})")?;
1710 }
1711 write!(
1712 f,
1713 "; a live daemon owns this data home's run state (typically one started with a different XDG_RUNTIME_DIR)"
1714 )
1715 }
1716 Self::RemoveStale { path, source } => write!(
1717 f,
1718 "failed to remove stale connection file {}: {source}",
1719 path.display()
1720 ),
1721 Self::Bind { host, port, source } if source.kind() == io::ErrorKind::AddrInUse => {
1722 write!(
1723 f,
1724 "port {port} in use on loopback {host}: {source}; set the port in config"
1725 )
1726 }
1727 Self::Bind { host, port, source } => {
1728 write!(f, "failed to bind loopback TCP {host}:{port}: {source}")
1729 }
1730 Self::LocalAddr { host, source } => {
1731 write!(f, "failed to read local address for {host}: {source}")
1732 }
1733 Self::DaemonConfig(err) => write!(f, "failed to load daemon config: {err}"),
1734 Self::MachineId(err) => write!(f, "refusing to start: {err}"),
1735 Self::RunDir(err) => write!(f, "refusing to start: {err}"),
1736 Self::Serve(err) => write!(f, "daemon server failed: {err}"),
1737 Self::ServeJoin(err) => write!(f, "daemon server task failed: {err}"),
1738 }
1739 }
1740}
1741
1742impl Error for BootstrapError {
1743 fn source(&self) -> Option<&(dyn Error + 'static)> {
1744 match self {
1745 Self::StartupNotServing => None,
1746 #[cfg(unix)]
1747 Self::Signal(source) => Some(source),
1748 Self::InvalidPort { source, .. } => Some(source),
1749 Self::ConnectionFileRead { source, .. }
1750 | Self::ConnectionFileWrite { source, .. }
1751 | Self::GenerateConnectionFile(source) => Some(source),
1752 Self::StartLockCreate { source, .. }
1753 | Self::RunDirLockCreate { source, .. }
1754 | Self::RemoveStale { source, .. }
1755 | Self::Bind { source, .. }
1756 | Self::LocalAddr { source, .. } => Some(source),
1757 Self::DaemonConfig(err) => Some(err),
1758 Self::MachineId(err) => Some(err),
1759 Self::RunDir(err) => Some(err),
1760 Self::Serve(err) => Some(err),
1761 Self::ServeJoin(err) => Some(err),
1762 Self::StartLockBusy { .. } | Self::RunDirBusy { .. } => None,
1763 }
1764 }
1765}
1766
1767#[cfg(test)]
1768mod tests {
1769 mod activity {
1770 include!("activity_bootstrap_tests.rs");
1771 }
1772 use super::*;
1773 use crate::server::ServerAuth;
1774 use cortexkit_test_support::ScratchDir;
1775 #[cfg(target_os = "linux")]
1776 use std::collections::BTreeSet;
1777 use std::sync::Mutex;
1778 #[cfg(target_os = "linux")]
1779 use subc_control::ModuleProtocol;
1780 use subc_transport::MIN_KEY_LEN;
1781 use tokio::io::AsyncReadExt;
1782 use tokio::task::JoinHandle;
1783
1784 #[cfg(unix)]
1785 use std::os::unix::fs::PermissionsExt;
1786
1787 static ENV_LOCK: Mutex<()> = Mutex::new(());
1788
1789 #[test]
1790 fn normalized_build_provenance_preserves_real_values() {
1791 assert_eq!(normalized_build_provenance("abc"), Some("abc".to_string()));
1792 }
1793
1794 #[test]
1795 fn normalized_build_provenance_omits_unavailable_and_empty_values() {
1796 assert_eq!(normalized_build_provenance("unavailable"), None);
1797 assert_eq!(normalized_build_provenance(""), None);
1798 }
1799
1800 #[cfg(target_os = "linux")]
1801 fn current_cgroup_path_for_test() -> io::Result<PathBuf> {
1802 let cgroups = fs::read_to_string("/proc/self/cgroup")?;
1803 let relative = cgroups
1804 .lines()
1805 .find_map(|line| line.strip_prefix("0::"))
1806 .ok_or_else(|| {
1807 io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 is unavailable")
1808 })?;
1809 Ok(Path::new("/sys/fs/cgroup").join(relative.trim_start_matches('/')))
1810 }
1811
1812 #[cfg(target_os = "linux")]
1813 fn module_cgroup_directories() -> io::Result<BTreeSet<OsString>> {
1814 let modules = current_cgroup_path_for_test()?.join("subc-modules");
1815 let entries = match fs::read_dir(modules) {
1816 Ok(entries) => entries,
1817 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
1818 Err(error) => return Err(error),
1819 };
1820 let mut directories = BTreeSet::new();
1821 for entry in entries {
1822 let entry = entry?;
1823 if entry.file_type()?.is_dir() {
1824 directories.insert(entry.file_name());
1825 }
1826 }
1827 Ok(directories)
1828 }
1829
1830 #[cfg(target_os = "linux")]
1831 async fn wait_for_path(path: &Path, task: &JoinHandle<Result<(), BootstrapError>>) {
1832 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1833 while !path.exists() && tokio::time::Instant::now() < deadline {
1834 assert!(
1835 !task.is_finished(),
1836 "daemon exited before creating {}",
1837 path.display()
1838 );
1839 sleep(Duration::from_millis(10)).await;
1840 }
1841 assert!(path.exists(), "daemon did not create {}", path.display());
1842 }
1843
1844 #[cfg(target_os = "linux")]
1849 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1850 async fn run_with_config_does_not_reconcile_the_ambient_cgroup_by_default() {
1851 let temp = unique_temp_dir("bootstrap-cgroup-default-disabled");
1852 let module_id = format!("cgroup-isolation-probe-{}", process::id());
1853 let before = module_cgroup_directories().expect("read ambient module cgroups before boot");
1854 assert!(
1855 !before.contains(&OsString::from(&module_id)),
1856 "isolation probe cgroup already exists before this daemon starts"
1857 );
1858 let capture = temp.join("logs").join(format!("{module_id}.stderr.log"));
1859 let module = ConfiguredModule {
1860 module_id,
1861 program: PathBuf::from("sh"),
1862 args: vec!["-c".to_string(), "sleep 30".to_string()],
1863 env: Vec::new(),
1864 log: None,
1865 enabled: true,
1866 reserved: false,
1867 reserved_prefixes: Vec::new(),
1868 protocol: ModuleProtocol::None,
1869 overlap: Default::default(),
1870 health: HealthConfig::default(),
1871 drain_timeout_ms: None,
1872 route_bind_relay_timeout_ms: None,
1873 restart: RestartPolicy::default(),
1874 };
1875 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1876 .with_configured_modules([module])
1877 .with_capture_logs_dir(temp.join("logs"))
1878 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1879 let task = tokio::spawn(run_with_config(config));
1880
1881 wait_for_path(&capture, &task).await;
1882 let after = module_cgroup_directories().expect("read ambient module cgroups after boot");
1883
1884 task.abort();
1885 assert!(task
1886 .await
1887 .expect_err("aborted daemon task must cancel")
1888 .is_cancelled());
1889 assert_eq!(
1890 before, after,
1891 "an in-process daemon must not create or reconcile ambient module cgroups"
1892 );
1893 }
1894
1895 #[cfg(target_os = "linux")]
1896 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1897 async fn explicit_cgroup_root_is_prepared_inside_the_fixture_tree() {
1898 let temp = unique_temp_dir("bootstrap-cgroup-explicit-root");
1899 let cgroup_root = temp.join("cgroup");
1900 fs::create_dir(&cgroup_root).expect("create scratch cgroup root");
1901 fs::write(cgroup_root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
1902 let modules = cgroup_root.join("subc-modules");
1903 let config = BootstrapConfig::new(temp.join("connection.json"), 0)
1904 .with_cgroup_placement(CgroupPlacementConfig::Root(cgroup_root))
1905 .with_terminal_journal_path(temp.join("terminals.jsonl"));
1906 let task = tokio::spawn(run_with_config(config));
1907
1908 wait_for_path(&modules, &task).await;
1909
1910 task.abort();
1911 assert!(task
1912 .await
1913 .expect_err("aborted daemon task must cancel")
1914 .is_cancelled());
1915 assert!(
1916 fs::read_dir(&modules)
1917 .expect("read prepared modules directory")
1918 .next()
1919 .is_none(),
1920 "the delegation probe must clean up after itself"
1921 );
1922 }
1923
1924 struct EnvGuard {
1925 key: &'static str,
1926 previous: Option<OsString>,
1927 }
1928
1929 impl EnvGuard {
1930 fn set(key: &'static str, value: &Path) -> Self {
1931 let previous = env::var_os(key);
1932 env::set_var(key, value);
1933 Self { key, previous }
1934 }
1935
1936 fn set_str(key: &'static str, value: &str) -> Self {
1937 let previous = env::var_os(key);
1938 env::set_var(key, value);
1939 Self { key, previous }
1940 }
1941
1942 fn unset(key: &'static str) -> Self {
1943 let previous = env::var_os(key);
1944 env::remove_var(key);
1945 Self { key, previous }
1946 }
1947 }
1948
1949 impl Drop for EnvGuard {
1950 fn drop(&mut self) {
1951 match &self.previous {
1952 Some(value) => env::set_var(self.key, value),
1953 None => env::remove_var(self.key),
1954 }
1955 }
1956 }
1957
1958 fn unique_temp_dir(name: &str) -> ScratchDir {
1959 ScratchDir::new(name)
1960 }
1961
1962 fn temp_connection_file_path(name: &str) -> (ScratchDir, PathBuf) {
1963 let dir = unique_temp_dir(name);
1964 let path = dir.join("conn.json");
1965 (dir, path)
1966 }
1967
1968 fn auth_for(info: &ConnectionInfo) -> ServerAuth {
1969 ServerAuth::new(info.key.clone(), info.daemon_id, info.daemon_ver.clone())
1970 }
1971
1972 fn start_server(bound: BoundDaemon) -> JoinHandle<Result<(), ServerError>> {
1973 let auth = auth_for(&bound.connection_info);
1974 tokio::spawn(serve_listeners(
1975 bound.listeners,
1976 Arc::new(Router::with_default_self_handler()),
1977 auth,
1978 ))
1979 }
1980
1981 fn expect_bound(outcome: Outcome) -> BoundDaemon {
1982 match outcome {
1983 Outcome::Bound(bound) => bound,
1984 Outcome::AlreadyRunning => panic!("fresh connection file unexpectedly had a daemon"),
1985 }
1986 }
1987
1988 async fn connect_from_info(conn: &ConnectionInfo) -> io::Result<TcpStream> {
1989 let endpoint = conn
1990 .endpoints
1991 .first()
1992 .expect("test connection file should have an endpoint");
1993 let ip: IpAddr = endpoint.host.parse().unwrap();
1994 TcpStream::connect(SocketAddr::new(ip, endpoint.port)).await
1995 }
1996
1997 fn make_connection_info(port: u16) -> ConnectionInfo {
1998 ConnectionInfo {
1999 schema: SCHEMA_VERSION,
2000 wire_version: Some(PROTOCOL_VERSION),
2001 endpoints: vec![Endpoint {
2002 host: "127.0.0.1".to_owned(),
2003 port,
2004 }],
2005 key: generate_key().unwrap(),
2006 daemon_id: generate_daemon_id().unwrap(),
2007 pid: process::id(),
2008 daemon_ver: "test-subc".to_owned(),
2009 }
2010 }
2011
2012 fn write_raw_owner_only_connection_file(path: &Path, contents: &[u8]) {
2013 fs::write(path, contents).unwrap();
2014 #[cfg(unix)]
2015 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap();
2016 }
2017
2018 fn assert_owner_only_connection_file(path: &Path) {
2019 #[cfg(unix)]
2022 {
2023 let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
2024 assert_eq!(mode, 0o600);
2025 }
2026 #[cfg(not(unix))]
2027 let _ = path;
2028 }
2029
2030 #[test]
2031 fn connection_file_path_uses_xdg_runtime_dir_when_set() {
2032 let _env_lock = ENV_LOCK.lock().unwrap();
2033 let runtime_dir = unique_temp_dir("xdg-runtime");
2034 let _xdg = EnvGuard::set("XDG_RUNTIME_DIR", runtime_dir.path());
2035
2036 assert_eq!(
2037 connection_file_path(),
2038 runtime_dir.join(CONNECTION_FILE_NAME)
2039 );
2040 }
2041
2042 #[test]
2043 fn connection_file_path_source_is_xdg_runtime_dir_when_set() {
2044 let runtime_dir = OsString::from("/run/user/1000");
2045
2046 let (path, source) = connection_file_path_with_source(Some(runtime_dir));
2047
2048 assert_eq!(
2049 path,
2050 PathBuf::from("/run/user/1000").join(CONNECTION_FILE_NAME)
2051 );
2052 assert_eq!(source, ConnectionFileSource::XdgRuntimeDir);
2053 }
2054
2055 #[test]
2056 fn connection_file_path_falls_back_to_temp_dir_with_user_token_when_xdg_unset() {
2057 let _env_lock = ENV_LOCK.lock().unwrap();
2058 let _xdg = EnvGuard::unset("XDG_RUNTIME_DIR");
2059
2060 assert_eq!(
2061 connection_file_path(),
2062 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2063 );
2064 }
2065
2066 #[test]
2067 fn connection_file_path_source_is_temp_dir_when_xdg_unset() {
2068 let (path, source) = connection_file_path_with_source(None);
2069
2070 assert_eq!(
2071 path,
2072 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2073 );
2074 assert_eq!(source, ConnectionFileSource::TempDirFallback);
2075 }
2076
2077 #[test]
2082 fn user_connection_token_is_stable_under_concurrent_callers() {
2083 let expected = user_connection_token();
2084 let workers: Vec<_> = (0..32)
2085 .map(|_| {
2086 std::thread::spawn(|| (0..40).map(|_| user_connection_token()).collect::<Vec<_>>())
2087 })
2088 .collect();
2089 for worker in workers {
2090 for token in worker.join().expect("probe thread") {
2091 assert_eq!(token, expected, "token diverged under concurrent probes");
2092 }
2093 }
2094 }
2095
2096 #[test]
2097 fn connection_file_path_source_is_temp_dir_when_xdg_empty() {
2098 let (path, source) = connection_file_path_with_source(Some(OsString::new()));
2099
2100 assert_eq!(
2101 path,
2102 env::temp_dir().join(format!("subc-{}.connection.json", user_connection_token()))
2103 );
2104 assert_eq!(source, ConnectionFileSource::TempDirFallback);
2105 }
2106
2107 #[test]
2111 fn daemon_binary_config_journals_and_captures_into_the_run_dir() {
2112 let _env_lock = ENV_LOCK.lock().unwrap();
2113 let root = unique_temp_dir("daemon-binary-config");
2114 let data_home = root.join("data");
2115 let _data = EnvGuard::set("XDG_DATA_HOME", &data_home);
2116 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2117 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2118
2119 let config = BootstrapConfig::from_env_for_daemon_binary().unwrap();
2120
2121 let run_dir = data_home.join("cortexkit").join("run");
2122 assert_eq!(
2123 config.terminal_journal_path,
2124 Some(run_dir.join("terminals.jsonl"))
2125 );
2126 assert_eq!(config.capture_logs_dir, Some(run_dir.join("logs")));
2127 assert_eq!(
2128 BootstrapConfig::new(root.join("connection.json"), 0).terminal_journal_path,
2129 None,
2130 "an in-process config must not journal anywhere unless asked to"
2131 );
2132 }
2133
2134 #[test]
2135 fn daemon_binary_config_refuses_a_relative_data_home() {
2136 let _env_lock = ENV_LOCK.lock().unwrap();
2137 let root = unique_temp_dir("daemon-binary-relative-data");
2138 let _data = EnvGuard::set_str("XDG_DATA_HOME", "relative-data-home");
2139 let _config = EnvGuard::set("XDG_CONFIG_HOME", &root.join("config"));
2140 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2141
2142 let error = BootstrapConfig::from_env_for_daemon_binary()
2143 .expect_err("a relative data home must refuse the daemon binary's config");
2144 assert!(
2145 matches!(error, BootstrapError::RunDir(_)),
2146 "expected a run-directory refusal, got {error}"
2147 );
2148 assert!(error.to_string().contains("XDG_DATA_HOME"), "{error}");
2149 }
2150
2151 #[test]
2152 fn configured_port_uses_default_config_and_env_override() {
2153 let _env_lock = ENV_LOCK.lock().unwrap();
2154 let (_dir, conn_path) = temp_connection_file_path("daemon-config-port");
2155 let config_path = conn_path.with_file_name("subc.jsonc");
2156
2157 let _port = EnvGuard::unset(SUBC_PORT_ENV);
2158 assert_eq!(
2159 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2160 .unwrap()
2161 .port,
2162 DEFAULT_SUBC_PORT
2163 );
2164
2165 fs::write(&config_path, r#"{ "version": 1, "port": 8123 }"#).unwrap();
2166 assert_eq!(
2167 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2168 .unwrap()
2169 .port,
2170 8123
2171 );
2172
2173 let _port = EnvGuard::set_str(SUBC_PORT_ENV, "9012");
2174 assert_eq!(
2175 BootstrapConfig::from_env_with_daemon_config_path(&config_path)
2176 .unwrap()
2177 .port,
2178 9012
2179 );
2180 }
2181
2182 #[tokio::test]
2183 async fn second_singleton_probe_against_served_tcp_daemon_reports_already_running() {
2184 let (_dir, path) = temp_connection_file_path("already-running");
2185
2186 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2187 let server = start_server(bound);
2188
2189 let second = ensure_singleton(&path, 0).await.unwrap();
2190 assert!(matches!(second, Outcome::AlreadyRunning));
2191
2192 server.abort();
2193 let _ = server.await;
2194 }
2195
2196 #[tokio::test]
2197 async fn daemon_connection_file_publishes_protocol_wire_version() {
2198 let (_dir, path) = temp_connection_file_path("wire-version");
2199 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2200 assert_eq!(bound.connection_info.wire_version, Some(PROTOCOL_VERSION));
2201 assert_eq!(
2202 connection_file::read(&path).unwrap().wire_version,
2203 Some(PROTOCOL_VERSION)
2204 );
2205
2206 drop(bound.listeners);
2207 }
2208
2209 #[tokio::test]
2210 async fn stale_unbound_connection_file_is_reclaimed() {
2211 let (_dir, path) = temp_connection_file_path("stale-reclaim");
2212 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2213 let stale_port = stale.local_addr().unwrap().port();
2214 drop(stale);
2215 let stale_info = make_connection_info(stale_port);
2216 write_atomic(&path, &stale_info).unwrap();
2217
2218 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2219 assert_ne!(bound.connection_info.key, stale_info.key);
2220 drop(bound.listeners);
2221 }
2222
2223 #[cfg(unix)]
2224 #[tokio::test]
2225 async fn ensure_singleton_reclaims_insecure_connection_file() {
2226 let (_dir, path) = temp_connection_file_path("insecure-reclaim");
2227 let stale = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2228 let stale_port = stale.local_addr().unwrap().port();
2229 drop(stale);
2230 let stale_info = make_connection_info(stale_port);
2231 write_atomic(&path, &stale_info).unwrap();
2232 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2233
2234 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2235 assert_ne!(bound.connection_info.key, stale_info.key);
2236 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2237 assert_owner_only_connection_file(&path);
2238
2239 drop(bound.listeners);
2240 }
2241
2242 #[tokio::test]
2243 async fn ensure_singleton_reclaims_non_loopback_connection_file() {
2244 let (_dir, path) = temp_connection_file_path("non-loopback-reclaim");
2245 let mut stale_info = make_connection_info(8757);
2246 stale_info.endpoints = vec![Endpoint {
2247 host: "192.0.2.10".to_owned(),
2248 port: 8757,
2249 }];
2250 write_atomic(&path, &stale_info).unwrap();
2251
2252 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2253 assert_ne!(bound.connection_info.key, stale_info.key);
2254 assert_ne!(bound.connection_info.daemon_id, stale_info.daemon_id);
2255 assert!(bound
2256 .connection_info
2257 .endpoints
2258 .iter()
2259 .all(|endpoint| endpoint.host.parse::<IpAddr>().unwrap().is_loopback()));
2260 assert_owner_only_connection_file(&path);
2261
2262 drop(bound.listeners);
2263 }
2264
2265 #[tokio::test]
2266 async fn ensure_singleton_reclaims_invalid_connection_file_shapes() {
2267 let mut unsupported_schema = make_connection_info(8757);
2268 unsupported_schema.schema = SCHEMA_VERSION + 1;
2269
2270 let mut empty_endpoints = make_connection_info(8757);
2271 empty_endpoints.endpoints.clear();
2272
2273 let mut short_key = make_connection_info(8757);
2274 short_key.key = vec![0x5A; MIN_KEY_LEN - 1];
2275
2276 let cases = vec![
2277 (
2278 "unsupported-schema",
2279 serde_json::to_vec(&unsupported_schema).unwrap(),
2280 Some(unsupported_schema),
2281 ),
2282 (
2283 "empty-endpoints",
2284 serde_json::to_vec(&empty_endpoints).unwrap(),
2285 Some(empty_endpoints),
2286 ),
2287 (
2288 "short-key",
2289 serde_json::to_vec(&short_key).unwrap(),
2290 Some(short_key),
2291 ),
2292 ("invalid-json", b"{not valid connection json".to_vec(), None),
2293 ];
2294
2295 for (label, contents, old_info) in cases {
2296 let (_dir, path) = temp_connection_file_path(label);
2297 write_raw_owner_only_connection_file(&path, &contents);
2298
2299 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2300 if let Some(old_info) = old_info {
2301 assert_ne!(bound.connection_info.key, old_info.key, "{label}");
2302 assert_ne!(
2303 bound.connection_info.daemon_id, old_info.daemon_id,
2304 "{label}"
2305 );
2306 }
2307 assert!(bound.connection_info.key.len() >= MIN_KEY_LEN, "{label}");
2308 assert_ne!(bound.connection_info.daemon_id, [0u8; 16], "{label}");
2309 assert_owner_only_connection_file(&path);
2310
2311 drop(bound.listeners);
2312 }
2313 }
2314
2315 #[tokio::test]
2316 async fn foreign_reused_port_connection_file_is_reclaimed_after_auth_probe_fails() {
2317 let (_dir, path) = temp_connection_file_path("foreign-reclaim");
2318 let foreign = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2319 let foreign_port = foreign.local_addr().unwrap().port();
2320 write_atomic(&path, &make_connection_info(foreign_port)).unwrap();
2321 let foreign_task = tokio::spawn(async move {
2322 if let Ok((mut stream, _)) = foreign.accept().await {
2323 let mut buf = [0u8; 64];
2324 let _ = stream.read(&mut buf).await;
2325 }
2326 });
2327
2328 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2329 assert!(bound
2330 .connection_info
2331 .endpoints
2332 .iter()
2333 .all(|endpoint| endpoint.port != foreign_port));
2334
2335 drop(bound.listeners);
2336 let _ = foreign_task.await;
2337 }
2338
2339 #[tokio::test]
2340 async fn stale_start_lock_file_is_reclaimable() {
2341 let (_dir, path) = temp_connection_file_path("start-lock-stale-file");
2342 let lock_path = start_lock_path(&path);
2343 drop(open_owner_only_lock(&lock_path).unwrap());
2344 assert!(lock_path.is_file());
2345
2346 let lock = StartLock::acquire(&path).await.unwrap();
2347 assert!(lock_path.is_file());
2348
2349 drop(lock);
2350 assert!(lock_path.is_file());
2351 }
2352
2353 #[tokio::test]
2354 async fn held_start_lock_blocks_second_acquire_until_release() {
2355 let (_dir, path) = temp_connection_file_path("start-lock-held");
2356 let lock_path = start_lock_path(&path);
2357 let first = StartLock::acquire(&path).await.unwrap();
2358
2359 let err = match StartLock::acquire(&path).await {
2360 Ok(_) => panic!("second acquire while held must stay busy"),
2361 Err(err) => err,
2362 };
2363 assert!(matches!(
2364 err,
2365 BootstrapError::StartLockBusy {
2366 ref path,
2367 attempts: START_LOCK_RETRIES,
2368 } if path == &lock_path
2369 ));
2370
2371 drop(first);
2372
2373 let second = StartLock::acquire(&path)
2374 .await
2375 .expect("released advisory lock should be reclaimable");
2376 drop(second);
2377 }
2378
2379 #[tokio::test]
2380 async fn bind_conflict_on_fixed_port_fails_loud_without_reselecting() {
2381 let (_dir, path) = temp_connection_file_path("bind-conflict");
2382 let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
2383 let occupied_port = occupied.local_addr().unwrap().port();
2384
2385 let err = ensure_singleton(&path, occupied_port).await.unwrap_err();
2386 assert!(matches!(
2387 err,
2388 BootstrapError::Bind { ref source, .. } if source.kind() == io::ErrorKind::AddrInUse
2389 ));
2390 assert!(err.to_string().contains("set the port in config"));
2391
2392 drop(occupied);
2393 }
2394
2395 #[tokio::test]
2396 async fn ephemeral_ipv6_collision_still_serves_ipv4() {
2397 let (listeners, endpoints) = bind_loopback_with(0, |port| async move {
2398 let occupied = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await?;
2399 let result = TcpListener::bind((Ipv6Addr::LOCALHOST, port)).await;
2400 drop(occupied);
2401 result
2402 })
2403 .await
2404 .expect("an incidental IPv6 collision must not prevent an ephemeral daemon from starting");
2405 assert_eq!(endpoints.len(), 1);
2406 assert_eq!(endpoints[0].host, "127.0.0.1");
2407 TcpStream::connect((Ipv4Addr::LOCALHOST, endpoints[0].port))
2408 .await
2409 .unwrap();
2410 drop(listeners);
2411 }
2412
2413 #[tokio::test]
2414 async fn unpublished_boot_holds_singleton_lock_until_publication() {
2415 let dir = ScratchDir::new("unpublished-singleton-lock");
2416 let path = dir.join("connection.json");
2417 let bound = expect_bound(
2418 ensure_singleton_inner(BootstrapConfig::new(&path, 0), false)
2419 .await
2420 .unwrap(),
2421 );
2422 assert!(!path.exists());
2423 let contender = open_owner_only_lock(&start_lock_path(&path)).unwrap();
2424 assert!(
2425 matches!(FileExt::try_lock(&contender), Err(TryLockError::WouldBlock)),
2426 "an unpublished boot must keep its singleton claim"
2427 );
2428 drop(bound);
2429 FileExt::try_lock(&contender).unwrap();
2430 }
2431
2432 #[tokio::test]
2433 async fn readiness_probe_requires_an_authenticating_server() {
2434 let dir = ScratchDir::new("unserved-readiness");
2435 let bound = expect_bound(
2436 ensure_singleton_inner(BootstrapConfig::new(dir.join("connection.json"), 0), false)
2437 .await
2438 .unwrap(),
2439 );
2440 assert!(matches!(
2441 verify_serving(&bound.connection_info).await,
2442 Err(BootstrapError::StartupNotServing)
2443 ));
2444 }
2445
2446 #[cfg(unix)]
2447 #[tokio::test]
2448 async fn orphan_sweep_finishes_before_connection_file_publication() {
2449 use crate::live_children::{ExecutableIdentity, LiveChild};
2450 let dir = ScratchDir::new("publish-after-sweep");
2451 let path = dir.join("connection.json");
2452 let record = dir.join("live-children.json");
2453 let ready = dir.join("ready");
2454 let mut child = std::process::Command::new("/bin/sh")
2455 .args([
2456 "-c",
2457 "trap '' TERM; : > \"$0\"; read -r _",
2458 ready.to_str().unwrap(),
2459 ])
2460 .stdin(std::process::Stdio::piped())
2461 .env("XDG_DATA_HOME", dir.path())
2462 .env("XDG_RUNTIME_DIR", dir.path())
2463 .env("XDG_CONFIG_HOME", dir.path())
2464 .spawn()
2465 .unwrap();
2466 timeout(Duration::from_secs(5), async {
2467 while !ready.exists() {
2468 sleep(Duration::from_millis(5)).await;
2469 }
2470 })
2471 .await
2472 .unwrap();
2473 let observed = subc_os::Process::open(child.id())
2474 .unwrap()
2475 .unwrap()
2476 .observe()
2477 .unwrap();
2478 crate::live_children::write_record(
2479 &record,
2480 &[LiveChild {
2481 module_id: "old-sleep".into(),
2482 pid: child.id(),
2483 protocol: subc_control::ModuleProtocol::None,
2484 start_time: Some(observed.start_time),
2485 executable: observed.executable.map(ExecutableIdentity::from),
2486 cgroup_name: None,
2487 }],
2488 )
2489 .unwrap();
2490 let config = BootstrapConfig::new(&path, 0).with_live_children_record(&record);
2491 let daemon = tokio::spawn(run_with_config(config));
2492 timeout(Duration::from_secs(5), async {
2493 while !path.exists() {
2494 sleep(Duration::from_millis(5)).await;
2495 }
2496 })
2497 .await
2498 .unwrap();
2499 assert!(path.exists());
2500 let ended = child.try_wait().unwrap().is_some();
2501 if !ended {
2502 child.kill().unwrap();
2503 }
2504 child.wait().unwrap();
2505 if ended {
2506 assert!(matches!(probe_existing(&path).await.unwrap(), Probe::Live));
2507 }
2508 daemon.abort();
2509 let _ = daemon.await;
2510 assert!(
2511 ended,
2512 "the published daemon must not still owe its orphan sweep"
2513 );
2514 }
2515
2516 #[tokio::test(flavor = "current_thread")]
2523 async fn connection_file_publication_leaves_the_async_runtime_free() {
2524 use std::sync::atomic::{AtomicBool, Ordering};
2525
2526 let (_dir, path) = temp_connection_file_path("publish-off-runtime");
2527 let write_started = Arc::new(AtomicBool::new(false));
2528 let (progress_tx, progress_rx) = std::sync::mpsc::channel::<()>();
2529 let (verdict_tx, verdict_rx) = std::sync::mpsc::channel::<bool>();
2530 let hook_started = Arc::clone(&write_started);
2531 connection_file_write_hook::install(&path, move || {
2532 hook_started.store(true, Ordering::SeqCst);
2533 let progressed = progress_rx.recv_timeout(Duration::from_secs(5)).is_ok();
2534 let _ = verdict_tx.send(progressed);
2535 });
2536 let ticker = tokio::spawn(async move {
2539 loop {
2540 if write_started.load(Ordering::SeqCst) {
2541 let _ = progress_tx.send(());
2542 return;
2543 }
2544 sleep(Duration::from_millis(5)).await;
2545 }
2546 });
2547
2548 let daemon = tokio::spawn(run_with_config(BootstrapConfig::new(&path, 0)));
2549 let published = timeout(Duration::from_secs(15), async {
2550 while !path.exists() && !daemon.is_finished() {
2551 sleep(Duration::from_millis(5)).await;
2552 }
2553 })
2554 .await;
2555 let daemon_exited = daemon.is_finished();
2556 daemon.abort();
2557 let daemon_result = daemon.await;
2558 ticker.abort();
2559
2560 assert!(
2561 published.is_ok() && !daemon_exited,
2562 "the daemon must start and publish its connection file: {daemon_result:?}"
2563 );
2564 match verdict_rx.try_recv() {
2565 Ok(true) => {}
2566 Ok(false) => panic!(
2567 "no other task on the runtime ran while the connection file was being written: the write is blocking the async worker thread"
2568 ),
2569 Err(_) => panic!("startup published the connection file without the hooked write"),
2570 }
2571 }
2572
2573 #[tokio::test]
2576 async fn a_panicking_connection_file_write_fails_startup() {
2577 let (_dir, path) = temp_connection_file_path("publish-panics");
2578 connection_file_write_hook::install(&path, || {
2579 panic!("deliberate panic inside the connection-file write")
2580 });
2581
2582 let error = timeout(
2583 Duration::from_secs(15),
2584 run_with_config(BootstrapConfig::new(&path, 0)),
2585 )
2586 .await
2587 .expect("startup must end once the write task fails")
2588 .expect_err("startup must fail when the connection-file write panics");
2589
2590 assert!(
2591 matches!(&error, BootstrapError::ConnectionFileWrite { path: failed, .. } if failed == &path),
2592 "expected a connection-file write error, got {error:?}"
2593 );
2594 let message = error.to_string();
2595 assert!(message.contains(&path.display().to_string()), "{message}");
2596 assert!(message.contains("panicked"), "{message}");
2597 assert!(!path.exists(), "no connection file may be published");
2598 }
2599
2600 #[cfg(target_os = "macos")]
2601 #[test]
2602 fn nofile_raise_worker() {
2603 let Some(path) = std::env::var_os("SUBC_NOFILE_TEST_FILE") else {
2604 return;
2605 };
2606 let (_, hard) = rlimit::Resource::NOFILE.get().unwrap();
2607 rlimit::Resource::NOFILE.set(256, hard).unwrap();
2608 raise_nofile_limit_with_ceiling(Some(10240));
2609 std::fs::write(path, rlimit::Resource::NOFILE.get().unwrap().0.to_string()).unwrap();
2610 }
2611
2612 #[cfg(target_os = "macos")]
2613 #[test]
2614 fn macos_nofile_raise_clamps_to_kernel_ceiling() {
2615 let dir = ScratchDir::new("nofile-ceiling");
2616 let result = dir.join("soft");
2617 let status = std::process::Command::new(std::env::current_exe().unwrap())
2618 .args(["--exact", "bootstrap::tests::nofile_raise_worker"])
2619 .env("SUBC_NOFILE_TEST_FILE", &result)
2620 .env("XDG_DATA_HOME", dir.path())
2621 .env("XDG_RUNTIME_DIR", dir.path())
2622 .env("XDG_CONFIG_HOME", dir.path())
2623 .status()
2624 .unwrap();
2625 assert!(status.success());
2626 let soft: u64 = std::fs::read_to_string(result).unwrap().parse().unwrap();
2627 assert!(
2628 soft > 256 && soft <= 10240,
2629 "the inherited limit must be raised within the kernel ceiling; got {soft}"
2630 );
2631 }
2632
2633 #[tokio::test]
2634 async fn key_rotation_republishes_new_material_and_old_file_fails_auth() {
2635 let (_dir, path) = temp_connection_file_path("key-rotation");
2636 let first = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2637 let old_info = first.connection_info.clone();
2638 let fixed_port = old_info.endpoints[0].port;
2639 drop(first.listeners);
2640
2641 let second = expect_bound(ensure_singleton(&path, fixed_port).await.unwrap());
2642 let new_info = second.connection_info.clone();
2643 assert_ne!(old_info.key, new_info.key);
2644 assert_ne!(old_info.daemon_id, new_info.daemon_id);
2645 let server = start_server(second);
2646
2647 let mut old_stream = connect_from_info(&old_info).await.unwrap();
2648 let old_auth = authenticate_client(&mut old_stream, &old_info, PROBE_AUTH_DEADLINE).await;
2649 assert!(
2650 old_auth.is_err(),
2651 "old key must not authenticate after restart"
2652 );
2653
2654 let reread = connection_file::read(&path).unwrap();
2655 let mut new_stream = connect_from_info(&reread).await.unwrap();
2656 authenticate_client(&mut new_stream, &reread, PROBE_AUTH_DEADLINE)
2657 .await
2658 .unwrap();
2659
2660 server.abort();
2661 let _ = server.await;
2662 }
2663
2664 #[cfg(unix)]
2665 #[tokio::test]
2666 async fn published_connection_file_permissions_are_owner_only() {
2667 let (_dir, path) = temp_connection_file_path("permissions");
2668 let bound = expect_bound(ensure_singleton(&path, 0).await.unwrap());
2669
2670 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2671 assert_eq!(mode, 0o600);
2672
2673 drop(bound.listeners);
2674 }
2675}