Skip to main content

lean_ctx/ipc/
detached.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Detached spawn of a long-lived background service (daemon, proxy,
3//! background Cockpit) so it survives the caller and holds nothing of it.
4//!
5//! On Unix a child simply outlives its parent: stdin/stdout are null, stderr
6//! is null or a log file, and the service leads its own process group
7//! (`process_group(0)`). Without that it stays in the spawner's group: a
8//! terminal's Ctrl-C reaches it, and every later CLI call from the same group
9//! (a script, CI, an app that spawns both `serve --daemon` and `serve --stop`)
10//! counts it among its own protected processes, so `serve --status` called it
11//! not running and `serve --stop` could not verify it.
12//!
13//! On Windows a child created through `std::process::Command` inherits two
14//! things a background service must not keep (#2176):
15//!
16//! - **Every inheritable handle of the caller.** std always passes
17//!   `bInheritHandles = TRUE`, so a pipe a shell gave `lean-ctx restart` (for
18//!   example cmd's stdout pipe in `cmd /c "lean-ctx restart | findstr ."`)
19//!   stays open in the daemon and the reader never sees EOF. The spawn below
20//!   uses `CreateProcessW` with `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`: the child
21//!   inherits exactly its three std handles (NUL, NUL, NUL or the log file)
22//!   and nothing else.
23//! - **The caller's job object.** PowerShell's `Start-Process -Wait` and AI
24//!   clients run their children in a job; a service left in it ends with the
25//!   job or keeps `-Wait` waiting (GL #545, #2176). In order:
26//!   1. not in a job → a plain detached spawn;
27//!   2. the job allows breakaway → `CREATE_BREAKAWAY_FROM_JOB`;
28//!   3. the job forbids it (PowerShell's jobs do: `LimitFlags = 0`) → the
29//!      service is created with this user's Explorer of this session as its
30//!      parent (`PROC_THREAD_ATTRIBUTE_PARENT_PROCESS`), the same way a
31//!      program started from the Start menu runs. Explorer is accepted only
32//!      when it is `%SystemRoot%\explorer.exe`, runs as this user in this
33//!      session and is itself in no job. The service then gets Explorer's
34//!      token: an elevated caller starts an unelevated service, which is
35//!      what the service needs (same user, same data dirs);
36//!   4. no such Explorer (a service session, Server Core) → the service stays
37//!      in the job, and one stderr line says so instead of a silent hang.
38
39use std::io;
40use std::process::{Command, ExitStatus};
41
42/// Where a detached service writes its stderr; stdin and stdout are always
43/// the null device.
44pub enum DetachedStderr {
45    Null,
46    File(std::fs::File),
47}
48
49/// A started background service. Dropping it neither waits for nor ends
50/// the service.
51pub struct DetachedChild {
52    pid: u32,
53    #[cfg(not(windows))]
54    child: std::process::Child,
55    #[cfg(windows)]
56    process: win::Owned,
57}
58
59impl DetachedChild {
60    #[must_use]
61    pub fn id(&self) -> u32 {
62        self.pid
63    }
64
65    /// The exit status once the service has ended, without waiting.
66    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
67        #[cfg(not(windows))]
68        {
69            self.child.try_wait()
70        }
71        #[cfg(windows)]
72        {
73            win::try_wait(&self.process)
74        }
75    }
76
77    /// End the service (tests and failed starts only).
78    pub fn kill(&mut self) -> io::Result<()> {
79        #[cfg(not(windows))]
80        {
81            self.child.kill()?;
82            self.child.wait().map(|_| ())
83        }
84        #[cfg(windows)]
85        {
86            win::terminate(&self.process)
87        }
88    }
89}
90
91/// Spawn `cmd` as a detached background service (see the module docs).
92/// Only the program, arguments, environment changes and working directory of
93/// `cmd` are used; its stdio settings are replaced.
94pub fn spawn_detached(cmd: &mut Command, stderr: DetachedStderr) -> io::Result<DetachedChild> {
95    #[cfg(not(windows))]
96    {
97        let stderr = match stderr {
98            DetachedStderr::Null => std::process::Stdio::null(),
99            DetachedStderr::File(file) => std::process::Stdio::from(file),
100        };
101        cmd.stdin(std::process::Stdio::null())
102            .stdout(std::process::Stdio::null())
103            .stderr(stderr);
104        #[cfg(unix)]
105        {
106            use std::os::unix::process::CommandExt;
107            cmd.process_group(0);
108        }
109        let child = cmd.spawn()?;
110        Ok(DetachedChild {
111            pid: child.id(),
112            child,
113        })
114    }
115    #[cfg(windows)]
116    {
117        win::spawn(cmd, stderr)
118    }
119}
120
121const JOB_OBJECT_LIMIT_BREAKAWAY_OK: u32 = 0x0800;
122const JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK: u32 = 0x1000;
123
124/// How one spawn attempt leaves the caller's job object.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126#[cfg_attr(not(windows), allow(dead_code))]
127enum Detach {
128    /// The caller is in no job.
129    Plain,
130    /// `CREATE_BREAKAWAY_FROM_JOB`.
131    Breakaway,
132    /// Created with the session's Explorer as parent, outside every job.
133    Reparent,
134    /// Left in the caller's job (last resort, announced on stderr).
135    StayInJob,
136}
137
138/// The attempts, in order, for a caller `in_job` whose job has
139/// `limit_flags`, with or without a usable Explorer broker. A breakaway the
140/// innermost job allows can still be refused by an outer one, so the broker
141/// and the in-job spawn follow it.
142#[cfg_attr(not(windows), allow(dead_code))]
143fn detach_attempts(in_job: bool, limit_flags: u32, broker: bool) -> Vec<Detach> {
144    if !in_job {
145        return vec![Detach::Plain];
146    }
147    let mut attempts = Vec::with_capacity(3);
148    if limit_flags & (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) != 0 {
149        attempts.push(Detach::Breakaway);
150    }
151    if broker {
152        attempts.push(Detach::Reparent);
153    }
154    attempts.push(Detach::StayInJob);
155    attempts
156}
157
158/// Append `arg` to a Windows command line so the MSVC runtime (and Rust's
159/// own argument parser) reads it back unchanged.
160#[cfg_attr(not(windows), allow(dead_code))]
161fn append_windows_arg(out: &mut Vec<u16>, arg: &[u16]) {
162    const QUOTE: u16 = b'"' as u16;
163    const BACKSLASH: u16 = b'\\' as u16;
164    let needs_quotes = arg.is_empty()
165        || arg
166            .iter()
167            .any(|&c| matches!(c, 0x20 | 0x09 | 0x0a | 0x0b) || c == QUOTE);
168    if !needs_quotes {
169        out.extend_from_slice(arg);
170        return;
171    }
172    out.push(QUOTE);
173    let mut backslashes = 0_usize;
174    for &c in arg {
175        if c == BACKSLASH {
176            backslashes += 1;
177            continue;
178        }
179        if c == QUOTE {
180            out.extend(std::iter::repeat_n(BACKSLASH, backslashes * 2 + 1));
181        } else {
182            out.extend(std::iter::repeat_n(BACKSLASH, backslashes));
183        }
184        out.push(c);
185        backslashes = 0;
186    }
187    out.extend(std::iter::repeat_n(BACKSLASH, backslashes * 2));
188    out.push(QUOTE);
189}
190
191/// The NUL-terminated command line for `program` and `args`. The program is
192/// always quoted (paths with spaces; a program name has no escape rules).
193#[cfg_attr(not(windows), allow(dead_code))]
194fn windows_command_line(program: &[u16], args: &[Vec<u16>]) -> Vec<u16> {
195    let mut line = Vec::with_capacity(program.len() + 2 + args.iter().map(Vec::len).sum::<usize>());
196    line.push(u16::from(b'"'));
197    line.extend(program.iter().copied().filter(|&c| c != u16::from(b'"')));
198    line.push(u16::from(b'"'));
199    for arg in args {
200        line.push(u16::from(b' '));
201        append_windows_arg(&mut line, arg);
202    }
203    line.push(0);
204    line
205}
206
207/// A `CREATE_UNICODE_ENVIRONMENT` block: this process's environment with
208/// the command's changes (`None` removes a variable), keys compared without
209/// case as Windows does, sorted, `KEY=VALUE\0…\0`.
210#[cfg_attr(not(windows), allow(dead_code))]
211fn environment_block(
212    base: Vec<(Vec<u16>, Vec<u16>)>,
213    overrides: Vec<(Vec<u16>, Option<Vec<u16>>)>,
214) -> Vec<u16> {
215    let fold = |key: &[u16]| String::from_utf16_lossy(key).to_uppercase();
216    let mut vars: std::collections::BTreeMap<String, (Vec<u16>, Vec<u16>)> = base
217        .into_iter()
218        .filter(|(key, _)| !key.is_empty())
219        .map(|(key, value)| (fold(&key), (key, value)))
220        .collect();
221    for (key, value) in overrides {
222        match value {
223            Some(value) => {
224                vars.insert(fold(&key), (key, value));
225            }
226            None => {
227                vars.remove(&fold(&key));
228            }
229        }
230    }
231    let mut block = Vec::new();
232    for (key, value) in vars.into_values() {
233        block.extend_from_slice(&key);
234        block.push(u16::from(b'='));
235        block.extend_from_slice(&value);
236        block.push(0);
237    }
238    if block.is_empty() {
239        block.push(0);
240    }
241    block.push(0);
242    block
243}
244
245#[cfg(windows)]
246mod win {
247    use super::{Detach, DetachedChild, DetachedStderr};
248    use std::ffi::OsStr;
249    use std::io;
250    use std::os::windows::ffi::OsStrExt;
251    use std::os::windows::io::AsRawHandle;
252    use std::process::{Command, ExitStatus};
253    use windows_sys::Win32::Foundation::{
254        CloseHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE,
255        INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
256    };
257    use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TokenSessionId};
258    use windows_sys::Win32::System::JobObjects::{
259        IsProcessInJob, JOBOBJECT_BASIC_LIMIT_INFORMATION, JobObjectBasicLimitInformation,
260        QueryInformationJobObject,
261    };
262    use windows_sys::Win32::System::Threading::{
263        CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_UNICODE_ENVIRONMENT,
264        CreateProcessW, DETACHED_PROCESS, DeleteProcThreadAttributeList,
265        EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetExitCodeProcess,
266        InitializeProcThreadAttributeList, OpenProcess, OpenProcessToken,
267        PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
268        PROCESS_CREATE_PROCESS, PROCESS_DUP_HANDLE, PROCESS_INFORMATION,
269        PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, STARTF_USESTDHANDLES,
270        STARTUPINFOEXW, STARTUPINFOW, TerminateProcess, UpdateProcThreadAttribute,
271        WaitForSingleObject,
272    };
273
274    /// A kernel handle closed on drop.
275    pub(in crate::ipc) struct Owned(HANDLE);
276
277    impl Drop for Owned {
278        fn drop(&mut self) {
279            if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE {
280                // SAFETY: the handle is owned by this value and closed once.
281                unsafe { CloseHandle(self.0) };
282            }
283        }
284    }
285
286    pub(super) fn try_wait(process: &Owned) -> io::Result<Option<ExitStatus>> {
287        use std::os::windows::process::ExitStatusExt;
288        // SAFETY: a valid process handle; a zero timeout never blocks.
289        if unsafe { WaitForSingleObject(process.0, 0) } != WAIT_OBJECT_0 {
290            return Ok(None);
291        }
292        let mut code = 0_u32;
293        // SAFETY: valid process handle and a writable out-pointer.
294        if unsafe { GetExitCodeProcess(process.0, &raw mut code) } == 0 {
295            return Err(io::Error::last_os_error());
296        }
297        Ok(Some(ExitStatus::from_raw(code)))
298    }
299
300    pub(super) fn terminate(process: &Owned) -> io::Result<()> {
301        // SAFETY: the handle came from CreateProcessW with full access.
302        if unsafe { TerminateProcess(process.0, 1) } == 0 {
303            return Err(io::Error::last_os_error());
304        }
305        Ok(())
306    }
307
308    fn wide(s: &OsStr) -> Vec<u16> {
309        s.encode_wide().collect()
310    }
311
312    pub(super) fn spawn(cmd: &Command, stderr: DetachedStderr) -> io::Result<DetachedChild> {
313        let program = wide(cmd.get_program());
314        let args: Vec<Vec<u16>> = cmd.get_args().map(wide).collect();
315        let command_line = super::windows_command_line(&program, &args);
316        let env = super::environment_block(
317            std::env::vars_os()
318                .map(|(k, v)| (wide(&k), wide(&v)))
319                .collect(),
320            cmd.get_envs()
321                .map(|(k, v)| (wide(k), v.map(wide)))
322                .collect(),
323        );
324        let cwd = cmd.get_current_dir().map(|dir| {
325            let mut w = wide(dir.as_os_str());
326            w.push(0);
327            w
328        });
329        let null_device = || {
330            std::fs::OpenOptions::new()
331                .read(true)
332                .write(true)
333                .open("NUL")
334        };
335        let stdin = null_device()?;
336        let stdout = null_device()?;
337        let stderr = match stderr {
338            DetachedStderr::Null => null_device()?,
339            DetachedStderr::File(file) => file,
340        };
341        let std_handles: [HANDLE; 3] = [
342            stdin.as_raw_handle(),
343            stdout.as_raw_handle(),
344            stderr.as_raw_handle(),
345        ];
346
347        let (in_job, limit_flags) = job_limits();
348        let broker = if in_job { session_broker() } else { None };
349        let mut last_error = None;
350        for attempt in super::detach_attempts(in_job, limit_flags, broker.is_some()) {
351            let parent = if attempt == Detach::Reparent {
352                broker.as_ref()
353            } else {
354                None
355            };
356            match create(
357                &command_line,
358                &env,
359                cwd.as_deref(),
360                std_handles,
361                parent,
362                attempt,
363            ) {
364                Ok(child) => {
365                    if attempt == Detach::StayInJob {
366                        eprintln!(
367                            "lean-ctx: started {} (PID {}) inside the caller's job object: the job forbids breakaway and this session has no Explorer to start it from, so it ends with that job (and `Start-Process -Wait` waits for it).",
368                            cmd.get_program().to_string_lossy(),
369                            child.pid
370                        );
371                    }
372                    return Ok(child);
373                }
374                Err(error) => last_error = Some(error),
375            }
376        }
377        Err(last_error.unwrap_or_else(|| io::Error::other("no spawn attempt")))
378    }
379
380    /// Whether this process is in a job, and that job's limit flags (0 when
381    /// they cannot be read: breakaway is then treated as forbidden).
382    fn job_limits() -> (bool, u32) {
383        let mut in_job = 0;
384        // SAFETY: pseudo handle of this process, null job (= any job), and a
385        // writable BOOL.
386        let ok =
387            unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &raw mut in_job) };
388        if ok == 0 {
389            // Unknown: keep the earlier behaviour (try breakaway first).
390            return (true, super::JOB_OBJECT_LIMIT_BREAKAWAY_OK);
391        }
392        if in_job == 0 {
393            return (false, 0);
394        }
395        // SAFETY: plain-data struct; zeroed is a valid value.
396        let mut info: JOBOBJECT_BASIC_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
397        let size =
398            u32::try_from(std::mem::size_of::<JOBOBJECT_BASIC_LIMIT_INFORMATION>()).unwrap_or(0);
399        // SAFETY: null job = this process's job; `info` is writable for `size`.
400        let ok = unsafe {
401            QueryInformationJobObject(
402                std::ptr::null_mut(),
403                JobObjectBasicLimitInformation,
404                (&raw mut info).cast(),
405                size,
406                std::ptr::null_mut(),
407            )
408        };
409        (true, if ok == 0 { 0 } else { info.LimitFlags })
410    }
411
412    fn token_session(process: HANDLE) -> Option<u32> {
413        let mut token = std::ptr::null_mut();
414        // SAFETY: `token` is a writable slot; it is closed below.
415        if unsafe { OpenProcessToken(process, TOKEN_QUERY, &raw mut token) } == 0 {
416            return None;
417        }
418        let token = Owned(token);
419        let mut session = 0_u32;
420        let mut len = 0_u32;
421        // SAFETY: TokenSessionId writes one u32 into `session`.
422        let ok = unsafe {
423            GetTokenInformation(
424                token.0,
425                TokenSessionId,
426                (&raw mut session).cast(),
427                4,
428                &raw mut len,
429            )
430        };
431        (ok != 0).then_some(session)
432    }
433
434    fn image_path(process: HANDLE) -> Option<String> {
435        let mut path = vec![0_u16; 32_768];
436        let mut len = u32::try_from(path.len()).ok()?;
437        // SAFETY: `path` is writable for `len` UTF-16 units.
438        if unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &raw mut len) } == 0 {
439            return None;
440        }
441        Some(String::from_utf16_lossy(path.get(..len as usize)?))
442    }
443
444    fn explorer_path() -> Option<String> {
445        use windows_sys::Win32::System::SystemInformation::GetSystemWindowsDirectoryW;
446        let mut buf = vec![0_u16; 1024];
447        // SAFETY: `buf` is writable for its length.
448        let len =
449            unsafe { GetSystemWindowsDirectoryW(buf.as_mut_ptr(), u32::try_from(buf.len()).ok()?) }
450                as usize;
451        if len == 0 || len >= buf.len() {
452            return None;
453        }
454        let dir = String::from_utf16_lossy(&buf[..len]);
455        Some(format!("{}\\explorer.exe", dir.trim_end_matches('\\')))
456    }
457
458    /// This user's Explorer in this session, outside every job, opened with
459    /// the rights a parent needs. `None` when there is none.
460    fn session_broker() -> Option<Owned> {
461        use windows_sys::Win32::System::Diagnostics::ToolHelp::{
462            CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
463            TH32CS_SNAPPROCESS,
464        };
465        // SAFETY: pseudo handle of this process.
466        let me = unsafe { GetCurrentProcess() };
467        let my_session = token_session(me)?;
468        let my_sid = crate::ipc::process::windows_token_user_sid(me)?;
469        let expected = explorer_path()?;
470
471        // SAFETY: plain flags; the snapshot is closed by `Owned`.
472        let snapshot = Owned(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) });
473        if snapshot.0.is_null() || snapshot.0 == INVALID_HANDLE_VALUE {
474            return None;
475        }
476        // SAFETY: plain data; zeroed is a valid initial state.
477        let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
478        entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).ok()?;
479        // SAFETY: valid snapshot and a sized, writable entry.
480        let mut more = unsafe { Process32FirstW(snapshot.0, &raw mut entry) } != 0;
481        while more {
482            let len = entry
483                .szExeFile
484                .iter()
485                .position(|&c| c == 0)
486                .unwrap_or(entry.szExeFile.len());
487            let name = String::from_utf16_lossy(&entry.szExeFile[..len]);
488            if name.eq_ignore_ascii_case("explorer.exe")
489                && let Some(broker) =
490                    usable_broker(entry.th32ProcessID, my_session, &my_sid, &expected)
491            {
492                return Some(broker);
493            }
494            // SAFETY: as above.
495            more = unsafe { Process32NextW(snapshot.0, &raw mut entry) } != 0;
496        }
497        None
498    }
499
500    fn usable_broker(pid: u32, session: u32, sid: &[u8], expected_path: &str) -> Option<Owned> {
501        let access =
502            PROCESS_CREATE_PROCESS | PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION;
503        // SAFETY: integers only; a null result is rejected.
504        let handle = unsafe { OpenProcess(access, 0, pid) };
505        if handle.is_null() {
506            return None;
507        }
508        let handle = Owned(handle);
509        let mut in_job = 1;
510        // SAFETY: valid handle with query rights and a writable BOOL.
511        let job_ok =
512            unsafe { IsProcessInJob(handle.0, std::ptr::null_mut(), &raw mut in_job) } != 0;
513        let usable = job_ok
514            && in_job == 0
515            && token_session(handle.0) == Some(session)
516            && crate::ipc::process::windows_token_user_sid(handle.0).as_deref() == Some(sid)
517            && image_path(handle.0).is_some_and(|path| path.eq_ignore_ascii_case(expected_path));
518        usable.then_some(handle)
519    }
520
521    /// Copies of the std handles, inheritable, in the process that will be
522    /// the child's parent; closed there when dropped.
523    struct InheritedHandles {
524        owner: HANDLE,
525        handles: Vec<HANDLE>,
526    }
527
528    impl Drop for InheritedHandles {
529        fn drop(&mut self) {
530            for &handle in &self.handles {
531                // SAFETY: `handle` was duplicated into `owner` and is closed
532                // there exactly once; no target is requested.
533                unsafe {
534                    DuplicateHandle(
535                        self.owner,
536                        handle,
537                        std::ptr::null_mut(),
538                        std::ptr::null_mut(),
539                        0,
540                        0,
541                        DUPLICATE_CLOSE_SOURCE,
542                    );
543                }
544            }
545        }
546    }
547
548    /// An initialized `PROC_THREAD_ATTRIBUTE_LIST`, deleted on drop.
549    struct AttributeList {
550        buf: Vec<u64>,
551    }
552
553    impl AttributeList {
554        fn new(count: u32) -> io::Result<Self> {
555            let mut size = 0_usize;
556            // SAFETY: a null list only reports the required size.
557            unsafe {
558                InitializeProcThreadAttributeList(std::ptr::null_mut(), count, 0, &raw mut size)
559            };
560            if size == 0 {
561                return Err(io::Error::last_os_error());
562            }
563            let mut buf = vec![0_u64; size.div_ceil(8)];
564            // SAFETY: `buf` is writable for at least `size` bytes.
565            if unsafe {
566                InitializeProcThreadAttributeList(buf.as_mut_ptr().cast(), count, 0, &raw mut size)
567            } == 0
568            {
569                return Err(io::Error::last_os_error());
570            }
571            Ok(Self { buf })
572        }
573
574        fn ptr(&mut self) -> *mut core::ffi::c_void {
575            self.buf.as_mut_ptr().cast()
576        }
577
578        /// SAFETY: `value` must stay valid and unmoved until the list is
579        /// dropped (it is read by `CreateProcessW`).
580        unsafe fn set(
581            &mut self,
582            attribute: u32,
583            value: *const core::ffi::c_void,
584            size: usize,
585        ) -> io::Result<()> {
586            // SAFETY: an initialized list; caller guarantees `value`.
587            let ok = unsafe {
588                UpdateProcThreadAttribute(
589                    self.ptr(),
590                    0,
591                    attribute as usize,
592                    value,
593                    size,
594                    std::ptr::null_mut(),
595                    std::ptr::null(),
596                )
597            };
598            if ok == 0 {
599                return Err(io::Error::last_os_error());
600            }
601            Ok(())
602        }
603    }
604
605    impl Drop for AttributeList {
606        fn drop(&mut self) {
607            // SAFETY: the list was initialized in `new`.
608            unsafe { DeleteProcThreadAttributeList(self.ptr()) };
609        }
610    }
611
612    fn create(
613        command_line: &[u16],
614        env: &[u16],
615        cwd: Option<&[u16]>,
616        std_handles: [HANDLE; 3],
617        parent: Option<&Owned>,
618        attempt: Detach,
619    ) -> io::Result<DetachedChild> {
620        // SAFETY: pseudo handle of this process; needs no close.
621        let me = unsafe { GetCurrentProcess() };
622        // With a parent attribute the child inherits from that parent, so
623        // its handles must live in the parent's table.
624        let owner = parent.map_or(me, |p| p.0);
625        let mut inherited = InheritedHandles {
626            owner,
627            handles: Vec::with_capacity(3),
628        };
629        for handle in std_handles {
630            let mut copy = std::ptr::null_mut();
631            // SAFETY: `handle` is open in this process; `copy` is writable.
632            let ok = unsafe {
633                DuplicateHandle(
634                    me,
635                    handle,
636                    owner,
637                    &raw mut copy,
638                    0,
639                    1,
640                    DUPLICATE_SAME_ACCESS,
641                )
642            };
643            if ok == 0 {
644                return Err(io::Error::last_os_error());
645            }
646            inherited.handles.push(copy);
647        }
648
649        let mut attributes = AttributeList::new(if parent.is_some() { 2 } else { 1 })?;
650        // SAFETY: `inherited.handles` and `parent_handle` outlive
651        // `attributes` (declared before it is dropped at the end of scope,
652        // and neither is moved).
653        unsafe {
654            attributes.set(
655                PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
656                inherited.handles.as_ptr().cast(),
657                std::mem::size_of_val(inherited.handles.as_slice()),
658            )?;
659        }
660        let parent_handle: HANDLE = parent.map_or(std::ptr::null_mut(), |p| p.0);
661        if parent.is_some() {
662            // SAFETY: as above; the value is a HANDLE.
663            unsafe {
664                attributes.set(
665                    PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
666                    (&raw const parent_handle).cast(),
667                    std::mem::size_of::<HANDLE>(),
668                )?;
669            }
670        }
671
672        // SAFETY: plain data; zeroed is valid before the fields are set.
673        let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
674        startup.StartupInfo.cb = u32::try_from(std::mem::size_of::<STARTUPINFOEXW>()).unwrap_or(0);
675        startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
676        startup.StartupInfo.hStdInput = inherited.handles[0];
677        startup.StartupInfo.hStdOutput = inherited.handles[1];
678        startup.StartupInfo.hStdError = inherited.handles[2];
679        startup.lpAttributeList = attributes.ptr();
680
681        let mut flags = DETACHED_PROCESS
682            | CREATE_NEW_PROCESS_GROUP
683            | CREATE_UNICODE_ENVIRONMENT
684            | EXTENDED_STARTUPINFO_PRESENT;
685        if attempt == Detach::Breakaway {
686            flags |= CREATE_BREAKAWAY_FROM_JOB;
687        }
688        // CreateProcessW may write to the command line buffer.
689        let mut line = command_line.to_vec();
690        // SAFETY: zeroed out-struct.
691        let mut info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
692        // SAFETY: every pointer refers to live, NUL-terminated or sized data
693        // owned by this frame; the handle list and parent are in `attributes`.
694        let ok = unsafe {
695            CreateProcessW(
696                std::ptr::null(),
697                line.as_mut_ptr(),
698                std::ptr::null(),
699                std::ptr::null(),
700                1,
701                flags,
702                env.as_ptr().cast(),
703                cwd.map_or(std::ptr::null(), <[u16]>::as_ptr),
704                (&raw const startup).cast::<STARTUPINFOW>(),
705                &raw mut info,
706            )
707        };
708        if ok == 0 {
709            return Err(io::Error::last_os_error());
710        }
711        drop(Owned(info.hThread));
712        Ok(DetachedChild {
713            pid: info.dwProcessId,
714            process: Owned(info.hProcess),
715        })
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn w(s: &str) -> Vec<u16> {
724        s.encode_utf16().collect()
725    }
726
727    fn arg(s: &str) -> String {
728        let mut out = Vec::new();
729        append_windows_arg(&mut out, &w(s));
730        String::from_utf16_lossy(&out)
731    }
732
733    #[test]
734    fn windows_args_round_trip_msvc_quoting() {
735        assert_eq!(arg("serve"), "serve");
736        assert_eq!(arg("--port=4444"), "--port=4444");
737        assert_eq!(arg(""), "\"\"");
738        assert_eq!(arg("a b"), "\"a b\"");
739        assert_eq!(
740            arg("C:\\Program Files\\x\\"),
741            "\"C:\\Program Files\\x\\\\\""
742        );
743        assert_eq!(arg("say \"hi\""), "\"say \\\"hi\\\"\"");
744        assert_eq!(arg("a\\\"b c"), "\"a\\\\\\\"b c\"");
745        assert_eq!(arg("C:\\no\\space"), "C:\\no\\space");
746    }
747
748    #[test]
749    fn command_line_quotes_the_program_and_ends_with_nul() {
750        let line = windows_command_line(
751            &w("C:\\Program Files\\lean-ctx.exe"),
752            &[w("proxy"), w("start")],
753        );
754        assert_eq!(line.last(), Some(&0));
755        assert_eq!(
756            String::from_utf16_lossy(&line[..line.len() - 1]),
757            "\"C:\\Program Files\\lean-ctx.exe\" proxy start"
758        );
759    }
760
761    #[test]
762    fn environment_block_applies_changes_case_insensitively() {
763        let block = environment_block(
764            vec![
765                (w("Path"), w("C:\\bin")),
766                (w("SECRET"), w("x")),
767                (w("b"), w("1")),
768            ],
769            vec![
770                (w("PATH"), Some(w("D:\\bin"))),
771                (w("secret"), None),
772                (w("LEAN_CTX_TCC_STANDALONE"), Some(w("1"))),
773            ],
774        );
775        let text = String::from_utf16_lossy(&block);
776        assert_eq!(text, "b=1\0LEAN_CTX_TCC_STANDALONE=1\0PATH=D:\\bin\0\0");
777        assert_eq!(environment_block(Vec::new(), Vec::new()), vec![0, 0]);
778    }
779
780    #[test]
781    fn job_without_breakaway_uses_the_broker_then_stays_announced() {
782        assert_eq!(detach_attempts(false, 0, true), vec![Detach::Plain]);
783        // PowerShell's Start-Process / Start-Job job: LimitFlags = 0.
784        assert_eq!(
785            detach_attempts(true, 0, true),
786            vec![Detach::Reparent, Detach::StayInJob]
787        );
788        assert_eq!(detach_attempts(true, 0, false), vec![Detach::StayInJob]);
789        // sshd's job: KILL_ON_JOB_CLOSE | BREAKAWAY_OK (0x2800).
790        assert_eq!(
791            detach_attempts(true, 0x2800, true),
792            vec![Detach::Breakaway, Detach::Reparent, Detach::StayInJob]
793        );
794        assert_eq!(
795            detach_attempts(true, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, false),
796            vec![Detach::Breakaway, Detach::StayInJob]
797        );
798    }
799
800    /// A detached service never holds the caller's stdout (#2176 d).
801    #[cfg(unix)]
802    #[test]
803    fn detached_spawn_does_not_hold_the_callers_stdout() {
804        let mut sleeper = Command::new("sleep");
805        sleeper.arg("30").stdout(std::process::Stdio::inherit());
806        let mut child = spawn_detached(&mut sleeper, DetachedStderr::Null).expect("spawn sleep");
807        let pid = child.id();
808        let out = Command::new("lsof")
809            .args(["-a", "-p", &pid.to_string(), "-d", "1", "-Fn"])
810            .output();
811        let _ = child.kill();
812        if let Ok(out) = out
813            && out.status.success()
814        {
815            let text = String::from_utf8_lossy(&out.stdout);
816            assert!(text.contains("/dev/null"), "fd 1 of the service: {text}");
817        }
818    }
819
820    /// A detached service leads its own process group, so that the caller's
821    /// group (a terminal job, a script, an app) neither signals it nor counts
822    /// it among the caller's protected processes.
823    #[cfg(unix)]
824    #[test]
825    fn detached_spawn_leads_its_own_process_group() {
826        let mut sleeper = Command::new("sleep");
827        sleeper.arg("30");
828        let mut child = spawn_detached(&mut sleeper, DetachedStderr::Null).expect("spawn sleep");
829        let pid = child.id();
830        let pid_t = libc::pid_t::try_from(pid).expect("pid fits pid_t");
831        // SAFETY: getpgid takes a pid and only reads process state.
832        let child_pgid = unsafe { libc::getpgid(pid_t) };
833        // SAFETY: getpgrp() takes no arguments and cannot fail.
834        let own_pgid = unsafe { libc::getpgrp() };
835        let _ = child.kill();
836        assert_eq!(child_pgid, pid_t, "the service leads its own group");
837        assert_ne!(child_pgid, own_pgid, "the service left the caller's group");
838    }
839
840    /// The raw Windows spawn runs the program with its arguments, sets the
841    /// environment change, and reports the exit code.
842    #[cfg(windows)]
843    #[test]
844    fn windows_detached_spawn_runs_and_reports_exit() {
845        let mut cmd = Command::new("cmd.exe");
846        cmd.args(["/c", "if \"%LCX_T%\"==\"ok\" (exit 7) else (exit 3)"])
847            .env("LCX_T", "ok");
848        let mut child = spawn_detached(&mut cmd, DetachedStderr::Null).expect("spawn cmd");
849        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
850        loop {
851            if let Some(status) = child.try_wait().expect("try_wait") {
852                assert_eq!(status.code(), Some(7));
853                break;
854            }
855            assert!(std::time::Instant::now() < deadline, "cmd did not exit");
856            std::thread::sleep(std::time::Duration::from_millis(50));
857        }
858    }
859}