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