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, RmuxError};
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::{default_socket_path, upgrade, 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(ClientError::Protocol(RmuxError::UnsupportedWireVersion { got, .. })) => {
294 return Err(AutoStartError::IncompatibleDaemon {
295 socket_path: socket_path.to_path_buf(),
296 message: upgrade::incompatible_daemon_message(&upgrade::IncompatibleDaemon {
297 daemon_version: None,
298 daemon_wire_version: Some(got),
299 }),
300 });
301 }
302 Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
303 let remaining = deadline.saturating_duration_since(Instant::now());
304 std::thread::sleep(STARTUP_POLL_INTERVAL.min(remaining));
305 }
306 Err(error) => return Err(AutoStartError::Client(error)),
307 }
308 }
309}
310
311#[cfg(unix)]
312fn auto_start_error_from_startup(
313 error: StartupError,
314 binary_path: &Path,
315 socket_path: &Path,
316) -> AutoStartError {
317 match error {
318 StartupError::Launcher { source } => AutoStartError::Launch {
319 path: binary_path.to_path_buf(),
320 error: source,
321 },
322 StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
323 socket_path: socket_path.to_path_buf(),
324 waited,
325 },
326 error => AutoStartError::Client(ClientError::Io(io::Error::new(
327 startup_error_kind(&error),
328 error.to_string(),
329 ))),
330 }
331}
332
333#[cfg(windows)]
334fn auto_start_error_from_startup(
335 error: StartupError,
336 binary_path: &Path,
337 socket_path: &Path,
338) -> AutoStartError {
339 match error {
340 StartupError::Launcher { source } => AutoStartError::Launch {
341 path: binary_path.to_path_buf(),
342 error: source,
343 },
344 StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
345 socket_path: socket_path.to_path_buf(),
346 waited,
347 },
348 error => AutoStartError::Client(ClientError::Io(io::Error::new(
349 startup_error_kind(&error),
350 error.to_string(),
351 ))),
352 }
353}
354
355#[cfg(unix)]
356fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
357 match error {
358 StartupError::InvalidPath { .. } | StartupError::SymlinkRejected { .. } => {
359 io::ErrorKind::InvalidInput
360 }
361 StartupError::UnsafeOwner { .. }
362 | StartupError::UnsafePermissions { .. }
363 | StartupError::PeerCredentialMismatch { .. } => io::ErrorKind::PermissionDenied,
364 StartupError::Lock { source, .. } | StartupError::Filesystem { source, .. } => {
365 source.kind()
366 }
367 StartupError::Launcher { source } => source.kind(),
368 StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
369 }
370}
371
372#[cfg(windows)]
373fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
374 match error {
375 StartupError::InvalidPipeName { .. } | StartupError::InvalidMutexName { .. } => {
376 io::ErrorKind::InvalidInput
377 }
378 StartupError::MutexAccessDenied { .. } | StartupError::PipeAccessDenied { .. } => {
379 io::ErrorKind::PermissionDenied
380 }
381 StartupError::MutexTimeout { .. }
382 | StartupError::PipeBusy { .. }
383 | StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
384 StartupError::PipeNotFound { .. } | StartupError::PipeNoData { .. } => {
385 io::ErrorKind::NotFound
386 }
387 StartupError::Mutex { source, .. } | StartupError::PipeIo { source, .. } => source.kind(),
388 StartupError::Launcher { source } => source.kind(),
389 }
390}
391
392#[cfg(not(any(unix, windows)))]
393fn ensure_server_running_polling(
394 socket_path: &Path,
395 config: AutoStartConfig,
396) -> Result<Connection, AutoStartError> {
397 if config.loads_startup_config() {
398 return ensure_server_running_with_probe(
399 socket_path,
400 AUTO_START_TIMEOUT,
401 POLL_INTERVAL,
402 || crate::connect_or_absent(socket_path),
403 || launch_hidden_daemon(socket_path, &config),
404 |_| Ok(()),
405 );
406 }
407
408 ensure_server_running_with(
409 socket_path,
410 AUTO_START_TIMEOUT,
411 POLL_INTERVAL,
412 || crate::connect_or_absent(socket_path),
413 || launch_hidden_daemon(socket_path, &config),
414 )
415}
416
417#[derive(Debug)]
419pub enum AutoStartError {
420 Client(ClientError),
422 BinaryPath(io::Error),
424 Launch {
426 path: PathBuf,
428 error: io::Error,
430 },
431 IncompatibleDaemon {
433 socket_path: PathBuf,
435 message: String,
437 },
438 TimedOut {
440 socket_path: PathBuf,
442 waited: Duration,
444 },
445}
446
447impl fmt::Display for AutoStartError {
448 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449 match self {
450 Self::Client(error) => write!(formatter, "{error}"),
451 Self::BinaryPath(error) => {
452 write!(formatter, "failed to resolve rmux binary path: {error}")
453 }
454 Self::Launch { path, error } => {
455 write!(
456 formatter,
457 "failed to launch hidden rmux daemon '{}': {error}",
458 path.display()
459 )
460 }
461 Self::IncompatibleDaemon {
462 socket_path,
463 message,
464 } => write!(
465 formatter,
466 "rmux: {message} on '{}'.\nrmux: run `{}` to stop it, then retry.",
467 socket_path.display(),
468 incompatible_daemon_kill_server_command(socket_path)
469 ),
470 Self::TimedOut {
471 socket_path,
472 waited,
473 } => write!(
474 formatter,
475 "timed out after {}s waiting for rmux server socket '{}'",
476 waited.as_secs(),
477 socket_path.display()
478 ),
479 }
480 }
481}
482
483impl std::error::Error for AutoStartError {
484 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
485 match self {
486 Self::Client(error) => Some(error),
487 Self::BinaryPath(error) => Some(error),
488 Self::Launch { error, .. } => Some(error),
489 Self::IncompatibleDaemon { .. } => None,
490 Self::TimedOut { .. } => None,
491 }
492 }
493}
494
495impl From<ClientError> for AutoStartError {
496 fn from(error: ClientError) -> Self {
497 Self::Client(error)
498 }
499}
500
501#[cfg(not(any(unix, windows)))]
502fn ensure_server_running_with<ConnectFn, LaunchFn>(
503 socket_path: &Path,
504 timeout: Duration,
505 poll_interval: Duration,
506 connect: ConnectFn,
507 launch: LaunchFn,
508) -> Result<Connection, AutoStartError>
509where
510 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
511 LaunchFn: FnMut() -> Result<(), AutoStartError>,
512{
513 ensure_server_running_with_probe(
514 socket_path,
515 timeout,
516 poll_interval,
517 connect,
518 launch,
519 probe_server_readiness,
520 )
521}
522
523#[cfg(any(all(test, unix), not(any(unix, windows))))]
524fn ensure_server_running_with_probe<ConnectFn, LaunchFn, ProbeFn>(
525 socket_path: &Path,
526 timeout: Duration,
527 poll_interval: Duration,
528 mut connect: ConnectFn,
529 mut launch: LaunchFn,
530 mut probe: ProbeFn,
531) -> Result<Connection, AutoStartError>
532where
533 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
534 LaunchFn: FnMut() -> Result<(), AutoStartError>,
535 ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
536{
537 match connect().map_err(AutoStartError::Client)? {
538 ConnectResult::Connected(mut connection) => {
539 probe(&mut connection).map_err(AutoStartError::Client)?;
540 return Ok(connection);
541 }
542 ConnectResult::Absent => {}
543 }
544
545 launch()?;
546 wait_for_server(
547 socket_path,
548 timeout,
549 poll_interval,
550 &mut connect,
551 &mut probe,
552 )
553}
554
555#[cfg(any(all(test, unix), not(any(unix, windows))))]
556fn wait_for_server<ConnectFn, ProbeFn>(
557 socket_path: &Path,
558 timeout: Duration,
559 poll_interval: Duration,
560 connect: &mut ConnectFn,
561 probe: &mut ProbeFn,
562) -> Result<Connection, AutoStartError>
563where
564 ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
565 ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
566{
567 let start = Instant::now();
568 let deadline = start + timeout;
569
570 loop {
571 match connect() {
572 Ok(crate::ConnectResult::Connected(mut connection)) => match probe(&mut connection) {
573 Ok(()) => return Ok(connection),
574 Err(error) if is_transient_connect_error(&error) => {}
575 Err(error) => return Err(AutoStartError::Client(error)),
576 },
577 Ok(crate::ConnectResult::Absent) => {}
578 Err(error) if is_transient_connect_error(&error) => {}
579 Err(error) => return Err(AutoStartError::Client(error)),
580 }
581
582 let now = Instant::now();
583 if now >= deadline {
584 return Err(AutoStartError::TimedOut {
585 socket_path: socket_path.to_path_buf(),
586 waited: timeout,
587 });
588 }
589
590 std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
591 }
592}
593
594fn is_transient_connect_error(error: &ClientError) -> bool {
595 matches!(
596 error,
597 ClientError::Io(io_error)
598 if matches!(
599 io_error.kind(),
600 io::ErrorKind::WouldBlock
601 | io::ErrorKind::Interrupted
602 | io::ErrorKind::TimedOut
603 )
604 )
605}
606
607fn incompatible_daemon_kill_server_command(socket_path: &Path) -> String {
608 if default_socket_path()
609 .ok()
610 .as_deref()
611 .is_some_and(|default_path| default_path == socket_path)
612 {
613 return "rmux kill-server".to_owned();
614 }
615
616 format!("rmux -S {} kill-server", shell_quote_path(socket_path))
617}
618
619fn probe_server_readiness(connection: &mut Connection) -> Result<(), ClientError> {
620 let response = connection.daemon_status()?;
621 match response {
622 Response::DaemonStatus(status) if status.config_loading => {
623 Err(ClientError::Io(io::Error::new(
624 io::ErrorKind::WouldBlock,
625 "daemon is still loading startup config",
626 )))
627 }
628 Response::DaemonStatus(_) => Ok(()),
629 Response::Error(_) => Ok(()),
630 other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
631 format!("unexpected readiness response: {other:?}"),
632 ))),
633 }
634}
635
636#[cfg(not(any(unix, windows)))]
637fn launch_hidden_daemon(
638 socket_path: &Path,
639 config: &AutoStartConfig,
640) -> Result<(), AutoStartError> {
641 let binary_path = rmux_binary_path(config).map_err(AutoStartError::BinaryPath)?;
642 spawn_hidden_daemon_for(&binary_path, socket_path, config).map_err(|error| {
643 AutoStartError::Launch {
644 path: binary_path,
645 error,
646 }
647 })
648}
649
650fn spawn_hidden_daemon_for(
651 binary_path: &Path,
652 socket_path: &Path,
653 config: &AutoStartConfig,
654) -> io::Result<()> {
655 let command = hidden_daemon_command(binary_path, socket_path, config, true);
656 match spawn_hidden_daemon(command) {
657 Ok(()) => Ok(()),
658 Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
659 let command = hidden_daemon_command(binary_path, socket_path, config, false);
660 spawn_hidden_daemon(command)
661 }
662 Err(error) => Err(error),
663 }
664}
665
666fn hidden_daemon_command(
667 binary_path: &Path,
668 socket_path: &Path,
669 config: &AutoStartConfig,
670 allow_job_breakaway: bool,
671) -> Command {
672 let mut command = Command::new(binary_path);
673 command
674 .arg(INTERNAL_DAEMON_FLAG)
675 .arg(socket_path)
676 .stdin(Stdio::null())
677 .stdout(Stdio::null())
678 .stderr(Stdio::null());
679 config.append_hidden_daemon_args(&mut command);
680 rmux_os::daemon::configure_hidden_daemon_command(&mut command, allow_job_breakaway);
681 command
682}
683
684fn spawn_hidden_daemon(mut command: Command) -> io::Result<()> {
685 let child = rmux_os::daemon::spawn_hidden_daemon_command(&mut command)?;
686 drop(child);
689 Ok(())
690}
691
692fn rmux_binary_path(config: &AutoStartConfig) -> io::Result<PathBuf> {
693 let current_exe = env::current_exe()?;
694 match env::var_os(BINARY_OVERRIDE_ENV).filter(|_| binary_override_enabled_for_tests()) {
695 Some(path) => Ok(PathBuf::from(path)),
696 None => {
697 Ok(hidden_daemon_binary_path_for_config(¤t_exe, config).unwrap_or(current_exe))
698 }
699 }
700}
701
702fn binary_override_enabled_for_tests() -> bool {
703 cfg!(debug_assertions)
704 && env::var_os(BINARY_OVERRIDE_TEST_OPT_IN_ENV).is_some_and(|value| value == "1")
705}
706
707#[cfg(all(test, unix))]
708fn hidden_daemon_binary_path(current_exe: &Path) -> Option<PathBuf> {
709 hidden_daemon_binary_path_for_config(current_exe, &AutoStartConfig::disabled())
710}
711
712fn hidden_daemon_binary_path_for_config(
713 current_exe: &Path,
714 config: &AutoStartConfig,
715) -> Option<PathBuf> {
716 if config.web_required {
717 return None;
718 }
719 let file_stem = current_exe.file_stem()?.to_str()?;
720 if file_stem == "rmux-daemon" {
721 return None;
722 }
723
724 let mut candidate = current_exe.to_path_buf();
725 let daemon_file_name = match current_exe
726 .extension()
727 .and_then(|extension| extension.to_str())
728 {
729 Some(extension) if !extension.is_empty() => format!("rmux-daemon.{extension}"),
730 _ => "rmux-daemon".to_owned(),
731 };
732 candidate.set_file_name(daemon_file_name);
733 candidate.is_file().then_some(candidate)
734}
735
736#[cfg(all(test, unix))]
737#[path = "auto_start/tests.rs"]
738mod tests;