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