Skip to main content

rmux_client/
auto_start.rs

1//! Hidden daemon auto-start support for tmux `CMD_STARTSERVER` commands.
2
3use std::env;
4use std::fmt;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::time::{Duration, Instant};
9#[cfg(target_os = "linux")]
10use std::{fs::File, io::Read, os::fd::AsRawFd};
11
12#[cfg(windows)]
13use rmux_proto::DaemonStatusResponse;
14use rmux_proto::{Response, RmuxError};
15#[cfg(unix)]
16use rmux_sdk::bootstrap::startup_unix::{
17    connect_or_start_with, StartupError, StartupOutcome, DEFAULT_STARTUP_DEADLINE,
18    STARTUP_POLL_INTERVAL,
19};
20#[cfg(windows)]
21use rmux_sdk::bootstrap::startup_windows::{
22    connect_or_start_blocking_with, StartupError, StartupOutcome, DEFAULT_STARTUP_DEADLINE,
23    STARTUP_POLL_INTERVAL,
24};
25
26use crate::shell_quote::shell_quote_path;
27#[cfg(any(all(test, unix), not(any(unix, windows))))]
28use crate::ConnectResult;
29use crate::{default_socket_path, upgrade, ClientError, Connection};
30
31mod upgrade_restart;
32
33#[cfg(target_os = "linux")]
34const STARTUP_READY_EVENT_TIMEOUT: Duration = Duration::from_millis(20);
35#[cfg(windows)]
36const STARTUP_READY_EVENT_TIMEOUT: Duration = Duration::from_secs(2);
37#[cfg(not(any(unix, windows)))]
38const AUTO_START_TIMEOUT: Duration = Duration::from_secs(5);
39#[cfg(not(any(unix, windows)))]
40const POLL_INTERVAL: Duration = Duration::from_millis(50);
41
42/// The undocumented CLI flag that switches `rmux` into hidden daemon mode.
43///
44/// This constant is shared with `src/main.rs` so both sides of the re-exec
45/// protocol stay in sync.
46pub const INTERNAL_DAEMON_FLAG: &str = "--__internal-daemon";
47
48const BINARY_OVERRIDE_ENV: &str = "RMUX_INTERNAL_BINARY_PATH";
49const BINARY_OVERRIDE_TEST_OPT_IN_ENV: &str = "RMUX_ALLOW_INTERNAL_BINARY_OVERRIDE";
50/// Config loading policy to pass to a newly auto-started hidden daemon.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct AutoStartConfig {
53    selection: AutoStartConfigSelection,
54    quiet: bool,
55    cwd: Option<PathBuf>,
56    web_frontend: Option<String>,
57    web_port: Option<u16>,
58    web_required: bool,
59    binary_override: Option<PathBuf>,
60}
61
62impl AutoStartConfig {
63    /// Builds a policy that leaves startup config loading disabled.
64    #[must_use]
65    pub const fn disabled() -> Self {
66        Self {
67            selection: AutoStartConfigSelection::Disabled,
68            quiet: true,
69            cwd: None,
70            web_frontend: None,
71            web_port: None,
72            web_required: false,
73            binary_override: None,
74        }
75    }
76
77    /// Builds a policy that loads RMUX's default startup config search path.
78    #[must_use]
79    pub fn default_files(quiet: bool, cwd: Option<PathBuf>) -> Self {
80        Self {
81            selection: AutoStartConfigSelection::Default,
82            quiet,
83            cwd,
84            web_frontend: None,
85            web_port: None,
86            web_required: false,
87            binary_override: None,
88        }
89    }
90
91    /// Builds a policy that loads the explicit top-level `-f` files.
92    #[must_use]
93    pub fn custom_files(files: Vec<PathBuf>, quiet: bool, cwd: Option<PathBuf>) -> Self {
94        Self {
95            selection: AutoStartConfigSelection::Files(files),
96            quiet,
97            cwd,
98            web_frontend: None,
99            web_port: None,
100            web_required: false,
101            binary_override: None,
102        }
103    }
104
105    /// Overrides the web-share listener port for a newly auto-started daemon.
106    #[must_use]
107    pub const fn with_web_port(mut self, port: u16) -> Self {
108        self.web_port = Some(port);
109        self.web_required = true;
110        self
111    }
112
113    /// Overrides the frontend origin used by newly auto-started web shares.
114    #[must_use]
115    pub fn with_web_frontend(mut self, frontend: String) -> Self {
116        self.web_frontend = Some(frontend);
117        self.web_required = true;
118        self
119    }
120
121    /// Requires a daemon compiled with web-share support for this autostart.
122    #[must_use]
123    pub const fn with_web_required(mut self) -> Self {
124        self.web_required = true;
125        self
126    }
127
128    /// Uses an explicit binary when this client must auto-start a hidden daemon.
129    #[must_use]
130    pub fn with_binary_override(mut self, binary_path: PathBuf) -> Self {
131        self.binary_override = Some(binary_path);
132        self
133    }
134
135    #[cfg(not(windows))]
136    #[cfg(not(any(unix, windows)))]
137    fn loads_startup_config(&self) -> bool {
138        !matches!(self.selection, AutoStartConfigSelection::Disabled)
139    }
140
141    fn append_hidden_daemon_args(&self, command: &mut Command) {
142        match &self.selection {
143            AutoStartConfigSelection::Disabled => {}
144            AutoStartConfigSelection::Default => {
145                command.arg("--config-default");
146            }
147            AutoStartConfigSelection::Files(files) => {
148                for file in files {
149                    command.arg("--config-file").arg(file);
150                }
151            }
152        }
153
154        if self.quiet {
155            command.arg("--config-quiet");
156        }
157        if let Some(cwd) = &self.cwd {
158            command.arg("--config-cwd").arg(cwd);
159        }
160        if let Some(port) = self.web_port {
161            command.arg("--web-port").arg(port.to_string());
162        }
163        if let Some(frontend) = &self.web_frontend {
164            command.arg("--frontend-url").arg(frontend);
165        }
166    }
167}
168
169/// Config file selection mode for a newly auto-started hidden daemon.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum AutoStartConfigSelection {
172    /// Do not load startup config files.
173    Disabled,
174    /// Load RMUX's default config search path.
175    Default,
176    /// Load these explicit config files in order.
177    Files(Vec<PathBuf>),
178}
179
180/// Ensures the RMUX server is reachable, auto-starting it when absent.
181///
182/// This boundary is reserved for command paths that match tmux's
183/// `CMD_STARTSERVER` startup inventory. Other command paths must keep using
184/// [`crate::connect`] or [`crate::connect_or_absent`] directly so they do not
185/// spawn a daemon as a side effect.
186pub fn ensure_server_running(socket_path: &Path) -> Result<Connection, AutoStartError> {
187    ensure_server_running_with_config(socket_path, AutoStartConfig::disabled())
188}
189
190/// Ensures the server is reachable, passing config load options if launched.
191#[cfg(unix)]
192pub fn ensure_server_running_with_config(
193    socket_path: &Path,
194    config: AutoStartConfig,
195) -> Result<Connection, AutoStartError> {
196    ensure_server_running_unix(socket_path, config)
197}
198
199/// Ensures the server is reachable, passing config load options if launched.
200#[cfg(windows)]
201pub fn ensure_server_running_with_config(
202    socket_path: &Path,
203    config: AutoStartConfig,
204) -> Result<Connection, AutoStartError> {
205    ensure_server_running_windows(socket_path, config)
206}
207
208/// Ensures the server is reachable, passing config load options if launched.
209#[cfg(not(any(unix, windows)))]
210pub fn ensure_server_running_with_config(
211    socket_path: &Path,
212    config: AutoStartConfig,
213) -> Result<Connection, AutoStartError> {
214    ensure_server_running_polling(socket_path, config)
215}
216
217#[cfg(unix)]
218fn ensure_server_running_unix(
219    socket_path: &Path,
220    config: AutoStartConfig,
221) -> Result<Connection, AutoStartError> {
222    let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
223    let launcher_binary_path = binary_path.clone();
224    let launcher_socket_path = socket_path.to_path_buf();
225    let launcher_config = config.clone();
226
227    let runtime = tokio::runtime::Builder::new_current_thread()
228        .enable_all()
229        .build()
230        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
231    let outcome = runtime.block_on(connect_or_start_with(
232        socket_path,
233        move || async move {
234            spawn_hidden_daemon_for(
235                &launcher_binary_path,
236                &launcher_socket_path,
237                &launcher_config,
238            )
239        },
240        DEFAULT_STARTUP_DEADLINE,
241        STARTUP_POLL_INTERVAL,
242    ));
243
244    let connection = startup_outcome_into_connection(
245        outcome.map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?,
246    )?;
247
248    let connection = probe_connected_server(connection, &config, socket_path)?;
249    upgrade_restart::ensure_daemon_fresh_or_restart(connection, socket_path, &binary_path, &config)
250}
251
252#[cfg(unix)]
253fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
254    let stream = outcome
255        .into_stream()
256        .into_std()
257        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
258    stream
259        .set_nonblocking(false)
260        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
261    Connection::new(stream).map_err(AutoStartError::Client)
262}
263
264#[cfg(windows)]
265fn ensure_server_running_windows(
266    socket_path: &Path,
267    config: AutoStartConfig,
268) -> Result<Connection, AutoStartError> {
269    let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
270    let launcher_binary_path = binary_path.clone();
271    let launcher_socket_path = socket_path.to_path_buf();
272    let launcher_config = config.clone();
273
274    let outcome = connect_or_start_blocking_with(
275        socket_path,
276        move || {
277            spawn_hidden_daemon_for(
278                &launcher_binary_path,
279                &launcher_socket_path,
280                &launcher_config,
281            )
282        },
283        DEFAULT_STARTUP_DEADLINE,
284        STARTUP_POLL_INTERVAL,
285    );
286
287    let connection = startup_outcome_into_connection(
288        outcome.map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?,
289    )?;
290    let (connection, readiness_status) =
291        probe_connected_server_windows(connection, &config, socket_path)?;
292    upgrade_restart::ensure_daemon_fresh_or_restart_after_windows_readiness(
293        connection,
294        socket_path,
295        &binary_path,
296        &config,
297        readiness_status,
298    )
299}
300
301#[cfg(windows)]
302fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
303    Connection::new(outcome.into_stream()).map_err(AutoStartError::Client)
304}
305
306#[cfg(windows)]
307fn probe_connected_server_windows(
308    mut connection: Connection,
309    _config: &AutoStartConfig,
310    socket_path: &Path,
311) -> Result<(Connection, Option<DaemonStatusResponse>), AutoStartError> {
312    let deadline = Instant::now() + DEFAULT_STARTUP_DEADLINE;
313    let mut poll_attempt = 0_u32;
314    loop {
315        match probe_server_readiness_status(&mut connection) {
316            Ok(status) => return Ok((connection, status)),
317            Err(ClientError::Protocol(RmuxError::UnsupportedWireVersion { got, .. })) => {
318                return Err(AutoStartError::IncompatibleDaemon {
319                    socket_path: socket_path.to_path_buf(),
320                    message: upgrade::incompatible_daemon_message(&upgrade::IncompatibleDaemon {
321                        daemon_version: None,
322                        daemon_wire_version: Some(got),
323                    }),
324                });
325            }
326            Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
327                let remaining = deadline.saturating_duration_since(Instant::now());
328                std::thread::sleep(startup_readiness_poll_sleep(&mut poll_attempt, remaining));
329            }
330            Err(error) => return Err(AutoStartError::Client(error)),
331        }
332    }
333}
334
335#[cfg(windows)]
336fn probe_server_readiness_status(
337    connection: &mut Connection,
338) -> Result<Option<DaemonStatusResponse>, ClientError> {
339    let response = connection.daemon_status()?;
340    match response {
341        Response::DaemonStatus(status) if status.config_loading => {
342            Err(ClientError::Io(io::Error::new(
343                io::ErrorKind::WouldBlock,
344                "daemon is still loading startup config",
345            )))
346        }
347        Response::DaemonStatus(status) => Ok(Some(status)),
348        Response::Error(_) => Ok(None),
349        other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
350            format!("unexpected readiness response: {other:?}"),
351        ))),
352    }
353}
354
355fn probe_connected_server(
356    mut connection: Connection,
357    _config: &AutoStartConfig,
358    socket_path: &Path,
359) -> Result<Connection, AutoStartError> {
360    let deadline = Instant::now() + DEFAULT_STARTUP_DEADLINE;
361    let mut poll_attempt = 0_u32;
362    loop {
363        match probe_server_readiness(&mut connection) {
364            Ok(()) => return Ok(connection),
365            Err(ClientError::Protocol(RmuxError::UnsupportedWireVersion { got, .. })) => {
366                return Err(AutoStartError::IncompatibleDaemon {
367                    socket_path: socket_path.to_path_buf(),
368                    message: upgrade::incompatible_daemon_message(&upgrade::IncompatibleDaemon {
369                        daemon_version: None,
370                        daemon_wire_version: Some(got),
371                    }),
372                });
373            }
374            Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
375                let remaining = deadline.saturating_duration_since(Instant::now());
376                std::thread::sleep(startup_readiness_poll_sleep(&mut poll_attempt, remaining));
377            }
378            Err(error) => return Err(AutoStartError::Client(error)),
379        }
380    }
381}
382
383fn startup_readiness_poll_sleep(poll_attempt: &mut u32, remaining: Duration) -> Duration {
384    #[cfg(windows)]
385    {
386        const INITIAL_POLL_MILLIS: u64 = 1;
387
388        let shift = (*poll_attempt).min(6);
389        *poll_attempt = (*poll_attempt).saturating_add(1);
390        let millis = INITIAL_POLL_MILLIS
391            .checked_shl(shift)
392            .unwrap_or(u64::MAX)
393            .min(STARTUP_POLL_INTERVAL.as_millis() as u64);
394        Duration::from_millis(millis).min(remaining)
395    }
396
397    #[cfg(not(windows))]
398    {
399        const INITIAL_POLL_MILLIS: u64 = 1;
400
401        let shift = (*poll_attempt).min(6);
402        *poll_attempt = (*poll_attempt).saturating_add(1);
403        let millis = INITIAL_POLL_MILLIS
404            .checked_shl(shift)
405            .unwrap_or(u64::MAX)
406            .min(STARTUP_POLL_INTERVAL.as_millis() as u64);
407        Duration::from_millis(millis).min(remaining)
408    }
409}
410
411#[cfg(unix)]
412fn auto_start_error_from_startup(
413    error: StartupError,
414    binary_path: &Path,
415    socket_path: &Path,
416) -> AutoStartError {
417    match error {
418        StartupError::Launcher { source } => AutoStartError::Launch {
419            path: binary_path.to_path_buf(),
420            error: source,
421        },
422        StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
423            socket_path: socket_path.to_path_buf(),
424            waited,
425        },
426        error => AutoStartError::Client(ClientError::Io(io::Error::new(
427            startup_error_kind(&error),
428            error.to_string(),
429        ))),
430    }
431}
432
433#[cfg(windows)]
434fn auto_start_error_from_startup(
435    error: StartupError,
436    binary_path: &Path,
437    socket_path: &Path,
438) -> AutoStartError {
439    match error {
440        StartupError::Launcher { source } => AutoStartError::Launch {
441            path: binary_path.to_path_buf(),
442            error: source,
443        },
444        StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
445            socket_path: socket_path.to_path_buf(),
446            waited,
447        },
448        error => AutoStartError::Client(ClientError::Io(io::Error::new(
449            startup_error_kind(&error),
450            error.to_string(),
451        ))),
452    }
453}
454
455#[cfg(unix)]
456fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
457    match error {
458        StartupError::InvalidPath { .. } | StartupError::SymlinkRejected { .. } => {
459            io::ErrorKind::InvalidInput
460        }
461        StartupError::UnsafeOwner { .. }
462        | StartupError::UnsafePermissions { .. }
463        | StartupError::PeerCredentialMismatch { .. } => io::ErrorKind::PermissionDenied,
464        StartupError::Lock { source, .. } | StartupError::Filesystem { source, .. } => {
465            source.kind()
466        }
467        StartupError::Launcher { source } => source.kind(),
468        StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
469    }
470}
471
472#[cfg(windows)]
473fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
474    match error {
475        StartupError::InvalidPipeName { .. } | StartupError::InvalidMutexName { .. } => {
476            io::ErrorKind::InvalidInput
477        }
478        StartupError::MutexAccessDenied { .. } | StartupError::PipeAccessDenied { .. } => {
479            io::ErrorKind::PermissionDenied
480        }
481        StartupError::MutexTimeout { .. }
482        | StartupError::PipeBusy { .. }
483        | StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
484        StartupError::PipeNotFound { .. } | StartupError::PipeNoData { .. } => {
485            io::ErrorKind::NotFound
486        }
487        StartupError::Mutex { source, .. } | StartupError::PipeIo { source, .. } => source.kind(),
488        StartupError::Launcher { source } => source.kind(),
489    }
490}
491
492#[cfg(not(any(unix, windows)))]
493fn ensure_server_running_polling(
494    socket_path: &Path,
495    config: AutoStartConfig,
496) -> Result<Connection, AutoStartError> {
497    if config.loads_startup_config() {
498        return ensure_server_running_with_probe(
499            socket_path,
500            AUTO_START_TIMEOUT,
501            POLL_INTERVAL,
502            || crate::connect_or_absent(socket_path),
503            || launch_hidden_daemon(socket_path, &config),
504            |_| Ok(()),
505        );
506    }
507
508    ensure_server_running_with(
509        socket_path,
510        AUTO_START_TIMEOUT,
511        POLL_INTERVAL,
512        || crate::connect_or_absent(socket_path),
513        || launch_hidden_daemon(socket_path, &config),
514    )
515}
516
517/// Errors raised while auto-starting or connecting to the RMUX server.
518#[derive(Debug)]
519pub enum AutoStartError {
520    /// The client transport failed before or during readiness polling.
521    Client(ClientError),
522    /// Resolving the `rmux` binary path failed.
523    BinaryPath(io::Error),
524    /// Re-executing the hidden daemon process failed.
525    Launch {
526        /// The binary path that failed to spawn.
527        path: PathBuf,
528        /// The underlying process-spawn error.
529        error: io::Error,
530    },
531    /// A running daemon speaks an incompatible protocol version.
532    IncompatibleDaemon {
533        /// The socket path hosting the incompatible daemon.
534        socket_path: PathBuf,
535        /// Human-readable protocol mismatch detail.
536        message: String,
537    },
538    /// The socket never became reachable before the readiness deadline.
539    TimedOut {
540        /// The socket path that never became reachable.
541        socket_path: PathBuf,
542        /// The amount of time spent polling.
543        waited: Duration,
544    },
545}
546
547impl fmt::Display for AutoStartError {
548    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
549        match self {
550            Self::Client(error) => write!(formatter, "{error}"),
551            Self::BinaryPath(error) => {
552                write!(formatter, "failed to resolve rmux binary path: {error}")
553            }
554            Self::Launch { path, error } => {
555                write!(
556                    formatter,
557                    "failed to launch hidden rmux daemon '{}': {error}",
558                    path.display()
559                )
560            }
561            Self::IncompatibleDaemon {
562                socket_path,
563                message,
564            } => write!(
565                formatter,
566                "rmux: {message} on '{}'.\nrmux: run `{}` to stop it, then retry.",
567                socket_path.display(),
568                incompatible_daemon_kill_server_command(socket_path)
569            ),
570            Self::TimedOut {
571                socket_path,
572                waited,
573            } => write!(
574                formatter,
575                "timed out after {}s waiting for rmux server socket '{}'. \
576                 The hidden daemon may have exited before creating the socket; run `{}` to surface startup errors.",
577                waited.as_secs(),
578                socket_path.display(),
579                diagnostic_start_server_command(socket_path)
580            ),
581        }
582    }
583}
584
585impl std::error::Error for AutoStartError {
586    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
587        match self {
588            Self::Client(error) => Some(error),
589            Self::BinaryPath(error) => Some(error),
590            Self::Launch { error, .. } => Some(error),
591            Self::IncompatibleDaemon { .. } => None,
592            Self::TimedOut { .. } => None,
593        }
594    }
595}
596
597impl From<ClientError> for AutoStartError {
598    fn from(error: ClientError) -> Self {
599        Self::Client(error)
600    }
601}
602
603#[cfg(not(any(unix, windows)))]
604fn ensure_server_running_with<ConnectFn, LaunchFn>(
605    socket_path: &Path,
606    timeout: Duration,
607    poll_interval: Duration,
608    connect: ConnectFn,
609    launch: LaunchFn,
610) -> Result<Connection, AutoStartError>
611where
612    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
613    LaunchFn: FnMut() -> Result<(), AutoStartError>,
614{
615    ensure_server_running_with_probe(
616        socket_path,
617        timeout,
618        poll_interval,
619        connect,
620        launch,
621        probe_server_readiness,
622    )
623}
624
625#[cfg(any(all(test, unix), not(any(unix, windows))))]
626fn ensure_server_running_with_probe<ConnectFn, LaunchFn, ProbeFn>(
627    socket_path: &Path,
628    timeout: Duration,
629    poll_interval: Duration,
630    mut connect: ConnectFn,
631    mut launch: LaunchFn,
632    mut probe: ProbeFn,
633) -> Result<Connection, AutoStartError>
634where
635    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
636    LaunchFn: FnMut() -> Result<(), AutoStartError>,
637    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
638{
639    match connect().map_err(AutoStartError::Client)? {
640        ConnectResult::Connected(mut connection) => {
641            probe(&mut connection).map_err(AutoStartError::Client)?;
642            return Ok(connection);
643        }
644        ConnectResult::Absent => {}
645    }
646
647    launch()?;
648    wait_for_server(
649        socket_path,
650        timeout,
651        poll_interval,
652        &mut connect,
653        &mut probe,
654    )
655}
656
657#[cfg(any(all(test, unix), not(any(unix, windows))))]
658fn wait_for_server<ConnectFn, ProbeFn>(
659    socket_path: &Path,
660    timeout: Duration,
661    poll_interval: Duration,
662    connect: &mut ConnectFn,
663    probe: &mut ProbeFn,
664) -> Result<Connection, AutoStartError>
665where
666    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
667    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
668{
669    let start = Instant::now();
670    let deadline = start + timeout;
671
672    loop {
673        match connect() {
674            Ok(crate::ConnectResult::Connected(mut connection)) => match probe(&mut connection) {
675                Ok(()) => return Ok(connection),
676                Err(error) if is_transient_connect_error(&error) => {}
677                Err(error) => return Err(AutoStartError::Client(error)),
678            },
679            Ok(crate::ConnectResult::Absent) => {}
680            Err(error) if is_transient_connect_error(&error) => {}
681            Err(error) => return Err(AutoStartError::Client(error)),
682        }
683
684        let now = Instant::now();
685        if now >= deadline {
686            return Err(AutoStartError::TimedOut {
687                socket_path: socket_path.to_path_buf(),
688                waited: timeout,
689            });
690        }
691
692        std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
693    }
694}
695
696fn is_transient_connect_error(error: &ClientError) -> bool {
697    matches!(
698        error,
699        ClientError::Io(io_error)
700            if matches!(
701                io_error.kind(),
702                io::ErrorKind::WouldBlock
703                    | io::ErrorKind::Interrupted
704                    | io::ErrorKind::TimedOut
705            )
706    )
707}
708
709fn incompatible_daemon_kill_server_command(socket_path: &Path) -> String {
710    if default_socket_path()
711        .ok()
712        .as_deref()
713        .is_some_and(|default_path| default_path == socket_path)
714    {
715        return "rmux kill-server".to_owned();
716    }
717
718    format!("rmux -S {} kill-server", shell_quote_path(socket_path))
719}
720
721fn diagnostic_start_server_command(socket_path: &Path) -> String {
722    if default_socket_path()
723        .ok()
724        .as_deref()
725        .is_some_and(|default_path| default_path == socket_path)
726    {
727        return "rmux start-server".to_owned();
728    }
729
730    format!("rmux -S {} start-server", shell_quote_path(socket_path))
731}
732
733fn probe_server_readiness(connection: &mut Connection) -> Result<(), ClientError> {
734    let response = connection.daemon_status()?;
735    match response {
736        Response::DaemonStatus(status) if status.config_loading => {
737            Err(ClientError::Io(io::Error::new(
738                io::ErrorKind::WouldBlock,
739                "daemon is still loading startup config",
740            )))
741        }
742        Response::DaemonStatus(_) => Ok(()),
743        Response::Error(_) => Ok(()),
744        other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
745            format!("unexpected readiness response: {other:?}"),
746        ))),
747    }
748}
749
750#[cfg(not(any(unix, windows)))]
751fn launch_hidden_daemon(
752    socket_path: &Path,
753    config: &AutoStartConfig,
754) -> Result<(), AutoStartError> {
755    let binary_path = rmux_binary_path(config).map_err(AutoStartError::BinaryPath)?;
756    spawn_hidden_daemon_for(&binary_path, socket_path, config).map_err(|error| {
757        AutoStartError::Launch {
758            path: binary_path,
759            error,
760        }
761    })
762}
763
764fn spawn_hidden_daemon_for(
765    binary_path: &Path,
766    socket_path: &Path,
767    config: &AutoStartConfig,
768) -> io::Result<()> {
769    #[cfg(target_os = "linux")]
770    {
771        spawn_hidden_daemon_for_linux(binary_path, socket_path, config)
772    }
773
774    #[cfg(not(target_os = "linux"))]
775    {
776        spawn_hidden_daemon_for_polling(binary_path, socket_path, config)
777    }
778}
779
780#[cfg(not(target_os = "linux"))]
781fn spawn_hidden_daemon_for_polling(
782    binary_path: &Path,
783    socket_path: &Path,
784    config: &AutoStartConfig,
785) -> io::Result<()> {
786    #[cfg(windows)]
787    {
788        spawn_hidden_daemon_for_windows(binary_path, socket_path, config)
789    }
790
791    #[cfg(not(windows))]
792    {
793        let command = hidden_daemon_command(binary_path, socket_path, config, true);
794        match spawn_hidden_daemon(command) {
795            Ok(()) => Ok(()),
796            Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
797                let command = hidden_daemon_command(binary_path, socket_path, config, false);
798                spawn_hidden_daemon(command)
799            }
800            Err(error) => Err(error),
801        }
802    }
803}
804
805#[cfg(windows)]
806fn spawn_hidden_daemon_for_windows(
807    binary_path: &Path,
808    socket_path: &Path,
809    config: &AutoStartConfig,
810) -> io::Result<()> {
811    let ready = rmux_os::daemon::StartupReadyEvent::new()?;
812    let mut command = hidden_daemon_command(binary_path, socket_path, config, true);
813    append_startup_ready_event(&mut command, &ready);
814    match spawn_hidden_daemon(command) {
815        Ok(()) => {
816            let _ = ready.wait(STARTUP_READY_EVENT_TIMEOUT);
817            Ok(())
818        }
819        Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
820            let ready = rmux_os::daemon::StartupReadyEvent::new()?;
821            let mut command = hidden_daemon_command(binary_path, socket_path, config, false);
822            append_startup_ready_event(&mut command, &ready);
823            spawn_hidden_daemon(command)?;
824            let _ = ready.wait(STARTUP_READY_EVENT_TIMEOUT);
825            Ok(())
826        }
827        Err(error) => Err(error),
828    }
829}
830
831#[cfg(windows)]
832fn append_startup_ready_event(command: &mut Command, ready: &rmux_os::daemon::StartupReadyEvent) {
833    command.arg("--startup-ready-event").arg(ready.name());
834}
835
836#[cfg(target_os = "linux")]
837fn spawn_hidden_daemon_for_linux(
838    binary_path: &Path,
839    socket_path: &Path,
840    config: &AutoStartConfig,
841) -> io::Result<()> {
842    let mut ready = StartupReadyEvent::new()?;
843    let mut command =
844        hidden_daemon_command_preserving_fd(binary_path, socket_path, config, true, ready.raw_fd());
845    ready.append_hidden_daemon_args(&mut command);
846    match spawn_hidden_daemon(command) {
847        Ok(()) => {
848            ready.wait_for_signal(STARTUP_READY_EVENT_TIMEOUT);
849            Ok(())
850        }
851        Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
852            let mut ready = StartupReadyEvent::new()?;
853            let mut command = hidden_daemon_command_preserving_fd(
854                binary_path,
855                socket_path,
856                config,
857                false,
858                ready.raw_fd(),
859            );
860            ready.append_hidden_daemon_args(&mut command);
861            spawn_hidden_daemon(command)?;
862            ready.wait_for_signal(STARTUP_READY_EVENT_TIMEOUT);
863            Ok(())
864        }
865        Err(error) => Err(error),
866    }
867}
868
869#[cfg(target_os = "linux")]
870struct StartupReadyEvent {
871    file: File,
872}
873
874#[cfg(target_os = "linux")]
875impl StartupReadyEvent {
876    fn new() -> io::Result<Self> {
877        let fd = rustix::event::eventfd(
878            0,
879            rustix::event::EventfdFlags::NONBLOCK | rustix::event::EventfdFlags::CLOEXEC,
880        )
881        .map_err(io::Error::from)?;
882        Ok(Self { file: fd.into() })
883    }
884
885    fn append_hidden_daemon_args(&self, command: &mut Command) {
886        command
887            .arg("--startup-ready-fd")
888            .arg(self.file.as_raw_fd().to_string());
889    }
890
891    fn raw_fd(&self) -> i32 {
892        self.file.as_raw_fd()
893    }
894
895    fn wait_for_signal(&mut self, timeout: Duration) {
896        let deadline = Instant::now() + timeout;
897        let mut bytes = [0_u8; 8];
898        loop {
899            match self.file.read_exact(&mut bytes) {
900                Ok(()) => return,
901                Err(error)
902                    if matches!(
903                        error.kind(),
904                        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
905                    ) && Instant::now() < deadline =>
906                {
907                    std::thread::sleep(Duration::from_millis(1));
908                }
909                Err(_) => return,
910            }
911        }
912    }
913}
914
915#[cfg(target_os = "linux")]
916fn hidden_daemon_command_preserving_fd(
917    binary_path: &Path,
918    socket_path: &Path,
919    config: &AutoStartConfig,
920    allow_job_breakaway: bool,
921    preserved_fd: i32,
922) -> Command {
923    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
924    rmux_os::daemon::configure_hidden_daemon_command_preserving_fds(
925        &mut command,
926        allow_job_breakaway,
927        &[preserved_fd],
928    );
929    command
930}
931
932#[cfg(not(target_os = "linux"))]
933fn hidden_daemon_command(
934    binary_path: &Path,
935    socket_path: &Path,
936    config: &AutoStartConfig,
937    allow_job_breakaway: bool,
938) -> Command {
939    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
940    rmux_os::daemon::configure_hidden_daemon_command(&mut command, allow_job_breakaway);
941    command
942}
943
944fn hidden_daemon_command_base(
945    binary_path: &Path,
946    socket_path: &Path,
947    config: &AutoStartConfig,
948) -> Command {
949    let mut command = Command::new(binary_path);
950    command
951        .arg(INTERNAL_DAEMON_FLAG)
952        .arg(socket_path)
953        .stdin(Stdio::null())
954        .stdout(Stdio::null())
955        .stderr(Stdio::null());
956    config.append_hidden_daemon_args(&mut command);
957    command
958}
959
960fn spawn_hidden_daemon(mut command: Command) -> io::Result<()> {
961    let child = rmux_os::daemon::spawn_hidden_daemon_command(&mut command)?;
962    // Intentionally drop without `wait()`: the daemon must outlive the
963    // short-lived client process that launched it.
964    drop(child);
965    Ok(())
966}
967
968fn rmux_binary_path(config: &AutoStartConfig) -> io::Result<PathBuf> {
969    if let Some(path) = &config.binary_override {
970        return Ok(path.clone());
971    }
972
973    let current_exe = env::current_exe()?;
974    let resolved_exe = std::fs::canonicalize(&current_exe).ok();
975    match env::var_os(BINARY_OVERRIDE_ENV).filter(|_| binary_override_enabled_for_tests()) {
976        Some(path) => Ok(PathBuf::from(path)),
977        None => Ok(hidden_daemon_binary_path_for_executable_paths(
978            &current_exe,
979            resolved_exe.as_deref(),
980            config,
981        )
982        .unwrap_or(current_exe)),
983    }
984}
985
986fn binary_override_enabled_for_tests() -> bool {
987    cfg!(debug_assertions)
988        && env::var_os(BINARY_OVERRIDE_TEST_OPT_IN_ENV).is_some_and(|value| value == "1")
989}
990
991#[cfg(all(test, unix))]
992fn hidden_daemon_binary_path(current_exe: &Path) -> Option<PathBuf> {
993    hidden_daemon_binary_path_for_executable_paths(current_exe, None, &AutoStartConfig::disabled())
994}
995
996fn hidden_daemon_binary_path_for_executable_paths(
997    current_exe: &Path,
998    resolved_exe: Option<&Path>,
999    config: &AutoStartConfig,
1000) -> Option<PathBuf> {
1001    hidden_daemon_binary_path_for_config(current_exe, config).or_else(|| {
1002        resolved_exe.and_then(|path| hidden_daemon_binary_path_for_config(path, config))
1003    })
1004}
1005
1006fn hidden_daemon_binary_path_for_config(
1007    current_exe: &Path,
1008    config: &AutoStartConfig,
1009) -> Option<PathBuf> {
1010    if config.web_required {
1011        return None;
1012    }
1013    let file_stem = current_exe.file_stem()?.to_str()?;
1014    if file_stem == "rmux-daemon" {
1015        return None;
1016    }
1017
1018    let mut candidate = current_exe.to_path_buf();
1019    let daemon_file_name = match current_exe
1020        .extension()
1021        .and_then(|extension| extension.to_str())
1022    {
1023        Some(extension) if !extension.is_empty() => format!("rmux-daemon.{extension}"),
1024        _ => "rmux-daemon".to_owned(),
1025    };
1026    candidate.set_file_name(daemon_file_name);
1027    candidate.is_file().then_some(candidate)
1028}
1029
1030#[cfg(all(test, unix))]
1031#[path = "auto_start/tests.rs"]
1032mod tests;
1033
1034#[cfg(all(test, windows))]
1035mod windows_tests {
1036    use std::time::Duration;
1037
1038    use super::{startup_readiness_poll_sleep, STARTUP_POLL_INTERVAL};
1039
1040    #[test]
1041    fn windows_startup_readiness_poll_uses_short_backoff() {
1042        let mut attempt = 0;
1043        let remaining = Duration::from_secs(1);
1044
1045        let sleeps = (0..8)
1046            .map(|_| startup_readiness_poll_sleep(&mut attempt, remaining))
1047            .collect::<Vec<_>>();
1048
1049        assert_eq!(
1050            sleeps,
1051            [
1052                Duration::from_millis(1),
1053                Duration::from_millis(2),
1054                Duration::from_millis(4),
1055                Duration::from_millis(8),
1056                Duration::from_millis(16),
1057                Duration::from_millis(32),
1058                STARTUP_POLL_INTERVAL,
1059                STARTUP_POLL_INTERVAL,
1060            ]
1061        );
1062    }
1063
1064    #[test]
1065    fn windows_startup_readiness_poll_respects_remaining_deadline() {
1066        let mut attempt = 6;
1067
1068        assert_eq!(
1069            startup_readiness_poll_sleep(&mut attempt, Duration::from_millis(7)),
1070            Duration::from_millis(7)
1071        );
1072    }
1073}