From 069fd91b8472b1f074e52d20d57066dd47c80db1 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 15 Oct 2026 13:16:50 -0700 Subject: [PATCH 1/2] fix(rust): allow explicit Windows job breakaway Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d460b2a4-7884-479b-842d-ccffc549a94d --- rust/README.md | 6 ++++-- rust/src/process_tree.rs | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/rust/README.md b/rust/README.md index f233c8b14e..83b8584319 100644 --- a/rust/README.md +++ b/rust/README.md @@ -127,8 +127,10 @@ transports. On Windows, SDK-spawned CLI processes start suspended and are assigned to a private kill-on-close Job Object before their initial thread resumes. Thread lookup captures only that child's process snapshot, rather than enumerating -machine-wide threads. The Job owns the CLI and its descendants until client -shutdown or final-client drop, including abrupt termination of the SDK host. +machine-wide threads. The Job cleans up the CLI and its ordinary descendants on +client shutdown, final-client drop, or abrupt termination of the SDK host. +Descendants explicitly launched with `CREATE_BREAKAWAY_FROM_JOB` can leave this +Job and survive its cleanup. **`ClientOptions`:** diff --git a/rust/src/process_tree.rs b/rust/src/process_tree.rs index 238854bcf9..5325cd216b 100644 --- a/rust/src/process_tree.rs +++ b/rust/src/process_tree.rs @@ -63,9 +63,9 @@ mod platform { PssWalkSnapshot, }; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, - SetInformationJobObject, TerminateJobObject, + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_BREAKAWAY_OK, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, }; use windows_sys::Win32::System::Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, GetCurrentProcess, GetProcessIdOfThread, OpenThread, @@ -160,7 +160,10 @@ mod platform { let job = OwnedHandle(raw_job); let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // Permit tool launches with CREATE_BREAKAWAY_FROM_JOB without silently + // letting ordinary children escape. + limits.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; // SAFETY: `limits` has the layout required by the selected info class. if unsafe { SetInformationJobObject( From 27220ff4b3afeded3781661879c0c65a93f68d24 Mon Sep 17 00:00:00 2001 From: David Barbet Date: Thu, 15 Oct 2026 15:08:31 -0700 Subject: [PATCH 2/2] test(rust): verify Windows job breakaway policy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d460b2a4-7884-479b-842d-ccffc549a94d --- rust/src/process_tree/tests.rs | 53 +++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/rust/src/process_tree/tests.rs b/rust/src/process_tree/tests.rs index 49dacd2a76..6b17c631ca 100644 --- a/rust/src/process_tree/tests.rs +++ b/rust/src/process_tree/tests.rs @@ -23,7 +23,11 @@ use windows_sys::Win32::Security::{ use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }; -use windows_sys::Win32::System::JobObjects::IsProcessInJob; +use windows_sys::Win32::System::JobObjects::{ + IsProcessInJob, JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JobObjectExtendedLimitInformation, QueryInformationJobObject, +}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetCurrentProcessId, GetCurrentThread, GetCurrentThreadId, OpenProcess, OpenProcessToken, OpenThread, OpenThreadToken, PROCESS_SYNCHRONIZE, SetThreadToken, @@ -778,6 +782,53 @@ async fn initial_thread_executes_only_after_private_job_assignment() { drop(tree); } +#[tokio::test] +async fn private_job_allows_only_explicit_breakaway() { + let _reset = ResetHooks; + BEFORE_RESUME.with_borrow_mut(|hook| { + *hook = Some(Box::new(|_, job| { + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // SAFETY: the Job remains owned and `limits` matches the requested information class. + let queried = unsafe { + QueryInformationJobObject( + job, + JobObjectExtendedLimitInformation, + ptr::from_mut(&mut limits).cast(), + std::mem::size_of::() as u32, + ptr::null_mut(), + ) + }; + assert_ne!(queried, 0, "{}", io::Error::last_os_error()); + let flags = limits.BasicLimitInformation.LimitFlags; + let required = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; + assert_eq!( + flags & required, + required, + "private Job must retain kill-on-close and permit explicit breakaway" + ); + assert_eq!( + flags & JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, + 0, + "ordinary descendants must not silently escape the private Job" + ); + Ok(()) + })); + }); + let mut command = command(); + command.args(["exit", "/b", "0"]); + let (mut child, tree) = super::spawn(&mut command).expect("spawn owned child"); + assert!( + BEFORE_RESUME.with_borrow(Option::is_none), + "job policy must be checked before resume" + ); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("child must exit") + .expect("reap child"); + drop(tree); + assert!(status.success()); +} + #[tokio::test] async fn lookup_failures_reap_suspended_root_and_release_pss_resources() { let _reset = ResetHooks;