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