1use std::io;
35use std::process::{Command, ExitStatus};
36
37pub enum DetachedStderr {
40 Null,
41 File(std::fs::File),
42}
43
44pub 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 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 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
86pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116#[cfg_attr(not(windows), allow(dead_code))]
117enum Detach {
118 Plain,
120 Breakaway,
122 Reparent,
124 StayInJob,
126}
127
128#[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#[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#[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#[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 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 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 if unsafe { WaitForSingleObject(process.0, 0) } != WAIT_OBJECT_0 {
280 return Ok(None);
281 }
282 let mut code = 0_u32;
283 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 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 fn job_limits() -> (bool, u32) {
373 let mut in_job = 0;
374 let ok =
377 unsafe { IsProcessInJob(GetCurrentProcess(), std::ptr::null_mut(), &raw mut in_job) };
378 if ok == 0 {
379 return (true, super::JOB_OBJECT_LIMIT_BREAKAWAY_OK);
381 }
382 if in_job == 0 {
383 return (false, 0);
384 }
385 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 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 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 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 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 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 fn session_broker() -> Option<Owned> {
451 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
452 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
453 TH32CS_SNAPPROCESS,
454 };
455 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 let snapshot = Owned(unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) });
463 if snapshot.0.is_null() || snapshot.0 == INVALID_HANDLE_VALUE {
464 return None;
465 }
466 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
468 entry.dwSize = u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).ok()?;
469 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 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 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 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 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 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 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 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 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 unsafe fn set(
571 &mut self,
572 attribute: u32,
573 value: *const core::ffi::c_void,
574 size: usize,
575 ) -> io::Result<()> {
576 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 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 let me = unsafe { GetCurrentProcess() };
612 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 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 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 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 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 let mut line = command_line.to_vec();
680 let mut info: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
682 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 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 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 #[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 #[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}