Skip to main content

browser_automation_cli/
win_job.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Windows Job Object helpers for residual-zero Chrome reap (PRD 5W / GAP-009).
3//!
4//! On Windows, launched Chrome is assigned to a Job Object with
5//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` so FINALIZE/Drop kills the full tree.
6//! On non-Windows targets this module is a validated stub (always returns 0).
7//!
8//! # Platform
9//!
10//! Real Job Object APIs are compiled only on `cfg(windows)`. docs.rs multi-target
11//! builds label those items via `#[doc(cfg(windows))]` under `--cfg docsrs`.
12
13#![allow(dead_code)]
14
15/// True when this build can create real Job Objects (Windows only).
16pub fn platform_supports_job_objects() -> bool {
17    cfg!(windows)
18}
19
20/// Human-readable capability line for doctor / diagnostics.
21pub fn capability_summary() -> &'static str {
22    if platform_supports_job_objects() {
23        "windows_job_object:available (KILL_ON_JOB_CLOSE)"
24    } else {
25        "windows_job_object:stub (non-windows host)"
26    }
27}
28
29/// Create a Job Object and assign `pid` to it. Returns handle as `usize` (0 on failure/non-Windows).
30///
31/// # Safety / multi-op policy (`rules_rust` / D-08)
32///
33/// Each Win32 call lives in its **own** `unsafe` block with a local SAFETY
34/// comment. Safe Rust owns control flow, handle cleanup, and the pid==0 guard.
35#[cfg(windows)]
36#[cfg_attr(docsrs, doc(cfg(windows)))]
37pub fn create_and_assign(pid: u32) -> usize {
38    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
39    use windows_sys::Win32::System::Threading::{
40        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, OpenProcess,
41        SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
42        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
43    };
44
45    if pid == 0 {
46        return 0;
47    }
48
49    // SAFETY: null security attributes and name are valid for an anonymous job.
50    // See: CreateJobObjectW — https://learn.microsoft.com/windows/win32/api/jobapi2/
51    let job: HANDLE = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
52    if job.is_null() {
53        return 0;
54    }
55
56    // SAFETY: `info` is fully zero-initialized before LimitFlags is set; size matches the struct.
57    // See: SetInformationJobObject + JOBOBJECT_EXTENDED_LIMIT_INFORMATION.
58    let set_ok = unsafe {
59        let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
60        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
61        SetInformationJobObject(
62            job,
63            JobObjectExtendedLimitInformation,
64            &info as *const _ as *const _,
65            std::mem::size_of_val(&info) as u32,
66        )
67    };
68    if set_ok == 0 {
69        // SAFETY: `job` is an owned handle from CreateJobObjectW on this path.
70        unsafe {
71            let _ = CloseHandle(job);
72        }
73        return 0;
74    }
75
76    // SAFETY: PROCESS_SET_QUOTA|PROCESS_TERMINATE is the minimum rights for AssignProcessToJobObject.
77    // `pid` is a Chrome child recorded in the residual ledger (never 0).
78    // See: OpenProcess — https://learn.microsoft.com/windows/win32/api/processthreadsapi/
79    let proc: HANDLE =
80        unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
81    if proc.is_null() {
82        unsafe {
83            let _ = CloseHandle(job);
84        }
85        return 0;
86    }
87
88    // SAFETY: both handles are open and valid; assignment fails cleanly with 0.
89    // See: AssignProcessToJobObject.
90    let assigned = unsafe { AssignProcessToJobObject(job, proc) };
91    // SAFETY: process handle is owned and no longer needed after assign attempt.
92    unsafe {
93        let _ = CloseHandle(proc);
94    }
95    if assigned == 0 {
96        unsafe {
97            let _ = CloseHandle(job);
98        }
99        return 0;
100    }
101    job as usize
102}
103
104/// Terminate all processes in the job.
105#[cfg(windows)]
106#[cfg_attr(docsrs, doc(cfg(windows)))]
107pub fn terminate_job(handle: usize) {
108    use windows_sys::Win32::System::Threading::TerminateJobObject;
109    if handle == 0 {
110        return;
111    }
112    // SAFETY: `handle` is a Job Object HANDLE returned by `create_and_assign` (or 0).
113    // TerminateJobObject is valid for any open job handle; exit code 1 is conventional.
114    // See: TerminateJobObject — https://learn.microsoft.com/windows/win32/api/jobapi2/
115    unsafe {
116        let _ = TerminateJobObject(handle as _, 1);
117    }
118}
119
120/// Close job handle.
121#[cfg(windows)]
122#[cfg_attr(docsrs, doc(cfg(windows)))]
123pub fn close_job(handle: usize) {
124    use windows_sys::Win32::Foundation::CloseHandle;
125    if handle == 0 {
126        return;
127    }
128    // SAFETY: `handle` is an owned Job Object HANDLE; CloseHandle releases it once.
129    // See: CloseHandle — Win32 Foundation.
130    unsafe {
131        let _ = CloseHandle(handle as _);
132    }
133}
134
135/// Terminate a single process by pid (fallback when no job).
136#[cfg(windows)]
137#[cfg_attr(docsrs, doc(cfg(windows)))]
138pub fn terminate_pid(pid: u32) {
139    use windows_sys::Win32::Foundation::CloseHandle;
140    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
141    if pid == 0 {
142        return;
143    }
144    // SAFETY: PROCESS_TERMINATE on a ledger Chrome child pid (never 0).
145    // See: OpenProcess Win32 docs.
146    let proc = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
147    if proc.is_null() {
148        return;
149    }
150    // SAFETY: `proc` is a valid process handle; exit code 1 is conventional for residual kill.
151    // See: TerminateProcess.
152    unsafe {
153        let _ = TerminateProcess(proc, 1);
154    }
155    // SAFETY: owned handle from OpenProcess; close exactly once.
156    unsafe {
157        let _ = CloseHandle(proc);
158    }
159}
160
161/// Self-test: on Windows, create a job for the current process and tear it down
162/// without terminating the process (do not set kill-on-close for self-test path).
163///
164/// Returns `Ok(handle)` if assignment succeeded; caller must `close_job` only
165/// (must NOT `terminate_job` on the current process).
166#[cfg(windows)]
167#[cfg_attr(docsrs, doc(cfg(windows)))]
168pub fn validate_assign_current_process() -> Result<usize, String> {
169    use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
170    use windows_sys::Win32::System::Threading::{
171        AssignProcessToJobObject, CreateJobObjectW, GetCurrentProcess, GetCurrentProcessId,
172        OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
173    };
174
175    // SAFETY: GetCurrentProcessId has no preconditions; returns this process id.
176    // See: GetCurrentProcessId Win32 docs.
177    let pid = unsafe { GetCurrentProcessId() };
178    // SAFETY:
179    // - Contract: doctor self-test creates a job and assigns the current process.
180    // - Invariant: no KILL_ON_JOB_CLOSE so closing the job does not kill this process.
181    // - Handles closed on failure; success returns owned job handle for `close_job`.
182    // - See: CreateJobObjectW / AssignProcessToJobObject.
183    unsafe {
184        let job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null());
185        if job.is_null() {
186            return Err("CreateJobObjectW failed".into());
187        }
188        // Assign current process WITHOUT kill-on-close so the test process survives.
189        let proc: HANDLE = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);
190        if proc.is_null() {
191            // Fall back to current process pseudo-handle path.
192            let cur = GetCurrentProcess();
193            let assigned = AssignProcessToJobObject(job, cur);
194            if assigned == 0 {
195                let _ = CloseHandle(job);
196                return Err("AssignProcessToJobObject(current) failed".into());
197            }
198            return Ok(job as usize);
199        }
200        let assigned = AssignProcessToJobObject(job, proc);
201        let _ = CloseHandle(proc);
202        if assigned == 0 {
203            let _ = CloseHandle(job);
204            return Err("AssignProcessToJobObject failed".into());
205        }
206        Ok(job as usize)
207    }
208}
209
210/// Stub: Job Object only exists on Windows (returns `0`).
211#[cfg(not(windows))]
212#[cfg_attr(docsrs, doc(cfg(not(windows))))]
213pub fn create_and_assign(_pid: u32) -> usize {
214    0
215}
216
217/// Stub: no-op on non-Windows hosts.
218#[cfg(not(windows))]
219#[cfg_attr(docsrs, doc(cfg(not(windows))))]
220pub fn terminate_job(_handle: usize) {}
221
222/// Stub: no-op on non-Windows hosts.
223#[cfg(not(windows))]
224#[cfg_attr(docsrs, doc(cfg(not(windows))))]
225pub fn close_job(_handle: usize) {}
226
227/// Stub: no-op on non-Windows hosts.
228#[cfg(not(windows))]
229#[cfg_attr(docsrs, doc(cfg(not(windows))))]
230pub fn terminate_pid(_pid: u32) {}
231
232/// Stub validation: reports that job objects are not available on this OS.
233#[cfg(not(windows))]
234#[cfg_attr(docsrs, doc(cfg(not(windows))))]
235pub fn validate_assign_current_process() -> Result<usize, String> {
236    Err("job objects unavailable on non-windows".into())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn capability_summary_is_non_empty() {
245        assert!(!capability_summary().is_empty());
246    }
247
248    #[test]
249    fn stub_or_create_does_not_panic() {
250        let h = create_and_assign(0);
251        assert_eq!(h, 0, "pid 0 must never produce a job handle");
252        terminate_job(0);
253        close_job(0);
254        terminate_pid(0);
255    }
256
257    #[test]
258    fn platform_flag_matches_cfg() {
259        assert_eq!(platform_supports_job_objects(), cfg!(windows));
260    }
261
262    #[test]
263    fn validate_current_process_behavior() {
264        match validate_assign_current_process() {
265            Ok(h) => {
266                // On Linux this branch is unreachable; on Windows we must close only.
267                assert_ne!(h, 0);
268                close_job(h);
269            }
270            Err(e) => {
271                #[cfg(windows)]
272                assert!(!e.is_empty());
273                #[cfg(not(windows))]
274                assert!(e.contains("non-windows"));
275            }
276        }
277    }
278}