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