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_selected_with, StartupError, StartupOutcome,
23    DEFAULT_STARTUP_DEADLINE, 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/// Identifies whether the current command started the daemon it connected to.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum ServerConnectionProvenance {
183    /// This command won the serialized startup race and launched the daemon.
184    StartedByCaller,
185    /// The daemon already existed, including when another caller won the race.
186    JoinedExisting,
187}
188
189/// A ready server connection together with its startup provenance.
190pub struct EnsuredServerConnection {
191    connection: Connection,
192    provenance: ServerConnectionProvenance,
193    socket_path: PathBuf,
194}
195
196impl EnsuredServerConnection {
197    fn new(
198        connection: Connection,
199        provenance: ServerConnectionProvenance,
200        socket_path: PathBuf,
201    ) -> Self {
202        Self {
203            connection,
204            provenance,
205            socket_path,
206        }
207    }
208
209    /// Returns how this command obtained the connected daemon.
210    #[must_use]
211    pub const fn provenance(&self) -> ServerConnectionProvenance {
212        self.provenance
213    }
214
215    /// Returns the exact endpoint serving this connection.
216    ///
217    /// On Windows this may differ from the originally resolved path when a
218    /// managed endpoint generation rotates during startup.
219    #[must_use]
220    pub fn socket_path(&self) -> &Path {
221        &self.socket_path
222    }
223
224    /// Consumes the outcome and returns the ready connection.
225    #[must_use]
226    pub fn into_connection(self) -> Connection {
227        self.connection
228    }
229
230    /// Consumes the outcome and preserves both the connection and endpoint.
231    #[must_use]
232    pub fn into_connection_and_socket_path(self) -> (Connection, PathBuf) {
233        (self.connection, self.socket_path)
234    }
235}
236
237/// Ensures the RMUX server is reachable, auto-starting it when absent.
238///
239/// This boundary is reserved for command paths that match tmux's
240/// `CMD_STARTSERVER` startup inventory. Other command paths must keep using
241/// [`crate::connect`] or [`crate::connect_or_absent`] directly so they do not
242/// spawn a daemon as a side effect.
243pub fn ensure_server_running(socket_path: &Path) -> Result<Connection, AutoStartError> {
244    ensure_server_running_with_config(socket_path, AutoStartConfig::disabled())
245}
246
247/// Ensures the server is reachable, passing config load options if launched.
248pub fn ensure_server_running_with_config(
249    socket_path: &Path,
250    config: AutoStartConfig,
251) -> Result<Connection, AutoStartError> {
252    ensure_server_running_with_config_outcome(socket_path, config)
253        .map(EnsuredServerConnection::into_connection)
254}
255
256/// Ensures the server is reachable and preserves who started the daemon.
257///
258/// Callers that conditionally clean up an empty daemon must use this function
259/// instead of inferring ownership from a failed preflight connection. The
260/// startup serializers can report [`ServerConnectionProvenance::JoinedExisting`]
261/// when another process wins the race.
262#[cfg(windows)]
263pub fn ensure_server_running_with_config_outcome(
264    socket_path: &Path,
265    config: AutoStartConfig,
266) -> Result<EnsuredServerConnection, AutoStartError> {
267    ensure_server_running_windows(socket_path, config)
268}
269
270/// Ensures the server is reachable and preserves who started the daemon.
271///
272/// Callers that conditionally clean up an empty daemon must use this function
273/// instead of inferring ownership from a failed preflight connection. The
274/// startup serializers can report [`ServerConnectionProvenance::JoinedExisting`]
275/// when another process wins the race.
276#[cfg(unix)]
277pub fn ensure_server_running_with_config_outcome(
278    socket_path: &Path,
279    config: AutoStartConfig,
280) -> Result<EnsuredServerConnection, AutoStartError> {
281    ensure_server_running_unix(socket_path, config)
282}
283
284/// Ensures the server is reachable and preserves who started the daemon.
285#[cfg(not(any(unix, windows)))]
286pub fn ensure_server_running_with_config_outcome(
287    socket_path: &Path,
288    config: AutoStartConfig,
289) -> Result<EnsuredServerConnection, AutoStartError> {
290    ensure_server_running_polling(socket_path, config)
291}
292
293#[cfg(unix)]
294fn ensure_server_running_unix(
295    socket_path: &Path,
296    config: AutoStartConfig,
297) -> Result<EnsuredServerConnection, AutoStartError> {
298    let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
299    let launcher_binary_path = binary_path.clone();
300    let launcher_socket_path = socket_path.to_path_buf();
301    let launcher_config = config.clone();
302
303    let runtime = tokio::runtime::Builder::new_current_thread()
304        .enable_all()
305        .build()
306        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
307    let outcome = runtime.block_on(connect_or_start_with(
308        socket_path,
309        move || async move {
310            spawn_hidden_daemon_for(
311                &launcher_binary_path,
312                &launcher_socket_path,
313                &launcher_config,
314            )
315        },
316        DEFAULT_STARTUP_DEADLINE,
317        STARTUP_POLL_INTERVAL,
318    ));
319
320    let outcome =
321        outcome.map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?;
322    let provenance = startup_outcome_provenance(&outcome);
323    let connection = startup_outcome_into_connection(outcome)?;
324
325    let connection = probe_connected_server(connection, &config, socket_path)?;
326    let connected = upgrade_restart::ensure_daemon_fresh_or_restart(
327        connection,
328        socket_path,
329        &binary_path,
330        &config,
331    )?;
332    Ok(EnsuredServerConnection::new(
333        connected.connection,
334        provenance,
335        connected.socket_path,
336    ))
337}
338
339#[cfg(unix)]
340fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
341    let stream = outcome
342        .into_stream()
343        .into_std()
344        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
345    stream
346        .set_nonblocking(false)
347        .map_err(|error| AutoStartError::Client(ClientError::Io(error)))?;
348    Connection::new(stream).map_err(AutoStartError::Client)
349}
350
351#[cfg(windows)]
352fn ensure_server_running_windows(
353    socket_path: &Path,
354    config: AutoStartConfig,
355) -> Result<EnsuredServerConnection, AutoStartError> {
356    let binary_path = rmux_binary_path(&config).map_err(AutoStartError::BinaryPath)?;
357    let launcher_binary_path = binary_path.clone();
358    let launcher_config = config.clone();
359
360    let (outcome, selected_endpoint) = connect_or_start_blocking_selected_with(
361        socket_path,
362        move |reserved_socket_path| {
363            spawn_hidden_daemon_for(
364                &launcher_binary_path,
365                reserved_socket_path,
366                &launcher_config,
367            )
368        },
369        DEFAULT_STARTUP_DEADLINE,
370        STARTUP_POLL_INTERVAL,
371    )
372    .map_err(|error| auto_start_error_from_startup(error, &binary_path, socket_path))?;
373    let selected_socket_path = selected_endpoint.into_path();
374    let provenance = startup_outcome_provenance(&outcome);
375    let connection = startup_outcome_into_connection(outcome)?;
376    let (connection, readiness_status) =
377        probe_connected_server_windows(connection, &config, &selected_socket_path)?;
378    let connected = upgrade_restart::ensure_daemon_fresh_or_restart_after_windows_readiness(
379        connection,
380        &selected_socket_path,
381        &binary_path,
382        &config,
383        readiness_status,
384    )?;
385    Ok(EnsuredServerConnection::new(
386        connected.connection,
387        provenance,
388        connected.socket_path,
389    ))
390}
391
392#[cfg(any(unix, windows))]
393fn startup_outcome_provenance(outcome: &StartupOutcome) -> ServerConnectionProvenance {
394    if outcome.is_owner() {
395        ServerConnectionProvenance::StartedByCaller
396    } else {
397        ServerConnectionProvenance::JoinedExisting
398    }
399}
400
401#[cfg(windows)]
402fn startup_outcome_into_connection(outcome: StartupOutcome) -> Result<Connection, AutoStartError> {
403    Connection::new(outcome.into_stream()).map_err(AutoStartError::Client)
404}
405
406#[cfg(windows)]
407fn probe_connected_server_windows(
408    mut connection: Connection,
409    _config: &AutoStartConfig,
410    socket_path: &Path,
411) -> Result<(Connection, Option<DaemonStatusResponse>), AutoStartError> {
412    let deadline = Instant::now() + DEFAULT_STARTUP_DEADLINE;
413    let mut poll_attempt = 0_u32;
414    loop {
415        match probe_server_readiness_status(&mut connection) {
416            Ok(status) => return Ok((connection, status)),
417            Err(ClientError::Protocol(RmuxError::UnsupportedWireVersion { got, .. })) => {
418                return Err(AutoStartError::IncompatibleDaemon {
419                    socket_path: socket_path.to_path_buf(),
420                    message: upgrade::incompatible_daemon_message(&upgrade::IncompatibleDaemon {
421                        daemon_version: None,
422                        daemon_wire_version: Some(got),
423                    }),
424                });
425            }
426            Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
427                let remaining = deadline.saturating_duration_since(Instant::now());
428                std::thread::sleep(startup_readiness_poll_sleep(&mut poll_attempt, remaining));
429            }
430            Err(error) => return Err(AutoStartError::Client(error)),
431        }
432    }
433}
434
435#[cfg(windows)]
436fn probe_server_readiness_status(
437    connection: &mut Connection,
438) -> Result<Option<DaemonStatusResponse>, ClientError> {
439    let response = connection.daemon_status()?;
440    match response {
441        Response::DaemonStatus(status) if status.config_loading => {
442            Err(ClientError::Io(io::Error::new(
443                io::ErrorKind::WouldBlock,
444                "daemon is still loading startup config",
445            )))
446        }
447        Response::DaemonStatus(status) => Ok(Some(status)),
448        Response::Error(_) => Ok(None),
449        other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
450            format!("unexpected readiness response: {other:?}"),
451        ))),
452    }
453}
454
455fn probe_connected_server(
456    mut connection: Connection,
457    _config: &AutoStartConfig,
458    socket_path: &Path,
459) -> Result<Connection, AutoStartError> {
460    let deadline = Instant::now() + DEFAULT_STARTUP_DEADLINE;
461    let mut poll_attempt = 0_u32;
462    loop {
463        match probe_server_readiness(&mut connection) {
464            Ok(()) => return Ok(connection),
465            Err(ClientError::Protocol(RmuxError::UnsupportedWireVersion { got, .. })) => {
466                return Err(AutoStartError::IncompatibleDaemon {
467                    socket_path: socket_path.to_path_buf(),
468                    message: upgrade::incompatible_daemon_message(&upgrade::IncompatibleDaemon {
469                        daemon_version: None,
470                        daemon_wire_version: Some(got),
471                    }),
472                });
473            }
474            Err(error) if is_transient_connect_error(&error) && Instant::now() < deadline => {
475                let remaining = deadline.saturating_duration_since(Instant::now());
476                std::thread::sleep(startup_readiness_poll_sleep(&mut poll_attempt, remaining));
477            }
478            Err(error) => return Err(AutoStartError::Client(error)),
479        }
480    }
481}
482
483fn startup_readiness_poll_sleep(poll_attempt: &mut u32, remaining: Duration) -> Duration {
484    #[cfg(windows)]
485    {
486        const INITIAL_POLL_MILLIS: u64 = 1;
487
488        let shift = (*poll_attempt).min(6);
489        *poll_attempt = (*poll_attempt).saturating_add(1);
490        let millis = INITIAL_POLL_MILLIS
491            .checked_shl(shift)
492            .unwrap_or(u64::MAX)
493            .min(STARTUP_POLL_INTERVAL.as_millis() as u64);
494        Duration::from_millis(millis).min(remaining)
495    }
496
497    #[cfg(not(windows))]
498    {
499        const INITIAL_POLL_MILLIS: u64 = 1;
500
501        let shift = (*poll_attempt).min(6);
502        *poll_attempt = (*poll_attempt).saturating_add(1);
503        let millis = INITIAL_POLL_MILLIS
504            .checked_shl(shift)
505            .unwrap_or(u64::MAX)
506            .min(STARTUP_POLL_INTERVAL.as_millis() as u64);
507        Duration::from_millis(millis).min(remaining)
508    }
509}
510
511#[cfg(unix)]
512fn auto_start_error_from_startup(
513    error: StartupError,
514    binary_path: &Path,
515    socket_path: &Path,
516) -> AutoStartError {
517    match error {
518        StartupError::Launcher { source } => AutoStartError::Launch {
519            path: binary_path.to_path_buf(),
520            error: source,
521        },
522        StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
523            socket_path: socket_path.to_path_buf(),
524            waited,
525        },
526        error => AutoStartError::Client(ClientError::Io(io::Error::new(
527            startup_error_kind(&error),
528            error.to_string(),
529        ))),
530    }
531}
532
533#[cfg(windows)]
534fn auto_start_error_from_startup(
535    error: StartupError,
536    binary_path: &Path,
537    socket_path: &Path,
538) -> AutoStartError {
539    match error {
540        StartupError::Launcher { source } => AutoStartError::Launch {
541            path: binary_path.to_path_buf(),
542            error: source,
543        },
544        StartupError::StartupTimeout { waited, .. } => AutoStartError::TimedOut {
545            socket_path: socket_path.to_path_buf(),
546            waited,
547        },
548        error => AutoStartError::Client(ClientError::Io(io::Error::new(
549            startup_error_kind(&error),
550            error.to_string(),
551        ))),
552    }
553}
554
555#[cfg(unix)]
556fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
557    match error {
558        StartupError::InvalidPath { .. } | StartupError::SymlinkRejected { .. } => {
559            io::ErrorKind::InvalidInput
560        }
561        StartupError::UnsafeOwner { .. }
562        | StartupError::UnsafePermissions { .. }
563        | StartupError::PeerCredentialMismatch { .. } => io::ErrorKind::PermissionDenied,
564        StartupError::Lock { source, .. } | StartupError::Filesystem { source, .. } => {
565            source.kind()
566        }
567        StartupError::Launcher { source } => source.kind(),
568        StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
569    }
570}
571
572#[cfg(windows)]
573fn startup_error_kind(error: &StartupError) -> io::ErrorKind {
574    match error {
575        StartupError::InvalidPipeName { .. } | StartupError::InvalidMutexName { .. } => {
576            io::ErrorKind::InvalidInput
577        }
578        StartupError::MutexAccessDenied { .. } | StartupError::PipeAccessDenied { .. } => {
579            io::ErrorKind::PermissionDenied
580        }
581        StartupError::MutexTimeout { .. }
582        | StartupError::PipeBusy { .. }
583        | StartupError::StartupTimeout { .. } => io::ErrorKind::TimedOut,
584        StartupError::PipeNotFound { .. } | StartupError::PipeNoData { .. } => {
585            io::ErrorKind::NotFound
586        }
587        StartupError::Mutex { source, .. } | StartupError::PipeIo { source, .. } => source.kind(),
588        StartupError::Launcher { source } => source.kind(),
589    }
590}
591
592#[cfg(not(any(unix, windows)))]
593fn ensure_server_running_polling(
594    socket_path: &Path,
595    config: AutoStartConfig,
596) -> Result<EnsuredServerConnection, AutoStartError> {
597    if config.loads_startup_config() {
598        return ensure_server_running_with_probe_outcome(
599            socket_path,
600            AUTO_START_TIMEOUT,
601            POLL_INTERVAL,
602            || crate::connect_or_absent(socket_path),
603            || launch_hidden_daemon(socket_path, &config),
604            |_| Ok(()),
605        );
606    }
607
608    ensure_server_running_with_probe_outcome(
609        socket_path,
610        AUTO_START_TIMEOUT,
611        POLL_INTERVAL,
612        || crate::connect_or_absent(socket_path),
613        || launch_hidden_daemon(socket_path, &config),
614        probe_server_readiness,
615    )
616}
617
618/// Errors raised while auto-starting or connecting to the RMUX server.
619#[derive(Debug)]
620pub enum AutoStartError {
621    /// The client transport failed before or during readiness polling.
622    Client(ClientError),
623    /// Resolving the `rmux` binary path failed.
624    BinaryPath(io::Error),
625    /// Re-executing the hidden daemon process failed.
626    Launch {
627        /// The binary path that failed to spawn.
628        path: PathBuf,
629        /// The underlying process-spawn error.
630        error: io::Error,
631    },
632    /// A running daemon speaks an incompatible protocol version.
633    IncompatibleDaemon {
634        /// The socket path hosting the incompatible daemon.
635        socket_path: PathBuf,
636        /// Human-readable protocol mismatch detail.
637        message: String,
638    },
639    /// The socket never became reachable before the readiness deadline.
640    TimedOut {
641        /// The socket path that never became reachable.
642        socket_path: PathBuf,
643        /// The amount of time spent polling.
644        waited: Duration,
645    },
646}
647
648impl fmt::Display for AutoStartError {
649    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
650        match self {
651            Self::Client(error) => write!(formatter, "{error}"),
652            Self::BinaryPath(error) => {
653                write!(formatter, "failed to resolve rmux binary path: {error}")
654            }
655            Self::Launch { path, error } => {
656                write!(
657                    formatter,
658                    "failed to launch hidden rmux daemon '{}': {error}",
659                    path.display()
660                )
661            }
662            Self::IncompatibleDaemon {
663                socket_path,
664                message,
665            } => write!(
666                formatter,
667                "rmux: {message} on '{}'.\nrmux: run `{}` to stop it, then retry.",
668                socket_path.display(),
669                incompatible_daemon_kill_server_command(socket_path)
670            ),
671            Self::TimedOut {
672                socket_path,
673                waited,
674            } => write!(
675                formatter,
676                "timed out after {}s waiting for rmux server socket '{}'. \
677                 The hidden daemon may have exited before creating the socket; run `{}` to surface startup errors.",
678                waited.as_secs(),
679                socket_path.display(),
680                diagnostic_start_server_command(socket_path)
681            ),
682        }
683    }
684}
685
686impl std::error::Error for AutoStartError {
687    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
688        match self {
689            Self::Client(error) => Some(error),
690            Self::BinaryPath(error) => Some(error),
691            Self::Launch { error, .. } => Some(error),
692            Self::IncompatibleDaemon { .. } => None,
693            Self::TimedOut { .. } => None,
694        }
695    }
696}
697
698impl From<ClientError> for AutoStartError {
699    fn from(error: ClientError) -> Self {
700        Self::Client(error)
701    }
702}
703
704#[cfg(not(any(unix, windows)))]
705fn ensure_server_running_with<ConnectFn, LaunchFn>(
706    socket_path: &Path,
707    timeout: Duration,
708    poll_interval: Duration,
709    connect: ConnectFn,
710    launch: LaunchFn,
711) -> Result<Connection, AutoStartError>
712where
713    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
714    LaunchFn: FnMut() -> Result<(), AutoStartError>,
715{
716    ensure_server_running_with_probe(
717        socket_path,
718        timeout,
719        poll_interval,
720        connect,
721        launch,
722        probe_server_readiness,
723    )
724}
725
726#[cfg(any(all(test, unix), not(any(unix, windows))))]
727fn ensure_server_running_with_probe<ConnectFn, LaunchFn, ProbeFn>(
728    socket_path: &Path,
729    timeout: Duration,
730    poll_interval: Duration,
731    connect: ConnectFn,
732    launch: LaunchFn,
733    probe: ProbeFn,
734) -> Result<Connection, AutoStartError>
735where
736    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
737    LaunchFn: FnMut() -> Result<(), AutoStartError>,
738    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
739{
740    ensure_server_running_with_probe_outcome(
741        socket_path,
742        timeout,
743        poll_interval,
744        connect,
745        launch,
746        probe,
747    )
748    .map(EnsuredServerConnection::into_connection)
749}
750
751#[cfg(any(all(test, unix), not(any(unix, windows))))]
752fn ensure_server_running_with_probe_outcome<ConnectFn, LaunchFn, ProbeFn>(
753    socket_path: &Path,
754    timeout: Duration,
755    poll_interval: Duration,
756    mut connect: ConnectFn,
757    mut launch: LaunchFn,
758    mut probe: ProbeFn,
759) -> Result<EnsuredServerConnection, AutoStartError>
760where
761    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
762    LaunchFn: FnMut() -> Result<(), AutoStartError>,
763    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
764{
765    match connect().map_err(AutoStartError::Client)? {
766        ConnectResult::Connected(mut connection) => {
767            probe(&mut connection).map_err(AutoStartError::Client)?;
768            return Ok(EnsuredServerConnection::new(
769                connection,
770                ServerConnectionProvenance::JoinedExisting,
771                socket_path.to_path_buf(),
772            ));
773        }
774        ConnectResult::Absent => {}
775    }
776
777    launch()?;
778    let connection = wait_for_server(
779        socket_path,
780        timeout,
781        poll_interval,
782        &mut connect,
783        &mut probe,
784    )?;
785    Ok(EnsuredServerConnection::new(
786        connection,
787        ServerConnectionProvenance::StartedByCaller,
788        socket_path.to_path_buf(),
789    ))
790}
791
792#[cfg(any(all(test, unix), not(any(unix, windows))))]
793fn wait_for_server<ConnectFn, ProbeFn>(
794    socket_path: &Path,
795    timeout: Duration,
796    poll_interval: Duration,
797    connect: &mut ConnectFn,
798    probe: &mut ProbeFn,
799) -> Result<Connection, AutoStartError>
800where
801    ConnectFn: FnMut() -> Result<ConnectResult, ClientError>,
802    ProbeFn: FnMut(&mut Connection) -> Result<(), ClientError>,
803{
804    let start = Instant::now();
805    let deadline = start + timeout;
806
807    loop {
808        match connect() {
809            Ok(crate::ConnectResult::Connected(mut connection)) => match probe(&mut connection) {
810                Ok(()) => return Ok(connection),
811                Err(error) if is_transient_connect_error(&error) => {}
812                Err(error) => return Err(AutoStartError::Client(error)),
813            },
814            Ok(crate::ConnectResult::Absent) => {}
815            Err(error) if is_transient_connect_error(&error) => {}
816            Err(error) => return Err(AutoStartError::Client(error)),
817        }
818
819        let now = Instant::now();
820        if now >= deadline {
821            return Err(AutoStartError::TimedOut {
822                socket_path: socket_path.to_path_buf(),
823                waited: timeout,
824            });
825        }
826
827        std::thread::sleep(poll_interval.min(deadline.saturating_duration_since(now)));
828    }
829}
830
831fn is_transient_connect_error(error: &ClientError) -> bool {
832    matches!(
833        error,
834        ClientError::Io(io_error)
835            if matches!(
836                io_error.kind(),
837                io::ErrorKind::WouldBlock
838                    | io::ErrorKind::Interrupted
839                    | io::ErrorKind::TimedOut
840            )
841    )
842}
843
844fn incompatible_daemon_kill_server_command(socket_path: &Path) -> String {
845    if default_socket_path()
846        .ok()
847        .as_deref()
848        .is_some_and(|default_path| default_path == socket_path)
849    {
850        return "rmux kill-server".to_owned();
851    }
852
853    format!("rmux -S {} kill-server", shell_quote_path(socket_path))
854}
855
856fn diagnostic_start_server_command(socket_path: &Path) -> String {
857    if default_socket_path()
858        .ok()
859        .as_deref()
860        .is_some_and(|default_path| default_path == socket_path)
861    {
862        return "rmux start-server".to_owned();
863    }
864
865    format!("rmux -S {} start-server", shell_quote_path(socket_path))
866}
867
868fn probe_server_readiness(connection: &mut Connection) -> Result<(), ClientError> {
869    let response = connection.daemon_status()?;
870    match response {
871        Response::DaemonStatus(status) if status.config_loading => {
872            Err(ClientError::Io(io::Error::new(
873                io::ErrorKind::WouldBlock,
874                "daemon is still loading startup config",
875            )))
876        }
877        Response::DaemonStatus(_) => Ok(()),
878        Response::Error(_) => Ok(()),
879        other => Err(ClientError::Protocol(rmux_proto::RmuxError::Server(
880            format!("unexpected readiness response: {other:?}"),
881        ))),
882    }
883}
884
885#[cfg(not(any(unix, windows)))]
886fn launch_hidden_daemon(
887    socket_path: &Path,
888    config: &AutoStartConfig,
889) -> Result<(), AutoStartError> {
890    let binary_path = rmux_binary_path(config).map_err(AutoStartError::BinaryPath)?;
891    spawn_hidden_daemon_for(&binary_path, socket_path, config).map_err(|error| {
892        AutoStartError::Launch {
893            path: binary_path,
894            error,
895        }
896    })
897}
898
899fn spawn_hidden_daemon_for(
900    binary_path: &Path,
901    socket_path: &Path,
902    config: &AutoStartConfig,
903) -> io::Result<()> {
904    #[cfg(target_os = "linux")]
905    {
906        spawn_hidden_daemon_for_linux(binary_path, socket_path, config)
907    }
908
909    #[cfg(not(target_os = "linux"))]
910    {
911        spawn_hidden_daemon_for_polling(binary_path, socket_path, config)
912    }
913}
914
915#[cfg(not(target_os = "linux"))]
916fn spawn_hidden_daemon_for_polling(
917    binary_path: &Path,
918    socket_path: &Path,
919    config: &AutoStartConfig,
920) -> io::Result<()> {
921    #[cfg(windows)]
922    {
923        spawn_hidden_daemon_for_windows(binary_path, socket_path, config)
924    }
925
926    #[cfg(not(windows))]
927    {
928        let command = hidden_daemon_command(binary_path, socket_path, config, true);
929        spawn_hidden_daemon(command)
930    }
931}
932
933#[cfg(windows)]
934fn spawn_hidden_daemon_for_windows(
935    binary_path: &Path,
936    socket_path: &Path,
937    config: &AutoStartConfig,
938) -> io::Result<()> {
939    let ready = rmux_os::daemon::StartupReadyEvent::new()?;
940    let mut command = hidden_daemon_command(binary_path, socket_path, config, true);
941    append_startup_ready_event(&mut command, &ready);
942    spawn_hidden_daemon(command)?;
943    let _ = ready.wait(STARTUP_READY_EVENT_TIMEOUT);
944    Ok(())
945}
946
947#[cfg(windows)]
948fn append_startup_ready_event(command: &mut Command, ready: &rmux_os::daemon::StartupReadyEvent) {
949    command.arg("--startup-ready-event").arg(ready.name());
950}
951
952#[cfg(target_os = "linux")]
953fn spawn_hidden_daemon_for_linux(
954    binary_path: &Path,
955    socket_path: &Path,
956    config: &AutoStartConfig,
957) -> io::Result<()> {
958    let mut ready = StartupReadyEvent::new()?;
959    let mut command =
960        hidden_daemon_command_preserving_fd(binary_path, socket_path, config, true, ready.raw_fd());
961    ready.append_hidden_daemon_args(&mut command);
962    spawn_hidden_daemon(command)?;
963    ready.wait_for_signal(STARTUP_READY_EVENT_TIMEOUT);
964    Ok(())
965}
966
967#[cfg(target_os = "linux")]
968struct StartupReadyEvent {
969    file: File,
970}
971
972#[cfg(target_os = "linux")]
973impl StartupReadyEvent {
974    fn new() -> io::Result<Self> {
975        let fd = rustix::event::eventfd(
976            0,
977            rustix::event::EventfdFlags::NONBLOCK | rustix::event::EventfdFlags::CLOEXEC,
978        )
979        .map_err(io::Error::from)?;
980        Ok(Self { file: fd.into() })
981    }
982
983    fn append_hidden_daemon_args(&self, command: &mut Command) {
984        command
985            .arg("--startup-ready-fd")
986            .arg(self.file.as_raw_fd().to_string());
987    }
988
989    fn raw_fd(&self) -> i32 {
990        self.file.as_raw_fd()
991    }
992
993    fn wait_for_signal(&mut self, timeout: Duration) {
994        let deadline = Instant::now() + timeout;
995        let mut bytes = [0_u8; 8];
996        loop {
997            match self.file.read_exact(&mut bytes) {
998                Ok(()) => return,
999                Err(error)
1000                    if matches!(
1001                        error.kind(),
1002                        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
1003                    ) && Instant::now() < deadline =>
1004                {
1005                    std::thread::sleep(Duration::from_millis(1));
1006                }
1007                Err(_) => return,
1008            }
1009        }
1010    }
1011}
1012
1013#[cfg(target_os = "linux")]
1014fn hidden_daemon_command_preserving_fd(
1015    binary_path: &Path,
1016    socket_path: &Path,
1017    config: &AutoStartConfig,
1018    allow_job_breakaway: bool,
1019    preserved_fd: i32,
1020) -> Command {
1021    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
1022    rmux_os::daemon::configure_hidden_daemon_command_preserving_fds(
1023        &mut command,
1024        allow_job_breakaway,
1025        &[preserved_fd],
1026    );
1027    command
1028}
1029
1030#[cfg(not(target_os = "linux"))]
1031fn hidden_daemon_command(
1032    binary_path: &Path,
1033    socket_path: &Path,
1034    config: &AutoStartConfig,
1035    allow_job_breakaway: bool,
1036) -> Command {
1037    let mut command = hidden_daemon_command_base(binary_path, socket_path, config);
1038    rmux_os::daemon::configure_hidden_daemon_command(&mut command, allow_job_breakaway);
1039    command
1040}
1041
1042fn hidden_daemon_command_base(
1043    binary_path: &Path,
1044    socket_path: &Path,
1045    config: &AutoStartConfig,
1046) -> Command {
1047    let mut command = Command::new(binary_path);
1048    command
1049        .arg(INTERNAL_DAEMON_FLAG)
1050        .arg(socket_path)
1051        .stdin(Stdio::null())
1052        .stdout(Stdio::null())
1053        .stderr(Stdio::null());
1054    config.append_hidden_daemon_args(&mut command);
1055    command
1056}
1057
1058fn spawn_hidden_daemon(mut command: Command) -> io::Result<()> {
1059    let child = rmux_os::daemon::spawn_hidden_daemon_command_requiring_job_breakaway(&mut command)?;
1060    // Intentionally drop without `wait()`: the daemon must outlive the
1061    // short-lived client process that launched it.
1062    drop(child);
1063    Ok(())
1064}
1065
1066fn rmux_binary_path(config: &AutoStartConfig) -> io::Result<PathBuf> {
1067    if let Some(path) = &config.binary_override {
1068        return Ok(path.clone());
1069    }
1070
1071    let current_exe = env::current_exe()?;
1072    let resolved_exe = std::fs::canonicalize(&current_exe).ok();
1073    match env::var_os(BINARY_OVERRIDE_ENV).filter(|_| binary_override_enabled_for_tests()) {
1074        Some(path) => Ok(PathBuf::from(path)),
1075        None => Ok(hidden_daemon_binary_path_for_executable_paths(
1076            &current_exe,
1077            resolved_exe.as_deref(),
1078            config,
1079        )
1080        .unwrap_or(current_exe)),
1081    }
1082}
1083
1084fn binary_override_enabled_for_tests() -> bool {
1085    cfg!(debug_assertions)
1086        && env::var_os(BINARY_OVERRIDE_TEST_OPT_IN_ENV).is_some_and(|value| value == "1")
1087}
1088
1089#[cfg(all(test, unix))]
1090fn hidden_daemon_binary_path(current_exe: &Path) -> Option<PathBuf> {
1091    hidden_daemon_binary_path_for_executable_paths(current_exe, None, &AutoStartConfig::disabled())
1092}
1093
1094fn hidden_daemon_binary_path_for_executable_paths(
1095    current_exe: &Path,
1096    resolved_exe: Option<&Path>,
1097    config: &AutoStartConfig,
1098) -> Option<PathBuf> {
1099    hidden_daemon_binary_path_for_config(current_exe, config).or_else(|| {
1100        resolved_exe.and_then(|path| hidden_daemon_binary_path_for_config(path, config))
1101    })
1102}
1103
1104fn hidden_daemon_binary_path_for_config(
1105    current_exe: &Path,
1106    config: &AutoStartConfig,
1107) -> Option<PathBuf> {
1108    if config.web_required {
1109        return None;
1110    }
1111    let file_stem = current_exe.file_stem()?.to_str()?;
1112    if file_stem == "rmux-daemon" {
1113        return None;
1114    }
1115
1116    let mut candidate = current_exe.to_path_buf();
1117    let daemon_file_name = match current_exe
1118        .extension()
1119        .and_then(|extension| extension.to_str())
1120    {
1121        Some(extension) if !extension.is_empty() => format!("rmux-daemon.{extension}"),
1122        _ => "rmux-daemon".to_owned(),
1123    };
1124    candidate.set_file_name(daemon_file_name);
1125    candidate.is_file().then_some(candidate)
1126}
1127
1128#[cfg(all(test, unix))]
1129#[path = "auto_start/tests.rs"]
1130mod tests;
1131
1132#[cfg(all(test, windows))]
1133mod windows_tests {
1134    use std::time::Duration;
1135
1136    use super::{startup_readiness_poll_sleep, STARTUP_POLL_INTERVAL};
1137
1138    #[test]
1139    fn windows_startup_readiness_poll_uses_short_backoff() {
1140        let mut attempt = 0;
1141        let remaining = Duration::from_secs(1);
1142
1143        let sleeps = (0..8)
1144            .map(|_| startup_readiness_poll_sleep(&mut attempt, remaining))
1145            .collect::<Vec<_>>();
1146
1147        assert_eq!(
1148            sleeps,
1149            [
1150                Duration::from_millis(1),
1151                Duration::from_millis(2),
1152                Duration::from_millis(4),
1153                Duration::from_millis(8),
1154                Duration::from_millis(16),
1155                Duration::from_millis(32),
1156                STARTUP_POLL_INTERVAL,
1157                STARTUP_POLL_INTERVAL,
1158            ]
1159        );
1160    }
1161
1162    #[test]
1163    fn windows_startup_readiness_poll_respects_remaining_deadline() {
1164        let mut attempt = 6;
1165
1166        assert_eq!(
1167            startup_readiness_poll_sleep(&mut attempt, Duration::from_millis(7)),
1168            Duration::from_millis(7)
1169        );
1170    }
1171}