crates\nfarms-core\src\job_win.rs¶
源码路径:
crates\nfarms-core\src\job_win.rs· 308 行
//! Windows Job Object 的最小封装:只做「最后一根句柄关了,就把里面的进程全结束」。
//!
//! 两个调用方,**一份实现**:
//!
//! * [`crate::live::Live`] —— 起 bililive-go;句柄存在 `Live` 上,`Drop` 时关。
//! * [`crate::observability`] —— 起 Grafana / Loki / VictoriaMetrics;句柄存在
//! 模块级的槽里,和启动器进程同寿命。
//!
//! **为什么不各抄一份**:这段 `unsafe` 里最贵的知识是下面那三个结构体的
//! ABI 布局(见 [`tests::extended_limit_information_matches_win32_layout`])。
//! 抄一份就是两个会各自漂移的副本,而漂移的症状只是一个**毫无信息量**的参数
//! 错误 —— 往 `IoCounters` 里少写一个 `OtherTransferCount` 就这么错过一次,
//! 当时是拿"结构体长度 136≠144"才反推回来的。本项目在 ICO 解析上刚因为同类
//! 问题绕开过一次重复,这里照同一条处理。
//!
//! 平台:整个模块在 `lib.rs` 里 `#[cfg(windows)]` 声明,所以文件内部不再写
//! `cfg` —— 非 Windows(Android 也要编)上它**不存在**,而不是留一个空壳。
use std::ffi::c_void;
use std::os::windows::io::AsRawHandle;
use std::process::Child;
/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`
const KILL_ON_JOB_CLOSE: u32 = 0x0000_2000;
/// `JobObjectExtendedLimitInformation`
const EXTENDED_LIMIT_INFORMATION_CLASS: u32 = 9;
/// `ERROR_INVALID_HANDLE` —— 句柄是 `0` 时我们自己造的错误码(内核对空句柄
/// 给的就是这个)。
pub(crate) const ERROR_INVALID_HANDLE: u32 = 6;
#[link(name = "kernel32")]
unsafe extern "system" {
fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> *mut c_void;
fn SetInformationJobObject(
job: *mut c_void,
class: u32,
info: *mut c_void,
len: u32,
) -> i32;
fn AssignProcessToJobObject(job: *mut c_void, process: *mut c_void) -> i32;
fn CloseHandle(handle: *mut c_void) -> i32;
fn GetLastError() -> u32;
#[cfg(test)]
fn IsProcessInJob(process: *mut c_void, job: *mut c_void, result: *mut i32) -> i32;
}
// 下面三个结构体是 Win32 ABI 的 `JOBOBJECT_EXTENDED_LIMIT_INFORMATION`,
// 字段名照 SDK。我们只填 `limit_flags`,但整块内存必须按真实布局交给内核,
// 让编译器替我们算偏移(手算偏移那种写法一换位数就错)。
#[repr(C)]
struct BasicLimitInformation {
per_process_user_time_limit: i64,
per_job_user_time_limit: i64,
limit_flags: u32,
minimum_working_set_size: usize,
maximum_working_set_size: usize,
active_process_limit: u32,
affinity: usize,
priority_class: u32,
scheduling_class: u32,
}
/// 六个计数,少一个整块长度就对不上(实测 `sizeof` = 48)。
#[repr(C)]
struct IoCounters {
read_operation_count: u64,
write_operation_count: u64,
other_operation_count: u64,
read_transfer_count: u64,
write_transfer_count: u64,
other_transfer_count: u64,
}
#[repr(C)]
struct ExtendedLimitInformation {
basic_limit_information: BasicLimitInformation,
io_info: IoCounters,
process_memory_limit: usize,
job_memory_limit: usize,
peak_process_memory_used: usize,
peak_job_memory_used: usize,
}
/// 建一个匿名 Job 并打上 kill-on-close。
///
/// 失败返回 Win32 错误码 —— 调用方只需要知道"没建成",错误码留给日志和测试
/// 用(`GetLastError` 在老 Windows 上不通用的坑不在这里)。
pub(crate) fn create() -> Result<usize, u32> {
// 安全性:只碰内核句柄和本地栈上的结构体;结构体布局与 SDK 一致,
// 长度由 `size_of` 给出。
unsafe {
let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null());
if job.is_null() {
return Err(GetLastError());
}
let mut info: ExtendedLimitInformation = std::mem::zeroed();
info.basic_limit_information.limit_flags = KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
job,
EXTENDED_LIMIT_INFORMATION_CLASS,
std::ptr::addr_of_mut!(info).cast(),
std::mem::size_of::<ExtendedLimitInformation>() as u32,
);
if ok == 0 {
// 没打上标记的 Job 没用,关掉免得漏句柄
let code = GetLastError();
CloseHandle(job);
return Err(code);
}
Ok(job as usize)
}
}
/// 把子进程挂进 Job。挂不上返回 Win32 错误码。
///
/// 挂进去之后,**子进程再派生的进程也在这个 Job 里**(Job 成员资格默认继承),
/// 所以"关句柄"收走的是整棵树 —— Grafana 那 13 个插件后端就是靠这一条。
pub(crate) fn assign(job: usize, child: &Child) -> Result<(), u32> {
if job == 0 {
// 把"没有句柄"和内核给的错误分开:前者是我们自己没建出来,
// 后者才是"建了但内核拒绝了"
return Err(ERROR_INVALID_HANDLE);
}
// 安全性:`job` 来自 `create()`,子进程句柄由 `std` 持有且仍然有效
let ok = unsafe { AssignProcessToJobObject(job as *mut c_void, child.as_raw_handle()) };
if ok != 0 {
Ok(())
} else {
// 紧跟着失败的那一下读,中间不能插别的 Win32 调用
Err(unsafe { GetLastError() })
}
}
/// 关句柄。`0` 是"没有句柄"的哨兵,直接忽略。
///
/// **关掉最后一根句柄 = 内核立刻结束 Job 里的所有进程**,不需要额外的
/// `TerminateJobObject`(那还得留着句柄,而"句柄一关就杀树"正是
/// `KILL_ON_JOB_CLOSE` 要的语义)。
pub(crate) fn close(job: usize) {
if job != 0 {
// 安全性:句柄来自 `create()`,调用方保证只关一次
unsafe { CloseHandle(job as *mut c_void) };
}
}
/// 只给测试用:子进程是不是真进了这个 Job。
///
/// 没有它的话,"挂了 Job"这件事只能靠后果反推 —— 而后果(进程没被收走)
/// 也可能是别的原因。
#[cfg(test)]
pub(crate) fn is_member(job: usize, child: &Child) -> bool {
if job == 0 {
return false;
}
let mut inside = 0i32;
// 安全性:两个句柄都有效(见上),`inside` 是本地变量
let ok = unsafe {
IsProcessInJob(
child.as_raw_handle(),
job as *mut c_void,
&mut inside as *mut i32,
)
};
ok != 0 && inside != 0
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// ABI 布局断言 —— **这段是整个模块最值钱的几行**。
///
/// `SetInformationJobObject` 拿到长度不对的结构体只会回一个"参数错误",
/// 不告诉你是哪个字段错了。往 `IoCounters` 里少写一个 `OtherTransferCount`
/// 就这么错过一次(结构体 136 字节,内核要 144)。把长度和偏移在这里钉死,
/// 以后谁动结构体都会在这一步直接红,而不是在某个服务起不来时反推。
///
/// 32 位目标上指针 4 字节,长度本来就不同,所以分开断言 —— 不是"跳过"。
#[test]
fn extended_limit_information_matches_win32_layout() {
let ptr = std::mem::size_of::<usize>();
// BasicLimitInformation:两个 i64 + limit_flags,后面跟三个指针宽的
// 字段加三个 u32(64 位下按 8 对齐补到 64;32 位下补到 48)
let expected_basic = if ptr == 8 { 64 } else { 48 };
assert_eq!(
std::mem::size_of::<BasicLimitInformation>(),
expected_basic,
"BasicLimitInformation 布局和 Win32 对不上"
);
assert_eq!(
std::mem::size_of::<IoCounters>(),
48,
"IoCounters 是六个 u64;少写一个字段这里就会变成 40"
);
// 整块:64 + 48 + 4 个指针宽的字段
let expected_total = if ptr == 8 { 144 } else { 112 };
assert_eq!(
std::mem::size_of::<ExtendedLimitInformation>(),
expected_total,
"结构体布局和 Win32 对不上(这个错误只会以'参数错误'的形式冒出来)"
);
// 64 位上把偏移也钉住:内核按这些偏移读字段,名字对、位置错一样是错的
if ptr == 8 {
assert_eq!(
std::mem::offset_of!(ExtendedLimitInformation, basic_limit_information.limit_flags),
16,
"limit_flags 不在第 16 字节上 —— KILL_ON_JOB_CLOSE 会被内核读到别处"
);
assert_eq!(
std::mem::offset_of!(ExtendedLimitInformation, io_info),
64
);
assert_eq!(
std::mem::offset_of!(ExtendedLimitInformation, process_memory_limit),
112
);
}
}
/// 建得出来、关得掉,而且**空句柄哨兵**走的是我们自己那个错误码。
#[test]
fn create_returns_a_usable_handle_and_assign_rejects_the_null_sentinel() {
match create() {
Ok(handle) => {
assert_ne!(handle, 0, "0 是'没有句柄'的哨兵,不能当成有效句柄");
close(handle);
}
Err(code) => panic!("建 Job Object 失败,Win32 错误码 {code}"),
}
// 0 号句柄:不该去碰内核,直接报 ERROR_INVALID_HANDLE
let mut child = Command::new("cmd")
.args(["/c", "exit"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("cmd 没起来");
assert_eq!(assign(0, &child), Err(ERROR_INVALID_HANDLE));
// **必须 wait**:不等的话进程表里会留一条记录(Windows 上是句柄泄漏),
// 而且 clippy 会报 `spawned process is never wait()ed on`。
// (这条测试只证"空句柄被拒",进程本来就已经退出了。)
let _ = child.wait();
// close(0) 必须是空操作(不能把"没有句柄"当成句柄关)
close(0);
}
/// 一个必定存在的长跑替身:`ping -n 30` ≈ 30 秒。
///
/// 不拿真的 bililive-go / Grafana:那两样不保证在测试机上装了,而这条
/// 测试要证的只是"进程被挂进 Job 之后,关句柄会不会被内核收走"。
fn long_running_stand_in() -> Option<std::path::PathBuf> {
let root = std::env::var("SystemRoot").ok()?;
let ping = std::path::PathBuf::from(root)
.join("System32")
.join("ping.exe");
ping.is_file().then_some(ping)
}
/// **本模块存在的理由**:关掉最后一根句柄,Job 里的进程随之消失。
///
/// 这条不经过 `Live` / `observability`,直接钉住那段 `unsafe` 本身 ——
/// 上面两个调用方各自的行为测试都可能因为别的原因红/绿,只有这条是
/// "内核到底认不认这个 Job"。
#[test]
fn closing_the_job_kills_the_process_inside_it() {
let Some(ping) = long_running_stand_in() else {
eprintln!("跳过:System32\\ping.exe 不在,拿不到长跑替身");
return;
};
let job = match create() {
Ok(handle) => handle,
Err(code) => panic!("建 Job Object 失败,Win32 错误码 {code}"),
};
let mut child = Command::new(&ping)
.args(["-n", "30", "127.0.0.1"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("替身进程没起来");
assert_eq!(assign(job, &child), Ok(()), "挂 Job 失败");
assert!(is_member(job, &child), "替身进程没进 Job");
assert!(
child.try_wait().expect("try_wait 失败").is_none(),
"替身进程应该还在跑"
);
close(job);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if child.try_wait().expect("try_wait 失败").is_some() {
return; // 内核把它收走了
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("关掉 Job Object 后替身进程还活着 —— 回收根本不生效");
}
std::thread::sleep(Duration::from_millis(50));
}
}
}