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