1use std::env;
4use std::fmt;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::time::{Duration, Instant};
9
10use rmux_proto::Response;
11#[cfg(unix)]
12use rmux_sdk::bootstrap::startup_unix::{
13 connect_or_start_with, StartupError, StartupOutcome, DEFAULT_STARTUP_DEADLINE,
14 STARTUP_POLL_INTERVAL,
15};
16#[cfg(windows)]
17use rmux_sdk::bootstrap::startup_windows::{
18 connect_or_start_with, StartupError, StartupOutcome, DEFAULT_STARTUP_DEADLINE,
19 STARTUP_POLL_INTERVAL,
20};
21
22use crate::shell_quote::shell_quote_path;
23#[cfg(any(all(test, unix), not(any(unix, windows))))]
24use crate::ConnectResult;
25use crate::{ClientError, Connection};
26
27mod upgrade_restart;
28
29#[cfg(not(any(unix, windows)))]
30const AUTO_START_TIMEOUT: Duration = Duration::from_secs(5);
31#[cfg(not(any(unix, windows)))]
32const POLL_INTERVAL: Duration = Duration::from_millis(50);
33
34pub const INTERNAL_DAEMON_FLAG: &str = "--__internal-daemon";
39
40const BINARY_OVERRIDE_ENV: &str = "RMUX_INTERNAL_BINARY_PATH";
41const BINARY_OVERRIDE_TEST_OPT_IN_ENV: &str = "RMUX_ALLOW_INTERNAL_BINARY_OVERRIDE";
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AutoStartConfig {
45 selection: AutoStartConfigSelection,
46 quiet: bool,
47 cwd: Option<PathBuf>,
48 web_frontend: Option<String>,
49 web_port: Option<u16>,
50 web_required: bool,
51}
52
53impl AutoStartConfig {
54 #[must_use]
56 pub const fn disabled() -> Self {
57 Self {
58 selection: AutoStartConfigSelection::Disabled,
59 quiet: true,
60 cwd: None,
61 web_frontend: None,
62 web_port: None,
63 web_required: false,
64 }
65 }
66
67 #[must_use]
69 pub fn default_files(quiet: bool, cwd: Option<PathBuf>) -> Self {
70 Self {
71 selection: AutoStartConfigSelection::Default,
72 quiet,
73 cwd,
74 web_frontend: None,
75 web_port: None,
76 web_required: false,
77 }
78 }
79
80 #[must_use]
82 pub fn custom_files(files: Vec<PathBuf>, quiet: bool, cwd: Option<PathBuf>) -> Self {
83 Self {
84 selection: AutoStartConfigSelection::Files(files),
85 quiet,
86 cwd,
87 web_frontend: None,
88 web_port: None,
89 web_required: false,
90 }
91 }
92
93 #[must_use]
95 pub const fn with_web_port(mut self, port: u16) -> Self {
96 self.web_port = Some(port);
97 self.web_required = true;
98 self
99 }
100
101 #[must_use]
103 pub fn with_web_frontend(mut self, frontend: String) -> Self {
104 self.web_frontend = Some(frontend);
105 self.web_required = true;
106 self
107 }
108
109 #[must_use]
111 pub const fn with_web_required(mut self) -> Self {
112 self.web_required = true;
113 self
114 }
115
116 #[cfg(not(windows))]
117 #[cfg(not(any(unix, windows)))]
118 fn loads_startup_config(&self) -> bool {
119 !matches!(self.selection, AutoStartConfigSelection::Disabled)
120 }
121
122 fn append_hidden_daemon_args(&self, command: &mut Command) {
123 match &self.selection {
124 AutoStartConfigSelection::Disabled => {}
125 AutoStartConfigSelection::Default => {
126 command.arg("--config-default");
127 }
128 AutoStartConfigSelection::Files(files) => {
129 for file in files {
130 command.arg("--config-file").arg(file);
131 }
132 }
133 }
134
135 if self.quiet {
136 command.arg("--config-quiet");
137 }
138 if let Some(cwd) = &self.cwd {
139 command.arg("--config-cwd").arg(cwd);
140 }
141 if let Some(port) = self.web_port {
142 command.arg("--web-port").arg(port.to_string());
143 }
144 if let Some(frontend) = &self.web_frontend {
145 command.arg("--frontend-url").arg(frontend);
146 }
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum AutoStartConfigSelection {
153 Disabled,
155 Default,
157 Files(Vec<PathBuf>),
159}
160
161pub fn ensure_server_running(socket_path: &Path) -> Result<Connection, AutoStartError> {
168 ensure_server_running_with_config(socket_path, AutoStartConfig::disabled())
169}
170
171#[cfg(unix)]
173pub fn ensure_server_running_with_config(
174 socket_path: &Path,
175 config: AutoStartConfig,
176) -> Result<Connection, AutoStartError> {
177 ensure_server_running_unix(socket_path, config)
178}
179
180#[cfg(windows)]
182pub fn ensure_server_running_with_config(
183 socket_path: &Path,
184 config: AutoStartConfig,
185) -> Result<Connection, AutoStartError> {
186 ensure_server_running_windows(socket_path, config)
187}
188
189#[cfg(not(any(unix, windows)))]
191pub fn ensure_server_running_with_config(
192 socket_path: &Path,
193 config: AutoStartConfig,
194) -> Result<Connection, AutoStartError> {
195 ensure_server_running_polling(socket_path, config)
196}
197
198#[cfg(unix)]
199fn ensure_server_running_unix(
200 socket_path: &Path,
201 config: AutoStartConfig,
202) -> Result<Connection, AutoStartError> {
203 let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
204 let launcher_binary_path = binary_path.clone();
205 let launcher_socket_path = socket_path.to_path_buf();
206 let launcher_config = config.clone();
207
208 let runtime = tokio::runtime::Builder::new_current_thread()
209 .enable_all()
210 .build()
211 .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
212 let outcome = runtime.block_on(connect_or_start_with(
213 socket_path,
214 move || async move {
215 spawn_hidden_daemon_for(
216 &launcher_binary_path,
217 &launcher_socket_path,
218 &launcher_config,
219 )
220 },
221 DEFAULT_STARTUP_DEADLINE,
222 STARTUP_POLL_INTERVAL,
223 ));
224
225 let connection = startup_outcome_into_connection(
226 outcome.map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?,
227 )?;
228
229 let connection = probe_connected_server(connection, &config, socket_path)?;
230 upgrade_restart::ensure_daemon_fresh_or_restart(connection, socket_path, &binary_path, &config)
231}
232
233#[cfg(unix)]
234fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
235 let stream = outcome
236 .into_stream()
237 .into_std()
238 .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
239 stream
240 .set_nonblocking(false)
241 .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
242 Connection::new(stream).map_err(AutoStartError::Client)
243}
244
245#[cfg(windows)]
246fn ensure_server_running_windows(
247 socket_path: &Path,
248 config: AutoStartConfig,
249) -> Result<Connection, AutoStartError> {
250 let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
251 let launcher_binary_path = binary_path.clone();
252 let launcher_socket_path = socket_path.to_path_buf();
253 let launcher_config = config.clone();
254
255 let runtime = tokio::runtime::Builder::new_current_thread()
256 .enable_all()
257 .build()
258 .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
259 let outcome = runtime.block_on(connect_or_start_with(
260 socket_path,
261 move || async move {
262 spawn_hidden_daemon_for(
263 &launcher_binary_path,
264 &launcher_socket_path,
265 &launcher_config,
266 )
267 },
268 DEFAULT_STARTUP_DEADLINE,
269 STARTUP_POLL_INTERVAL,
270 ));
271
272 let connection = startup_outcome_into_connection(
273 outcome.map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?,
274 )?;
275 let connection = probe_connected_server(connection, &config, socket_path)?;
276 upgrade_restart::ensure_daemon_fresh_or_restart(connection, socket_path, &binary_path, &config)
277}
278
279#[cfg(windows)]
280fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
281 Connection::new(outcome.into_stream()).map_err(AutoStartError::Client)
282}
283
284fn probe_connected_server(
285 mut connection: Connection,
286 _config: &AutoStartConfig,
287 _socket_path: &Path,
288) -> Result<Connection, AutoStartError> {
289 let deadline = Instant::now() + DEFAULT_STARTUP_DEADLINE;
290 loop {
291 match probe_server_readiness(&mut connection) {
292 Ok(()) => return Ok(connection),
293 Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
294 let remaining = deadline.saturating_duration_since(Instant::now());
295 std::thread::sleep(STARTUP_POLL_INTERVAL.min(remaining));
296 }
297 Err(error) => return Err(AutoStartError::Client(error)),
298 }
299 }
300}
301
302#[cfg(unix)]
303fn auto_start_error_from_startup(
304 error: StartupError,
305 binary_path: &Path,
306 socket_path: &Path,
307) -> AutoStartError {
308 match error {
309 StartupError::Launcher { source } => AutoStartError::Launch {
310 path: binary_path.to_path_buf(),
311 error: source,
312 },
313 StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
314 socket_path: socket_path.to_path_buf(),
315 waited,
316 },
317 error => AutoStartError::Client(ClientError::Io(io::Error::new(
318 startup_error_kind(&error),
319 error.to_string(),
320 ))),
321 }
322}
323
324#[cfg(windows)]
325fn auto_start_error_from_startup(
326 error: StartupError,
327 binary_path: &Path,
328 socket_path: &Path,
329) -> AutoStartError {
330 match error {
331 StartupError::Launcher { source } => AutoStartError::Launch {
332 path: binary_path.to_path_buf(),
333 error: source,
334 },
335 StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
336 socket_path: socket_path.to_path_buf(),
337 waited,
338 },
339 error => AutoStartError::Client(ClientError::Io(io::Error::new(
340 startup_error_kind(&error),
341 error.to_string(),
342 ))),
343 }
344}
345
346#[cfg(unix)]
347fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
348 match error {
349 StartupError::InvalidPath { .. } | StartupError::SymlinkRejected { .. } => {
350 io::ErrorKind::InvalidInput
351 }
352 StartupError::UnsafeOwner { .. }
353 | StartupError::UnsafePermissions { .. }
354 | StartupError::PeerCredentialMismatch { .. } => io::ErrorKind::PermissionDenied,
355 StartupError::Lock { source, .. } | StartupError::Filesystem { source, .. } => {
356 source.kind()
357 }
358 StartupError::Launcher { source } => source.kind(),
359 StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
360 }
361}
362
363#[cfg(windows)]
364fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
365 match error {
366 StartupError::InvalidPipeName { .. } | StartupError::InvalidMutexName { .. } => {
367 io::ErrorKind::InvalidInput
368 }
369 StartupError::MutexAccessDenied { .. } | StartupError::PipeAccessDenied { .. } => {
370 io::ErrorKind::PermissionDenied
371 }
372 StartupError::MutexTimeout { .. }
373 | StartupError::PipeBusy { .. }
374 | StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
375 StartupError::PipeNotFound { .. } | StartupError::PipeNoData { .. } => {
376 io::ErrorKind::NotFound
377 }
378 StartupError::Mutex { source, .. } | StartupError::PipeIo { source, .. } => source.kind(),
379 StartupError::Launcher { source } => source.kind(),
380 }
381}
382
383#[cfg(not(any(unix, windows)))]
384fn ensure_server_running_polling(
385 socket_path: &Path,
386 config: AutoStartConfig,
387) -> Result<Connection, AutoStartError> {
388 if config.loads_startup_config() {
389 return ensure_server_running_with_probe(
390 socket_path,
391 AUTO_START_TIMEOUT,
392 POLL_INTERVAL,
393 || crate::connect_or_absent(socket_path),
394 || launch_hidden_daemon(socket_path, &config),
395 |_| Ok(()),
396 );
397 }
398
399 ensure_server_running_with(
400 socket_path,
401 AUTO_START_TIMEOUT,
402 POLL_INTERVAL,
403 || crate::connect_or_absent(socket_path),
404 || launch_hidden_daemon(socket_path, &config),
405 )
406}
407
408#[derive(Debug)]
410pub enum AutoStartError {
411 Client(ClientError),
413 BinaryPath(io::Error),
415 Launch {
417 path: PathBuf,
419 error: io::Error,
421 },
422 IncompatibleDaemon {
424 socket_path: PathBuf,
426 message: String,
428 },
429 TimedOut {
431 socket_path: PathBuf,
433 waited: Duration,
435 },
436}
437
438impl fmt::Display for AutoStartError {
439 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
440 match self {
441 Self::Client(error) => write!(formatter, "{error}"),
442 Self::BinaryPath(error) => {
443 write!(formatter, "failed to resolve rmux binary path: {error}")
444 }
445 Self::Launch { path, error } => {
446 write!(
447 formatter,
448 "failed to launch hidden rmux daemon '{}': {error}",
449 path.display()
450 )
451 }
452 Self::IncompatibleDaemon {
453 socket_path,
454 message,
455 } => write!(
456 formatter,
457 "{message} on '{}'; detach existing clients, then run `rmux -S {} kill-server` before retrying",
458 socket_path.display(),
459 shell_quote_path(socket_path)
460 ),
461 Self::TimedOut {
462 socket_path,
463 waited,
464 } => write!(
465 formatter,
466 "timed out after {}s waiting for rmux server socket '{}'",
467 waited.as_secs(),
468 socket_path.display()
469 ),
470 }
471 }
472}
473
474impl std::error::Error for AutoStartError {
475 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
476 match self {
477 Self::Client(error) => Some(error),
478 Self::BinaryPath(error) => Some(error),
479 Self::Launch { error, .. } => Some(error),
480 Self::IncompatibleDaemon { .. } => None,
481 Self::TimedOut { .. } => None,
482 }
483 }
484}
485
486impl From<ClientError> for AutoStartError {
487 fn from(error: ClientError) -> Self {
488 Self::Client(error)
489 }
490}
491
492#[cfg(not(any(unix, windows)))]
493fn ensure_server_running_with<ConnectFn, LaunchFn>(
494 socket_path: &Path,
495 timeout: Duration,
496 poll_interval: Duration,
497 connect: ConnectFn,
498 launch: LaunchFn,
499) -> Result<Connection, AutoStartError>
500where
501 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
502 LaunchFn: FnMut() -> Result<(), AutoStartError>,
503{
504 ensure_server_running_with_probe(
505 socket_path,
506 timeout,
507 poll_interval,
508 connect,
509 launch,
510 probe_server_readiness,
511 )
512}
513
514#[cfg(any(all(test, unix), not(any(unix, windows))))]
515fn ensure_server_running_with_probe<ConnectFn, LaunchFn, ProbeFn>(
516 socket_path: &Path,
517 timeout: Duration,
518 poll_interval: Duration,
519 mut connect: ConnectFn,
520 mut launch: LaunchFn,
521 mut probe: ProbeFn,
522) -> Result<Connection, AutoStartError>
523where
524 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
525 LaunchFn: FnMut() -> Result<(), AutoStartError>,
526 ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
527{
528 match connect().map_err(AutoStartError::Client)? {
529 ConnectResult::Connected(mut connection) => {
530 probe(&mut connection).map_err(AutoStartError::Client)?;
531 return Ok(connection);
532 }
533 ConnectResult::Absent => {}
534 }
535
536 launch()?;
537 wait_for_server(
538 socket_path,
539 timeout,
540 poll_interval,
541 &mut connect,
542 &mut probe,
543 )
544}
545
546#[cfg(any(all(test, unix), not(any(unix, windows))))]
547fn wait_for_server<ConnectFn, ProbeFn>(
548 socket_path: &Path,
549 timeout: Duration,
550 poll_interval: Duration,
551 connect: &mut ConnectFn,
552 probe: &mut ProbeFn,
553) -> Result<Connection, AutoStartError>
554where
555 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
556 ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
557{
558 let start = Instant::now();
559 let deadline = start + timeout;
560
561 loop {
562 match connect() {
563 Ok(crate::ConnectResult::Connected(mut connection)) => match probe(&mut connection) {
564 Ok(()) => return Ok(connection),
565 Err(error) if is_transient_connect_error(&error) => {}
566 Err(error) => return Err(AutoStartError::Client(error)),
567 },
568 Ok(crate::ConnectResult::Absent) => {}
569 Err(error) if is_transient_connect_error(&error) => {}
570 Err(error) => return Err(AutoStartError::Client(error)),
571 }
572
573 let now = Instant::now();
574 if now >= deadline {
575 return Err(AutoStartError::TimedOut {
576 socket_path: socket_path.to_path_buf(),
577 waited: timeout,
578 });
579 }
580
581 std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
582 }
583}
584
585fn is_transient_connect_error(error: &ClientError) -> bool {
586 matches!(
587 error,
588 ClientError::Io(io_error)
589 if matches!(
590 io_error.kind(),
591 io::ErrorKind::WouldBlock
592 | io::ErrorKind::Interrupted
593 | io::ErrorKind::TimedOut
594 )
595 )
596}
597
598fn probe_server_readiness(connection: &mut Connection) -> Result<(), ClientError> {
599 let response = connection.daemon_status()?;
600 match response {
601 Response::DaemonStatus(status) if status.config_loading => {
602 Err(ClientError::Io(io::Error::new(
603 io::ErrorKind::WouldBlock,
604 "daemon is still loading startup config",
605 )))
606 }
607 Response::DaemonStatus(_) => Ok(()),
608 Response::Error(_) => Ok(()),
609 other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
610 format!("unexpected readiness response: {other:?}"),
611 ))),
612 }
613}
614
615#[cfg(not(any(unix, windows)))]
616fn launch_hidden_daemon(
617 socket_path: &Path,
618 config: &AutoStartConfig,
619) -> Result<(), AutoStartError> {
620 let binary_path = rmux_binary_path(config).map_err(AutoStartError::BinaryPath)?;
621 spawn_hidden_daemon_for(&binary_path, socket_path, config).map_err(|error| {
622 AutoStartError::Launch {
623 path: binary_path,
624 error,
625 }
626 })
627}
628
629fn spawn_hidden_daemon_for(
630 binary_path: &Path,
631 socket_path: &Path,
632 config: &AutoStartConfig,
633) -> io::Result<()> {
634 let command = hidden_daemon_command(binary_path, socket_path, config, true);
635 match spawn_hidden_daemon(command) {
636 Ok(()) => Ok(()),
637 Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
638 let command = hidden_daemon_command(binary_path, socket_path, config, false);
639 spawn_hidden_daemon(command)
640 }
641 Err(error) => Err(error),
642 }
643}
644
645fn hidden_daemon_command(
646 binary_path: &Path,
647 socket_path: &Path,
648 config: &AutoStartConfig,
649 allow_job_breakaway: bool,
650) -> Command {
651 let mut command = Command::new(binary_path);
652 command
653 .arg(INTERNAL_DAEMON_FLAG)
654 .arg(socket_path)
655 .stdin(Stdio::null())
656 .stdout(Stdio::null())
657 .stderr(Stdio::null());
658 config.append_hidden_daemon_args(&mut command);
659 rmux_os::daemon::configure_hidden_daemon_command(&mut command, allow_job_breakaway);
660 command
661}
662
663fn spawn_hidden_daemon(mut command: Command) -> io::Result<()> {
664 let child = rmux_os::daemon::spawn_hidden_daemon_command(&mut command)?;
665 drop(child);
668 Ok(())
669}
670
671fn rmux_binary_path(config: &AutoStartConfig) -> io::Result<PathBuf> {
672 let current_exe = env::current_exe()?;
673 match env::var_os(BINARY_OVERRIDE_ENV).filter(|_| binary_override_enabled_for_tests()) {
674 Some(path) => Ok(PathBuf::from(path)),
675 None => {
676 Ok(hidden_daemon_binary_path_for_config(¤t_exe, config).unwrap_or(current_exe))
677 }
678 }
679}
680
681fn binary_override_enabled_for_tests() -> bool {
682 cfg!(debug_assertions)
683 && env::var_os(BINARY_OVERRIDE_TEST_OPT_IN_ENV).is_some_and(|value| value == "1")
684}
685
686#[cfg(all(test, unix))]
687fn hidden_daemon_binary_path(current_exe: &Path) -> Option<PathBuf> {
688 hidden_daemon_binary_path_for_config(current_exe, &AutoStartConfig::disabled())
689}
690
691fn hidden_daemon_binary_path_for_config(
692 current_exe: &Path,
693 config: &AutoStartConfig,
694) -> Option<PathBuf> {
695 if config.web_required {
696 return None;
697 }
698 let file_stem = current_exe.file_stem()?.to_str()?;
699 if file_stem == "rmux-daemon" {
700 return None;
701 }
702
703 let mut candidate = current_exe.to_path_buf();
704 let daemon_file_name = match current_exe
705 .extension()
706 .and_then(|extension| extension.to_str())
707 {
708 Some(extension) if !extension.is_empty() => format!("rmux-daemon.{extension}"),
709 _ => "rmux-daemon".to_owned(),
710 };
711 candidate.set_file_name(daemon_file_name);
712 candidate.is_file().then_some(candidate)
713}
714
715#[cfg(all(test, unix))]
716#[path = "auto_start/tests.rs"]
717mod tests;