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                waited.as_secs(),
577                socket_path.display()
578            ),
579        }
580    }
581}
582
583impl std::error::Error for AutoStartError {
584    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
585        match self {
586            Self::Client(error) => Some(error),
587            Self::BinaryPath(error) => Some(error),
588            Self::Launch { error, .. } => Some(error),
589            Self::IncompatibleDaemon { .. } => None,
590            Self::TimedOut { .. } => None,
591        }
592    }
593}
594
595impl From<ClientError> for AutoStartError {
596    fn from(error: ClientError) -> Self {
597        Self::Client(error)
598    }
599}
600
601#[cfg(not(any(unix, windows)))]
602fn ensure_server_running_with<ConnectFn, LaunchFn>(
603    socket_path: &Path,
604    timeout: Duration,
605    poll_interval: Duration,
606    connect: ConnectFn,
607    launch: LaunchFn,
608) -> Result<Connection, AutoStartError>
609where
610    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
611    LaunchFn: FnMut() -> Result<(), AutoStartError>,
612{
613    ensure_server_running_with_probe(
614        socket_path,
615        timeout,
616        poll_interval,
617        connect,
618        launch,
619        probe_server_readiness,
620    )
621}
622
623#[cfg(any(all(test, unix), not(any(unix, windows))))]
624fn ensure_server_running_with_probe<ConnectFn, LaunchFn, ProbeFn>(
625    socket_path: &Path,
626    timeout: Duration,
627    poll_interval: Duration,
628    mut connect: ConnectFn,
629    mut launch: LaunchFn,
630    mut probe: ProbeFn,
631) -> Result<Connection, AutoStartError>
632where
633    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
634    LaunchFn: FnMut() -> Result<(), AutoStartError>,
635    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
636{
637    match connect().map_err(AutoStartError::Client)? {
638        ConnectResult::Connected(mut connection) => {
639            probe(&mut connection).map_err(AutoStartError::Client)?;
640            return Ok(connection);
641        }
642        ConnectResult::Absent => {}
643    }
644
645    launch()?;
646    wait_for_server(
647        socket_path,
648        timeout,
649        poll_interval,
650        &mut connect,
651        &mut probe,
652    )
653}
654
655#[cfg(any(all(test, unix), not(any(unix, windows))))]
656fn wait_for_server<ConnectFn, ProbeFn>(
657    socket_path: &Path,
658    timeout: Duration,
659    poll_interval: Duration,
660    connect: &mut ConnectFn,
661    probe: &mut ProbeFn,
662) -> Result<Connection, AutoStartError>
663where
664    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
665    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
666{
667    let start = Instant::now();
668    let deadline = start + timeout;
669
670    loop {
671        match connect() {
672            Ok(crate::ConnectResult::Connected(mut connection)) => match probe(&mut connection) {
673                Ok(()) => return Ok(connection),
674                Err(error) if is_transient_connect_error(&error) => {}
675                Err(error) => return Err(AutoStartError::Client(error)),
676            },
677            Ok(crate::ConnectResult::Absent) => {}
678            Err(error) if is_transient_connect_error(&error) => {}
679            Err(error) => return Err(AutoStartError::Client(error)),
680        }
681
682        let now = Instant::now();
683        if now >= deadline {
684            return Err(AutoStartError::TimedOut {
685                socket_path: socket_path.to_path_buf(),
686                waited: timeout,
687            });
688        }
689
690        std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
691    }
692}
693
694fn is_transient_connect_error(error: &ClientError) -> bool {
695    matches!(
696        error,
697        ClientError::Io(io_error)
698            if matches!(
699                io_error.kind(),
700                io::ErrorKind::WouldBlock
701                    | io::ErrorKind::Interrupted
702                    | io::ErrorKind::TimedOut
703            )
704    )
705}
706
707fn incompatible_daemon_kill_server_command(socket_path: &Path) -> String {
708    if default_socket_path()
709        .ok()
710        .as_deref()
711        .is_some_and(|default_path| default_path == socket_path)
712    {
713        return "rmux kill-server".to_owned();
714    }
715
716    format!("rmux -S {} kill-server", shell_quote_path(socket_path))
717}
718
719fn probe_server_readiness(connection: &mut Connection) -> Result<(), ClientError> {
720    let response = connection.daemon_status()?;
721    match response {
722        Response::DaemonStatus(status) if status.config_loading => {
723            Err(ClientError::Io(io::Error::new(
724                io::ErrorKind::WouldBlock,
725                "daemon is still loading startup config",
726            )))
727        }
728        Response::DaemonStatus(_) => Ok(()),
729        Response::Error(_) => Ok(()),
730        other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
731            format!("unexpected readiness response: {other:?}"),
732        ))),
733    }
734}
735
736#[cfg(not(any(unix, windows)))]
737fn launch_hidden_daemon(
738    socket_path: &Path,
739    config: &AutoStartConfig,
740) -> Result<(), AutoStartError> {
741    let binary_path = rmux_binary_path(config).map_err(AutoStartError::BinaryPath)?;
742    spawn_hidden_daemon_for(&binary_path, socket_path, config).map_err(|error| {
743        AutoStartError::Launch {
744            path: binary_path,
745            error,
746        }
747    })
748}
749
750fn spawn_hidden_daemon_for(
751    binary_path: &Path,
752    socket_path: &Path,
753    config: &AutoStartConfig,
754) -> io::Result<()> {
755    #[cfg(target_os = "linux")]
756    {
757        spawn_hidden_daemon_for_linux(binary_path, socket_path, config)
758    }
759
760    #[cfg(not(target_os = "linux"))]
761    {
762        spawn_hidden_daemon_for_polling(binary_path, socket_path, config)
763    }
764}
765
766#[cfg(not(target_os = "linux"))]
767fn spawn_hidden_daemon_for_polling(
768    binary_path: &Path,
769    socket_path: &Path,
770    config: &AutoStartConfig,
771) -> io::Result<()> {
772    #[cfg(windows)]
773    {
774        spawn_hidden_daemon_for_windows(binary_path, socket_path, config)
775    }
776
777    #[cfg(not(windows))]
778    {
779        let command = hidden_daemon_command(binary_path, socket_path, config, true);
780        match spawn_hidden_daemon(command) {
781            Ok(()) => Ok(()),
782            Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
783                let command = hidden_daemon_command(binary_path, socket_path, config, false);
784                spawn_hidden_daemon(command)
785            }
786            Err(error) => Err(error),
787        }
788    }
789}
790
791#[cfg(windows)]
792fn spawn_hidden_daemon_for_windows(
793    binary_path: &Path,
794    socket_path: &Path,
795    config: &AutoStartConfig,
796) -> io::Result<()> {
797    let ready = rmux_os::daemon::StartupReadyEvent::new()?;
798    let mut command = hidden_daemon_command(binary_path, socket_path, config, true);
799    append_startup_ready_event(&mut command, &ready);
800    match spawn_hidden_daemon(command) {
801        Ok(()) => {
802            let _ = ready.wait(STARTUP_READY_EVENT_TIMEOUT);
803            Ok(())
804        }
805        Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
806            let ready = rmux_os::daemon::StartupReadyEvent::new()?;
807            let mut command = hidden_daemon_command(binary_path, socket_path, config, false);
808            append_startup_ready_event(&mut command, &ready);
809            spawn_hidden_daemon(command)?;
810            let _ = ready.wait(STARTUP_READY_EVENT_TIMEOUT);
811            Ok(())
812        }
813        Err(error) => Err(error),
814    }
815}
816
817#[cfg(windows)]
818fn append_startup_ready_event(command: &mut Command, ready: &rmux_os::daemon::StartupReadyEvent) {
819    command.arg("--startup-ready-event").arg(ready.name());
820}
821
822#[cfg(target_os = "linux")]
823fn spawn_hidden_daemon_for_linux(
824    binary_path: &Path,
825    socket_path: &Path,
826    config: &AutoStartConfig,
827) -> io::Result<()> {
828    let mut ready = StartupReadyEvent::new()?;
829    let mut command =
830        hidden_daemon_command_preserving_fd(binary_path, socket_path, config, true, ready.raw_fd());
831    ready.append_hidden_daemon_args(&mut command);
832    match spawn_hidden_daemon(command) {
833        Ok(()) => {
834            ready.wait_for_signal(STARTUP_READY_EVENT_TIMEOUT);
835            Ok(())
836        }
837        Err(error) if rmux_os::daemon::should_retry_hidden_daemon_without_breakaway(&error) => {
838            let mut ready = StartupReadyEvent::new()?;
839            let mut command = hidden_daemon_command_preserving_fd(
840                binary_path,
841                socket_path,
842                config,
843                false,
844                ready.raw_fd(),
845            );
846            ready.append_hidden_daemon_args(&mut command);
847            spawn_hidden_daemon(command)?;
848            ready.wait_for_signal(STARTUP_READY_EVENT_TIMEOUT);
849            Ok(())
850        }
851        Err(error) => Err(error),
852    }
853}
854
855#[cfg(target_os = "linux")]
856struct StartupReadyEvent {
857    file: File,
858}
859
860#[cfg(target_os = "linux")]
861impl StartupReadyEvent {
862    fn new() -> io::Result<Self> {
863        let fd = rustix::event::eventfd(
864            0,
865            rustix::event::EventfdFlags::NONBLOCK | rustix::event::EventfdFlags::CLOEXEC,
866        )
867        .map_err(io::Error::from)?;
868        Ok(Self { file: fd.into() })
869    }
870
871    fn append_hidden_daemon_args(&self, command: &mut Command) {
872        command
873            .arg("--startup-ready-fd")
874            .arg(self.file.as_raw_fd().to_string());
875    }
876
877    fn raw_fd(&self) -> i32 {
878        self.file.as_raw_fd()
879    }
880
881    fn wait_for_signal(&mut self, timeout: Duration) {
882        let deadline = Instant::now() + timeout;
883        let mut bytes = [0_u8; 8];
884        loop {
885            match self.file.read_exact(&mut bytes) {
886                Ok(()) => return,
887                Err(error)
888                    if matches!(
889                        error.kind(),
890                        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
891                    ) && Instant::now() < deadline =>
892                {
893                    std::thread::sleep(Duration::from_millis(1));
894                }
895                Err(_) => return,
896            }
897        }
898    }
899}
900
901#[cfg(target_os = "linux")]
902fn hidden_daemon_command_preserving_fd(
903    binary_path: &Path,
904    socket_path: &Path,
905    config: &AutoStartConfig,
906    allow_job_breakaway: bool,
907    preserved_fd: i32,
908) -> Command {
909    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
910    rmux_os::daemon::configure_hidden_daemon_command_preserving_fds(
911        &mut command,
912        allow_job_breakaway,
913        &[preserved_fd],
914    );
915    command
916}
917
918#[cfg(not(target_os = "linux"))]
919fn hidden_daemon_command(
920    binary_path: &Path,
921    socket_path: &Path,
922    config: &AutoStartConfig,
923    allow_job_breakaway: bool,
924) -> Command {
925    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
926    rmux_os::daemon::configure_hidden_daemon_command(&mut command, allow_job_breakaway);
927    command
928}
929
930fn hidden_daemon_command_base(
931    binary_path: &Path,
932    socket_path: &Path,
933    config: &AutoStartConfig,
934) -> Command {
935    let mut command = Command::new(binary_path);
936    command
937        .arg(INTERNAL_DAEMON_FLAG)
938        .arg(socket_path)
939        .stdin(Stdio::null())
940        .stdout(Stdio::null())
941        .stderr(Stdio::null());
942    config.append_hidden_daemon_args(&mut command);
943    command
944}
945
946fn spawn_hidden_daemon(mut command: Command) -> io::Result<()> {
947    let child = rmux_os::daemon::spawn_hidden_daemon_command(&mut command)?;
948    // Intentionally drop without `wait()`: the daemon must outlive the
949    // short-lived client process that launched it.
950    drop(child);
951    Ok(())
952}
953
954fn rmux_binary_path(config: &AutoStartConfig) -> io::Result<PathBuf> {
955    if let Some(path) = &config.binary_override {
956        return Ok(path.clone());
957    }
958
959    let current_exe = env::current_exe()?;
960    match env::var_os(BINARY_OVERRIDE_ENV).filter(|_| binary_override_enabled_for_tests()) {
961        Some(path) => Ok(PathBuf::from(path)),
962        None => {
963            Ok(hidden_daemon_binary_path_for_config(&current_exe, config).unwrap_or(current_exe))
964        }
965    }
966}
967
968fn binary_override_enabled_for_tests() -> bool {
969    cfg!(debug_assertions)
970        && env::var_os(BINARY_OVERRIDE_TEST_OPT_IN_ENV).is_some_and(|value| value == "1")
971}
972
973#[cfg(all(test, unix))]
974fn hidden_daemon_binary_path(current_exe: &Path) -> Option<PathBuf> {
975    hidden_daemon_binary_path_for_config(current_exe, &AutoStartConfig::disabled())
976}
977
978fn hidden_daemon_binary_path_for_config(
979    current_exe: &Path,
980    config: &AutoStartConfig,
981) -> Option<PathBuf> {
982    if config.web_required {
983        return None;
984    }
985    let file_stem = current_exe.file_stem()?.to_str()?;
986    if file_stem == "rmux-daemon" {
987        return None;
988    }
989
990    let mut candidate = current_exe.to_path_buf();
991    let daemon_file_name = match current_exe
992        .extension()
993        .and_then(|extension| extension.to_str())
994    {
995        Some(extension) if !extension.is_empty() => format!("rmux-daemon.{extension}"),
996        _ => "rmux-daemon".to_owned(),
997    };
998    candidate.set_file_name(daemon_file_name);
999    candidate.is_file().then_some(candidate)
1000}
1001
1002#[cfg(all(test, unix))]
1003#[path = "auto_start/tests.rs"]
1004mod tests;
1005
1006#[cfg(all(test, windows))]
1007mod windows_tests {
1008    use std::time::Duration;
1009
1010    use super::{startup_readiness_poll_sleep, STARTUP_POLL_INTERVAL};
1011
1012    #[test]
1013    fn windows_startup_readiness_poll_uses_short_backoff() {
1014        let mut attempt = 0;
1015        let remaining = Duration::from_secs(1);
1016
1017        let sleeps = (0..8)
1018            .map(|_| startup_readiness_poll_sleep(&mut attempt, remaining))
1019            .collect::<Vec<_>>();
1020
1021        assert_eq!(
1022            sleeps,
1023            [
1024                Duration::from_millis(1),
1025                Duration::from_millis(2),
1026                Duration::from_millis(4),
1027                Duration::from_millis(8),
1028                Duration::from_millis(16),
1029                Duration::from_millis(32),
1030                STARTUP_POLL_INTERVAL,
1031                STARTUP_POLL_INTERVAL,
1032            ]
1033        );
1034    }
1035
1036    #[test]
1037    fn windows_startup_readiness_poll_respects_remaining_deadline() {
1038        let mut attempt = 6;
1039
1040        assert_eq!(
1041            startup_readiness_poll_sleep(&mut attempt, Duration::from_millis(7)),
1042            Duration::from_millis(7)
1043        );
1044    }
1045}