Skip to main content

rmux_server/
daemon.rs

1#[cfg(windows)]
2use std::ffi::{OsStr, OsString};
3#[cfg(all(test, unix))]
4use std::fs;
5use std::io;
6#[cfg(windows)]
7use std::io::Read;
8#[cfg(windows)]
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex as StdMutex};
12#[cfg(windows)]
13use std::time::Duration;
14
15use tokio::sync::oneshot;
16use tokio::task::JoinHandle;
17
18use rmux_core::events::SubscriptionLimits;
19#[cfg(windows)]
20use rmux_ipc::connect_blocking;
21use rmux_ipc::LocalEndpoint;
22#[cfg(windows)]
23use rmux_ipc::LocalListener;
24#[cfg(windows)]
25use rmux_proto::{
26    encode_frame, FrameDecoder, HasSessionRequest, Request, Response, RmuxError, SessionName,
27};
28
29use crate::listener;
30use crate::listener_options::ServeOptions;
31#[cfg(windows)]
32use crate::server_access::current_owner_uid;
33#[cfg(unix)]
34use crate::unix_socket::bind_unix_listener_at;
35#[cfg(unix)]
36use crate::unix_socket::real_user_id;
37#[cfg(all(test, unix))]
38use crate::unix_socket::{
39    ensure_parent_directory, indicates_stale_socket, remove_stale_socket_if_needed,
40};
41
42#[cfg(all(test, unix))]
43const FALLBACK_SOCKET_ROOT: &str = "/tmp";
44const DEFAULT_WEB_PORT: u16 = 9777;
45
46/// Computes the default RMUX daemon socket path.
47///
48/// The path uses an rmux-specific per-user directory so it cannot collide with
49/// a real tmux server socket.
50pub fn default_socket_path() -> io::Result<PathBuf> {
51    rmux_ipc::default_endpoint().map(LocalEndpoint::into_path)
52}
53
54#[cfg(all(test, unix))]
55fn socket_root_from_env(tmpdir: Option<&std::ffi::OsStr>) -> io::Result<PathBuf> {
56    let tmpdir = tmpdir
57        .filter(|value| !value.is_empty())
58        .map(PathBuf::from)
59        .into_iter();
60    let candidates = tmpdir.chain(std::iter::once(PathBuf::from(FALLBACK_SOCKET_ROOT)));
61
62    for candidate in candidates {
63        if let Ok(resolved) = fs::canonicalize(&candidate) {
64            return Ok(resolved);
65        }
66    }
67
68    Err(io::Error::new(
69        io::ErrorKind::NotFound,
70        "no suitable rmux socket directory",
71    ))
72}
73
74/// Daemon configuration for a single RMUX server instance.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DaemonConfig {
77    socket_path: PathBuf,
78    config_load: ConfigLoadOptions,
79    subscription_limits: SubscriptionLimits,
80    web_frontend: Option<String>,
81    web_port: u16,
82    web_port_explicit: bool,
83    web_required: bool,
84    startup_ready_fd: Option<i32>,
85    #[cfg(windows)]
86    startup_ready_event: Option<OsString>,
87}
88
89impl DaemonConfig {
90    /// Builds a daemon configuration for the given socket path.
91    #[must_use]
92    pub fn new(socket_path: PathBuf) -> Self {
93        Self {
94            socket_path,
95            config_load: ConfigLoadOptions::disabled(),
96            subscription_limits: SubscriptionLimits::default(),
97            web_frontend: None,
98            web_port: DEFAULT_WEB_PORT,
99            web_port_explicit: false,
100            web_required: false,
101            startup_ready_fd: None,
102            #[cfg(windows)]
103            startup_ready_event: None,
104        }
105    }
106
107    /// Builds a daemon configuration using the default spec socket path.
108    pub fn with_default_socket_path() -> io::Result<Self> {
109        Ok(Self::new(default_socket_path()?))
110    }
111
112    /// Returns the configured local IPC endpoint path.
113    #[must_use]
114    pub fn socket_path(&self) -> &Path {
115        &self.socket_path
116    }
117
118    /// Returns the startup config loading policy.
119    #[must_use]
120    pub const fn config_load(&self) -> &ConfigLoadOptions {
121        &self.config_load
122    }
123
124    /// Returns the pane-output subscription limits.
125    #[must_use]
126    pub fn subscription_limits(&self) -> SubscriptionLimits {
127        self.subscription_limits
128    }
129
130    /// Returns the configured web-share listener port.
131    #[must_use]
132    pub const fn web_port(&self) -> u16 {
133        self.web_port
134    }
135
136    /// Returns whether the web-share listener port was explicitly configured.
137    #[must_use]
138    pub const fn web_port_explicit(&self) -> bool {
139        self.web_port_explicit
140    }
141
142    /// Returns whether this daemon startup requires the web listener to bind.
143    #[must_use]
144    pub const fn web_required(&self) -> bool {
145        self.web_required
146    }
147
148    /// Returns the optional external web-share frontend origin.
149    #[must_use]
150    pub fn web_frontend(&self) -> Option<&str> {
151        self.web_frontend.as_deref()
152    }
153
154    /// Enables RMUX default startup config loading.
155    #[must_use]
156    pub fn with_default_config_load(mut self, quiet: bool, cwd: Option<PathBuf>) -> Self {
157        self.config_load = ConfigLoadOptions {
158            selection: ConfigFileSelection::Default,
159            quiet,
160            cwd,
161        };
162        self
163    }
164
165    /// Overrides pane-output subscription limits for this daemon.
166    #[must_use]
167    pub fn with_subscription_limits(mut self, subscription_limits: SubscriptionLimits) -> Self {
168        self.subscription_limits = subscription_limits;
169        self
170    }
171
172    /// Overrides the web-share listener port.
173    #[must_use]
174    pub const fn with_web_port(mut self, port: u16) -> Self {
175        self.web_port = port;
176        self.web_port_explicit = true;
177        self.web_required = true;
178        self
179    }
180
181    /// Overrides the frontend origin used in generated web-share URLs.
182    #[must_use]
183    pub fn with_web_frontend(mut self, frontend: String) -> Self {
184        self.web_frontend = Some(frontend);
185        self.web_required = true;
186        self
187    }
188
189    /// Enables explicit `-f` startup config loading.
190    #[must_use]
191    pub fn with_config_files(
192        mut self,
193        files: Vec<PathBuf>,
194        quiet: bool,
195        cwd: Option<PathBuf>,
196    ) -> Self {
197        self.config_load = ConfigLoadOptions {
198            selection: ConfigFileSelection::Files(files),
199            quiet,
200            cwd,
201        };
202        self
203    }
204
205    /// Signals this inherited Linux eventfd after the daemon listener is bound.
206    #[cfg(target_os = "linux")]
207    #[must_use]
208    pub const fn with_startup_ready_fd(mut self, ready_fd: i32) -> Self {
209        self.startup_ready_fd = Some(ready_fd);
210        self
211    }
212
213    #[cfg(unix)]
214    const fn startup_ready_fd(&self) -> Option<i32> {
215        self.startup_ready_fd
216    }
217
218    /// Signals this named Win32 event after the daemon listener is bound.
219    #[cfg(windows)]
220    #[must_use]
221    pub fn with_startup_ready_event(mut self, ready_event: OsString) -> Self {
222        self.startup_ready_event = Some(ready_event);
223        self
224    }
225
226    #[cfg(windows)]
227    fn startup_ready_event(&self) -> Option<&OsStr> {
228        self.startup_ready_event.as_deref()
229    }
230}
231
232/// Startup config loading policy for a daemon.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct ConfigLoadOptions {
235    selection: ConfigFileSelection,
236    quiet: bool,
237    cwd: Option<PathBuf>,
238}
239
240impl ConfigLoadOptions {
241    /// Builds a config policy that performs no startup config loading.
242    #[must_use]
243    pub const fn disabled() -> Self {
244        Self {
245            selection: ConfigFileSelection::Disabled,
246            quiet: true,
247            cwd: None,
248        }
249    }
250
251    /// Returns the selected config files mode.
252    #[must_use]
253    pub const fn selection(&self) -> &ConfigFileSelection {
254        &self.selection
255    }
256
257    /// Returns whether missing files should be suppressed.
258    #[must_use]
259    pub const fn quiet(&self) -> bool {
260        self.quiet
261    }
262
263    /// Returns the startup client's current working directory.
264    #[must_use]
265    pub fn cwd(&self) -> Option<&Path> {
266        self.cwd.as_deref()
267    }
268}
269
270/// Config file selection mode for daemon startup.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum ConfigFileSelection {
273    /// Do not load config files.
274    Disabled,
275    /// Load RMUX default config files, with a filtered tmux config fallback.
276    Default,
277    /// Load the explicit `-f` files in order.
278    Files(Vec<PathBuf>),
279}
280
281/// RMUX daemon launcher — call [`bind`](Self::bind) to start listening.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct ServerDaemon {
284    config: DaemonConfig,
285}
286
287#[derive(Debug, Clone)]
288pub(crate) struct ShutdownHandle {
289    sender: Arc<StdMutex<Option<oneshot::Sender<()>>>>,
290}
291
292impl ShutdownHandle {
293    pub(crate) fn new() -> (Self, oneshot::Receiver<()>) {
294        let (sender, receiver) = oneshot::channel();
295        (
296            Self {
297                sender: Arc::new(StdMutex::new(Some(sender))),
298            },
299            receiver,
300        )
301    }
302
303    pub(crate) fn request_shutdown(&self) {
304        if let Some(sender) = self.sender.lock().expect("shutdown sender").take() {
305            let _ = sender.send(());
306        }
307    }
308}
309
310impl ServerDaemon {
311    /// Creates a daemon launcher for the given configuration.
312    #[must_use]
313    pub fn new(config: DaemonConfig) -> Self {
314        Self { config }
315    }
316
317    /// Binds the local IPC endpoint, starts accepting requests, and returns a handle.
318    pub async fn bind(self) -> io::Result<ServerHandle> {
319        #[cfg(unix)]
320        {
321            let bound_listener = bind_unix_listener_at(self.config.socket_path())?;
322            let (shutdown_handle, shutdown_receiver) = ShutdownHandle::new();
323            let signal_watcher = crate::signals::SignalWatcher::install()?;
324            let socket_path = self.config.socket_path().to_path_buf();
325            let owner_uid = real_user_id()?;
326            let serve_options = ServeOptions::new(
327                self.config.config_load().clone(),
328                self.config.subscription_limits(),
329                owner_uid,
330            )
331            .with_web_options(
332                self.config.web_port(),
333                self.config.web_frontend().map(str::to_owned),
334                self.config.web_required(),
335                self.config.web_port_explicit(),
336            )
337            .with_socket_identity(bound_listener.identity)
338            .with_server_signals(signal_watcher);
339
340            let task = tokio::spawn(listener::serve(
341                bound_listener.listener,
342                socket_path.clone(),
343                shutdown_handle.clone(),
344                shutdown_receiver,
345                serve_options,
346            ));
347            if let Some(ready_fd) = self.config.startup_ready_fd() {
348                signal_startup_ready_fd(ready_fd);
349            }
350
351            Ok(ServerHandle {
352                socket_path,
353                shutdown_handle,
354                task: Some(task),
355            })
356        }
357
358        #[cfg(windows)]
359        {
360            let endpoint = LocalEndpoint::from_path(self.config.socket_path().to_path_buf());
361            let listener = bind_windows_listener(&endpoint)?;
362            let (shutdown_handle, shutdown_receiver) = ShutdownHandle::new();
363            let socket_path = self.config.socket_path().to_path_buf();
364            let owner_uid = current_owner_uid();
365            let serve_options = ServeOptions::new(
366                self.config.config_load().clone(),
367                self.config.subscription_limits(),
368                owner_uid,
369            )
370            .with_web_options(
371                self.config.web_port(),
372                self.config.web_frontend().map(str::to_owned),
373                self.config.web_required(),
374                self.config.web_port_explicit(),
375            );
376
377            let task = tokio::spawn(listener::serve(
378                listener,
379                socket_path.clone(),
380                shutdown_handle.clone(),
381                shutdown_receiver,
382                serve_options,
383            ));
384            if let Some(ready_event) = self.config.startup_ready_event() {
385                signal_startup_ready_event(ready_event);
386            }
387
388            Ok(ServerHandle {
389                socket_path,
390                shutdown_handle,
391                task: Some(task),
392            })
393        }
394    }
395}
396
397#[cfg(unix)]
398fn signal_startup_ready_fd(_ready_fd: i32) {
399    #[cfg(target_os = "linux")]
400    {
401        let _ = rmux_os::daemon::signal_startup_ready_fd(_ready_fd);
402    }
403}
404
405#[cfg(windows)]
406fn signal_startup_ready_event(ready_event: &OsStr) {
407    let _ = rmux_os::daemon::signal_startup_ready_event(ready_event);
408}
409
410#[cfg(windows)]
411fn bind_windows_listener(endpoint: &LocalEndpoint) -> io::Result<LocalListener> {
412    match LocalListener::bind(endpoint) {
413        Ok(listener) => Ok(listener),
414        Err(bind_error) => Err(windows_bind_error(endpoint, bind_error)),
415    }
416}
417
418#[cfg(windows)]
419fn windows_bind_error(endpoint: &LocalEndpoint, bind_error: io::Error) -> io::Error {
420    if windows_pipe_responds(endpoint) {
421        return io::Error::new(
422            io::ErrorKind::AddrInUse,
423            format!(
424                "Windows named pipe '{}' is already held by a responsive rmux-compatible server",
425                endpoint.as_path().display()
426            ),
427        );
428    }
429
430    io::Error::new(
431        bind_error.kind(),
432        format!(
433            "failed to bind Windows named pipe '{}': {bind_error}. Another process may still be holding this endpoint",
434            endpoint.as_path().display()
435        ),
436    )
437}
438
439#[cfg(windows)]
440fn windows_pipe_responds(endpoint: &LocalEndpoint) -> bool {
441    let endpoint = endpoint.clone();
442    std::thread::spawn(move || windows_protocol_probe(&endpoint).unwrap_or(false))
443        .join()
444        .unwrap_or(false)
445}
446
447#[cfg(windows)]
448fn windows_protocol_probe(endpoint: &LocalEndpoint) -> io::Result<bool> {
449    let mut stream = connect_blocking(endpoint, Duration::from_millis(100))?;
450    stream.set_write_timeout(Some(Duration::from_millis(100)))?;
451    stream.set_read_timeout(Some(Duration::from_millis(100)))?;
452
453    let request = Request::HasSession(HasSessionRequest {
454        target: SessionName::new("__rmux_probe__").map_err(io::Error::other)?,
455    });
456    let frame = encode_frame(&request).map_err(io::Error::other)?;
457    stream.write_all(&frame)?;
458    stream.flush()?;
459
460    let mut decoder = FrameDecoder::new();
461    let mut buffer = [0_u8; 512];
462    loop {
463        let bytes_read = match stream.read(&mut buffer) {
464            Ok(0) => return Ok(false),
465            Ok(bytes_read) => bytes_read,
466            Err(error) if error.kind() == io::ErrorKind::TimedOut => return Ok(false),
467            Err(error) => return Err(error),
468        };
469        decoder.push_bytes(&buffer[..bytes_read]);
470        match decoder.next_frame::<Response>() {
471            Ok(Some(Response::HasSession(_))) => return Ok(true),
472            Ok(Some(_response)) => return Ok(false),
473            Ok(None) => continue,
474            Err(RmuxError::IncompleteFrame { .. }) => continue,
475            Err(_error) => return Ok(false),
476        }
477    }
478}
479
480/// Handle to a running RMUX daemon; dropping it triggers shutdown.
481#[derive(Debug)]
482pub struct ServerHandle {
483    socket_path: PathBuf,
484    shutdown_handle: ShutdownHandle,
485    task: Option<JoinHandle<io::Result<()>>>,
486}
487
488impl ServerHandle {
489    /// Returns the bound local IPC endpoint path for the running daemon.
490    #[must_use]
491    pub fn socket_path(&self) -> &Path {
492        &self.socket_path
493    }
494
495    /// Waits for the daemon task to exit after an external shutdown request.
496    pub async fn wait(mut self) -> io::Result<()> {
497        if let Some(task) = self.task.take() {
498            return task.await.map_err(io::Error::other)?;
499        }
500
501        Ok(())
502    }
503
504    /// Requests shutdown and waits for socket cleanup to complete.
505    pub async fn shutdown(mut self) -> io::Result<()> {
506        self.request_shutdown();
507
508        if let Some(task) = self.task.take() {
509            return task.await.map_err(io::Error::other)?;
510        }
511
512        Ok(())
513    }
514
515    fn request_shutdown(&mut self) {
516        self.shutdown_handle.request_shutdown();
517    }
518}
519
520impl Drop for ServerHandle {
521    fn drop(&mut self) {
522        self.request_shutdown();
523    }
524}
525
526#[cfg(all(test, unix))]
527#[path = "daemon_tests/unix.rs"]
528mod tests;
529
530#[cfg(all(test, windows))]
531#[path = "daemon_tests/windows.rs"]
532mod tests;