Skip to main content

term_session/
auto_spawn.rs

1use std::fmt;
2use std::io;
3use std::thread;
4use std::time::{Duration, Instant};
5
6use term_session_muxio_service_definitions::{
7    ChannelName, gateway_channel_name, probe_ipc_endpoint,
8};
9
10#[cfg(unix)]
11use std::process::{Child, Command, Stdio};
12
13/// Resolve the gateway channel name to probe/spawn.
14/// Uses the runtime `TERM_WM_GATEWAY` override if present, else the static
15/// user-scoped default (`term-wm/<user>/gateway`).
16pub fn resolve_gateway() -> ChannelName {
17    gateway_channel_name()
18}
19
20/// Handle to a just-spawned daemon process, used to poll for early death during
21/// the gateway startup handshake.
22///
23/// Windows has no `setsid()`; the daemon there is spawned with a raw
24/// `CreateProcessW(..., bInheritHandles = FALSE, ...)` so it can never inherit
25/// the parent's console, pipes, or sockets — the analogue of the Unix
26/// detachment. Because `std::process::Child` cannot be built from a raw process
27/// handle on stable Rust, this wrapper holds either the std child (unix) or the
28/// raw process handles (windows) behind a common `try_wait`.
29enum DaemonChild {
30    #[cfg(unix)]
31    Unix(Child),
32    #[cfg(windows)]
33    Windows(WindowsDaemonProcess),
34}
35
36/// How a daemon process ended, rendered into the startup-failure message.
37enum DaemonExitStatus {
38    #[cfg(unix)]
39    Unix(std::process::ExitStatus),
40    #[cfg(windows)]
41    Windows(u32),
42}
43
44impl fmt::Display for DaemonExitStatus {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            #[cfg(unix)]
48            DaemonExitStatus::Unix(status) => write!(f, "{status}"),
49            #[cfg(windows)]
50            DaemonExitStatus::Windows(code) => write!(f, "exit code: {code}"),
51        }
52    }
53}
54
55impl DaemonChild {
56    /// Poll for daemon exit without blocking. Returns `Some` if the process has
57    /// already exited, `None` if it is still running.
58    fn try_wait(&mut self) -> io::Result<Option<DaemonExitStatus>> {
59        match self {
60            #[cfg(unix)]
61            DaemonChild::Unix(child) => Ok(child.try_wait()?.map(DaemonExitStatus::Unix)),
62            #[cfg(windows)]
63            DaemonChild::Windows(proc) => proc.try_wait(),
64        }
65    }
66}
67
68/// Owned Windows process handles for the detached daemon. `process` is polled
69/// for early exit; both handles are closed on drop.
70#[cfg(windows)]
71struct WindowsDaemonProcess {
72    process: windows_sys::Win32::Foundation::HANDLE,
73    thread: windows_sys::Win32::Foundation::HANDLE,
74}
75
76#[cfg(windows)]
77impl WindowsDaemonProcess {
78    fn try_wait(&mut self) -> io::Result<Option<DaemonExitStatus>> {
79        use windows_sys::Win32::Foundation::{WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT};
80        use windows_sys::Win32::System::Threading::{GetExitCodeProcess, WaitForSingleObject};
81        unsafe {
82            match WaitForSingleObject(self.process, 0) {
83                WAIT_TIMEOUT => Ok(None),
84                WAIT_OBJECT_0 => {
85                    let mut code = 0u32;
86                    if GetExitCodeProcess(self.process, &mut code) == 0 {
87                        return Err(io::Error::last_os_error());
88                    }
89                    Ok(Some(DaemonExitStatus::Windows(code)))
90                }
91                WAIT_FAILED => Err(io::Error::last_os_error()),
92                _ => Err(io::Error::last_os_error()),
93            }
94        }
95    }
96}
97
98#[cfg(windows)]
99impl Drop for WindowsDaemonProcess {
100    fn drop(&mut self) {
101        use windows_sys::Win32::Foundation::CloseHandle;
102        unsafe {
103            let _ = CloseHandle(self.process);
104            let _ = CloseHandle(self.thread);
105        }
106    }
107}
108
109fn spawn_detached_server(bin: &std::path::Path) -> io::Result<DaemonChild> {
110    #[cfg(unix)]
111    {
112        unix_spawn_detached_server(bin)
113    }
114    #[cfg(windows)]
115    {
116        windows_spawn_detached_server(bin)
117    }
118    #[cfg(not(any(unix, windows)))]
119    {
120        Err(io::Error::new(
121            io::ErrorKind::Unsupported,
122            "daemon detachment is not supported on this platform",
123        ))
124    }
125}
126
127#[cfg(unix)]
128fn unix_spawn_detached_server(bin: &std::path::Path) -> io::Result<DaemonChild> {
129    use std::os::unix::process::CommandExt;
130    let mut cmd = Command::new(bin);
131    cmd.arg("--daemon");
132    // All stdio is detached: a daemon must not rely on the parent reading its
133    // pipes. In particular, a piped stderr that is never drained lets the OS
134    // pipe buffer fill, blocking the server's stderr writes and deadlocking
135    // startup on every platform. Discard it instead.
136    cmd.stdin(Stdio::null())
137        .stdout(Stdio::null())
138        .stderr(Stdio::null());
139    // Start the server in its own session and process group via setsid().
140    // This is the only process-group manipulation done here: a child that
141    // already became a process-group leader (e.g. via setpgid) would have
142    // setsid() fail with EPERM. Detaching from the launching terminal means
143    // the daemon can never freeze its input, and terminal Ctrl+C / Ctrl+Z /
144    // SIGHUP-on-close are never delivered to it.
145    unsafe {
146        cmd.pre_exec(|| {
147            if libc::setsid() == -1 {
148                return Err(std::io::Error::last_os_error());
149            }
150            Ok(())
151        });
152    }
153    cmd.spawn().map(DaemonChild::Unix)
154}
155
156#[cfg(windows)]
157fn windows_spawn_detached_server(bin: &std::path::Path) -> io::Result<DaemonChild> {
158    use std::os::windows::ffi::OsStrExt;
159    use windows_sys::Win32::Foundation::{
160        CloseHandle, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE,
161    };
162    use windows_sys::Win32::Storage::FileSystem::{
163        CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
164    };
165    use windows_sys::Win32::System::Threading::{
166        CREATE_NEW_PROCESS_GROUP, CreateProcessW, DETACHED_PROCESS, PROCESS_INFORMATION,
167        STARTF_USESTDHANDLES, STARTUPINFOW,
168    };
169
170    // The Unix side detaches via setsid(); on Windows the same guarantee needs
171    // bInheritHandles = FALSE, which std::process::Command never passes (it
172    // always spawns with TRUE so its own stdio handles are inherited, making
173    // CREATE_NO_INHERIT a no-op). Spawn with raw CreateProcessW instead:
174    // DETACHED_PROCESS removes the console (no CTRL_CLOSE_EVENT ever reaches
175    // the daemon) and CREATE_NEW_PROCESS_GROUP isolates it from Ctrl+C,
176    // mirroring the Unix session/process-group split.
177    let nul_path: Vec<u16> = "\\\\.\\NUL\0".encode_utf16().collect();
178    let nul_handle = unsafe {
179        CreateFileW(
180            nul_path.as_ptr(),
181            GENERIC_READ | GENERIC_WRITE,
182            FILE_SHARE_READ | FILE_SHARE_WRITE,
183            std::ptr::null(),
184            OPEN_EXISTING,
185            0,
186            std::ptr::null_mut(),
187        )
188    };
189    if nul_handle == INVALID_HANDLE_VALUE {
190        return Err(io::Error::last_os_error());
191    }
192
193    // Point stdio at NUL so the daemon's standard streams never block on an
194    // undrained pipe (the stderr-deadlock hazard noted on the unix side). With
195    // bInheritHandles = FALSE these handles are NOT inherited as stray handles;
196    // STARTF_USESTDHANDLES only selects them for the standard-handle slots.
197    let si = STARTUPINFOW {
198        cb: std::mem::size_of::<STARTUPINFOW>() as u32,
199        lpReserved: std::ptr::null_mut(),
200        lpDesktop: std::ptr::null_mut(),
201        lpTitle: std::ptr::null_mut(),
202        dwX: 0,
203        dwY: 0,
204        dwXSize: 0,
205        dwYSize: 0,
206        dwXCountChars: 0,
207        dwYCountChars: 0,
208        dwFillAttribute: 0,
209        dwFlags: STARTF_USESTDHANDLES,
210        wShowWindow: 0,
211        cbReserved2: 0,
212        lpReserved2: std::ptr::null_mut(),
213        hStdInput: nul_handle,
214        hStdOutput: nul_handle,
215        hStdError: nul_handle,
216    };
217
218    let mut program: Vec<u16> = bin.as_os_str().encode_wide().collect();
219    program.push(0);
220    // Quote argv[0] exactly like std::process does so spaces in the path are
221    // safe. lpApplicationName is set, so CreateProcessW uses it verbatim (no
222    // PATH search or extension appending). The command line embeds the program
223    // WITHOUT its trailing NUL (only the buffer's final terminator below).
224    let mut command_line: Vec<u16> = Vec::with_capacity(program.len() + 16);
225    command_line.push(b'"' as u16);
226    command_line.extend_from_slice(&program[..program.len() - 1]);
227    command_line.push(b'"' as u16);
228    command_line.extend(" --daemon".encode_utf16());
229    command_line.push(0);
230
231    let mut pi = PROCESS_INFORMATION {
232        hProcess: std::ptr::null_mut(),
233        hThread: std::ptr::null_mut(),
234        dwProcessId: 0,
235        dwThreadId: 0,
236    };
237
238    let ok = unsafe {
239        CreateProcessW(
240            program.as_ptr(),
241            command_line.as_mut_ptr(),
242            std::ptr::null(),
243            std::ptr::null(),
244            0, // bInheritHandles = FALSE
245            DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP,
246            std::ptr::null(),
247            std::ptr::null(),
248            &si as *const STARTUPINFOW,
249            &mut pi,
250        )
251    };
252    unsafe {
253        let _ = CloseHandle(nul_handle);
254    }
255    if ok == 0 {
256        return Err(io::Error::last_os_error());
257    }
258
259    Ok(DaemonChild::Windows(WindowsDaemonProcess {
260        process: pi.hProcess,
261        thread: pi.hThread,
262    }))
263}
264
265/// Wait for the gateway to become reachable, spawning a detached daemon if
266/// none is running.
267///
268/// Returns the gateway channel name string, which the caller passes to the
269/// muxio IPC client. `bin` defaults to the current executable so tests can
270/// point it at `CARGO_BIN_EXE_term-session`.
271pub fn connect_or_spawn_server(bin: Option<&std::path::Path>) -> io::Result<String> {
272    let gateway = resolve_gateway();
273    let socket_name = gateway.to_string();
274
275    if probe_ipc_endpoint(&gateway) {
276        return Ok(socket_name);
277    }
278
279    let bin = bin
280        .map(|b| b.to_path_buf())
281        .unwrap_or_else(|| std::env::current_exe().expect("current exe path"));
282    let mut child = spawn_detached_server(&bin)?;
283    let start = Instant::now();
284    let timeout = Duration::from_secs(3);
285    let poll_interval = Duration::from_millis(50);
286
287    while start.elapsed() < timeout {
288        if probe_ipc_endpoint(&gateway) {
289            return Ok(socket_name);
290        }
291        if let Ok(Some(status)) = child.try_wait() {
292            // The spawned daemon died before the socket came up. Another racer
293            // may have won the bind; re-probe before surfacing the failure.
294            if probe_ipc_endpoint(&gateway) {
295                return Ok(socket_name);
296            }
297            return Err(io::Error::new(
298                io::ErrorKind::ConnectionRefused,
299                format!("Gateway exited during startup with status: {status}"),
300            ));
301        }
302        thread::sleep(poll_interval);
303    }
304
305    Err(io::Error::new(
306        io::ErrorKind::TimedOut,
307        format!("Timed out waiting for gateway on channel '{gateway}'"),
308    ))
309}