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