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