Related to #6759 (windows): ensure sidecar process dies with the main app (#6853)

* fix(windows): ensure sidecar process dies with the main app

* chore: fix clippy lints

* chore: fix TS2307 error on commit.

chore: Undo the changes about package.json and pnpm-lock.yaml

* Fix formatting errors caused by merging.

刚刚的合并让原本的函数少了个结尾括号

* fix(windows): close process handle after assigning sidecar to job object

* fix(windows): sidecar Job Object 错误改为向上传播并补充测试

将 job 创建逻辑抽为返回 Result<OwnedHandle> 的独立函数,任一 Win32 调用失败时
终止已 spawn 的子进程并返回 Err,不再静默降级。句柄由裸 isize 改为 OwnedHandle,
借助 RAII 自动关闭。新增 Windows 单元测试,覆盖 KILL_ON_JOB_CLOSE 生命周期
及无效 PID 的失败路径。

---------

Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com>
This commit is contained in:
cjc
2026-07-19 07:30:38 +08:00
committed by GitHub
co-authored by wonfen
parent 6bbe8bd37d
commit 6e9288437f
5 changed files with 189 additions and 0 deletions
Generated
+1
View File
@@ -1148,6 +1148,7 @@ dependencies = [
"warp",
"webpki-roots",
"windows 0.62.2",
"windows-sys 0.61.2",
"winreg 0.56.0",
"zip 8.6.0",
]
+5
View File
@@ -77,6 +77,11 @@ bitflags = { version = "2.13.0" }
# *** For Windows platform only ***
deelevate = "0.2.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_JobObjects"
] }
# *********************************
[workspace.lints.clippy]
+1
View File
@@ -109,6 +109,7 @@ webpki-roots = "1.0.8"
rust_iso3166 = "0.2.0"
# Use the git repo until the next release after v2.0.0.
dark-light = { git = "https://github.com/rust-dark-light/dark-light" }
windows-sys.workspace = true
bytes = "1.12.0"
tokio-util = { version = "0.7.18", features = ["time"] }
+17
View File
@@ -17,6 +17,8 @@ use std::{
use tauri_plugin_shell::process::CommandChild;
use crate::singleton;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
pub(crate) static CLASH_LOGGER: Lazy<Arc<AsyncLogger>> = Lazy::new(|| Arc::new(AsyncLogger::new()));
@@ -41,6 +43,8 @@ impl fmt::Display for RunningMode {
pub struct CoreManager {
state: ArcSwap<State>,
last_update: ArcSwapOption<Instant>,
#[cfg(target_os = "windows")]
job_handle: ArcSwapOption<OwnedHandle>,
config_update_in_progress: AtomicBool,
// 串行化 start/stop/restart 和 sidecar→service 交接。
// 锁序固定为 config_update_in_progress → lifecycle_lock。
@@ -70,6 +74,8 @@ impl Default for CoreManager {
Self {
state: ArcSwap::new(Arc::new(State::default())),
last_update: ArcSwapOption::new(None),
#[cfg(target_os = "windows")]
job_handle: ArcSwapOption::new(None),
config_update_in_progress: AtomicBool::new(false),
lifecycle_lock: tokio::sync::Mutex::new(()),
#[cfg(target_os = "windows")]
@@ -113,6 +119,17 @@ impl CoreManager {
self.last_update.store(Some(Arc::new(time)));
}
/// Replaces the Windows Job Object handle owned by the core manager
///
/// Passing `None` drops the current handle, which closes the Job Object
/// and terminates its assigned processes due to `KILL_ON_JOB_CLOSE`.
///
/// This method is currently only used on Windows.
#[cfg(target_os = "windows")]
fn set_job_handle(&self, handle: Option<OwnedHandle>) {
self.job_handle.store(handle.map(Arc::new));
}
fn try_start_config_update(&self) -> bool {
!self.config_update_in_progress.swap(true, Ordering::AcqRel)
}
+165
View File
@@ -13,6 +13,21 @@ use log::Level;
use scopeguard::defer;
use tauri_plugin_shell::ShellExt as _;
#[cfg(target_os = "windows")]
use {
std::os::windows::io::{AsRawHandle as _, FromRawHandle as _, OwnedHandle},
windows_sys::Win32::{
Foundation::HANDLE,
System::{
JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
},
Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_SET_QUOTA, PROCESS_TERMINATE},
},
},
};
impl CoreManager {
pub async fn get_clash_logs(&self) -> Result<Vec<CompactString>> {
match *self.get_running_mode() {
@@ -48,6 +63,28 @@ impl CoreManager {
&IClashTemp::guard_external_controller_ipc(),
])
.spawn()?;
#[cfg(target_os = "windows")]
{
let job = match create_and_assign_sidecar_job(child.pid()) {
Ok(job) => job,
Err(job_error) => {
let pid = child.pid();
let error = match child.kill() {
Ok(()) => job_error,
Err(kill_error) => anyhow::anyhow!(
"failed to configure Job Object for sidecar PID {pid}: \
{job_error:#}; failed to terminate child: {kill_error:#}"
),
};
logging!(error, Type::Core, "Failed to start sidecar: {error:#}");
return Err(error);
}
};
self.set_job_handle(Some(job));
}
#[cfg(unix)]
unsafe {
tauri_plugin_clash_verge_sysinfo::libc::umask(previous_mask)
@@ -95,6 +132,20 @@ impl CoreManager {
}
if let Some(child) = self.take_child_sidecar() {
let pid = child.pid();
#[cfg(target_os = "windows")]
{
// Setting the job handle to None clears the stored handle and
// closes the previous Windows job handle in `set_job_handle`.
self.set_job_handle(None);
logging!(
trace,
Type::Core,
"Closed job handle for sidecar process (PID: {})",
pid
);
}
let result = child.kill();
logging!(
trace,
@@ -155,3 +206,117 @@ impl CoreManager {
Ok(())
}
}
#[cfg(target_os = "windows")]
fn create_and_assign_sidecar_job(child_pid: u32) -> Result<OwnedHandle> {
unsafe {
let raw_job: HANDLE = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if raw_job.is_null() {
return Err(last_win32_error("CreateJobObjectW failed"));
}
let job = OwnedHandle::from_raw_handle(raw_job);
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let set_info_result = SetInformationJobObject(
job.as_raw_handle() as HANDLE,
JobObjectExtendedLimitInformation,
&mut info as *mut _ as *mut _,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if set_info_result == 0 {
return Err(last_win32_error("SetInformationJobObject failed"));
}
let raw_process_handle = OpenProcess(
PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_INFORMATION,
0,
child_pid,
);
if raw_process_handle.is_null() {
return Err(last_win32_error("OpenProcess failed"));
}
let process_handle = OwnedHandle::from_raw_handle(raw_process_handle);
let assign_result = AssignProcessToJobObject(job.as_raw_handle(), process_handle.as_raw_handle());
if assign_result == 0 {
return Err(last_win32_error("AssignProcessToJobObject failed"));
}
Ok(job)
}
}
#[cfg(target_os = "windows")]
fn last_win32_error(operation: &'static str) -> anyhow::Error {
anyhow::Error::new(std::io::Error::last_os_error()).context(operation)
}
#[cfg(all(test, target_os = "windows"))]
mod tests {
use super::create_and_assign_sidecar_job;
use anyhow::Result;
use std::{
process::{Child, Command, Stdio},
thread::sleep,
time::{Duration, Instant},
};
// 起一个长命子进程用于验证 Job Object 的生命周期绑定。
// 直接使用 System32 下的 ping.exe,避免 cmd 中间层。
fn spawn_long_lived() -> Result<Child> {
let child = Command::new("ping")
.args(["-n", "999", "127.0.0.1"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(child)
}
// 在超时内轮询子进程是否退出,返回是否已退出。
fn wait_until_exited(child: &mut Child, timeout: Duration) -> Result<bool> {
let deadline = Instant::now() + timeout;
loop {
if child.try_wait()?.is_some() {
return Ok(true);
}
if Instant::now() >= deadline {
return Ok(false);
}
sleep(Duration::from_millis(50));
}
}
// 成功路径:进程被分配进 Job Object 后仍存活;drop Job 句柄触发
// KILL_ON_JOB_CLOSE,进程应在超时内被 OS 终止。
#[test]
fn job_kills_child_on_handle_drop() -> Result<()> {
let mut child = spawn_long_lived()?;
let job = create_and_assign_sidecar_job(child.id())?;
// 分配后进程应仍在运行。
assert!(
child.try_wait()?.is_none(),
"child should still be running after being assigned to the job"
);
// 关闭 Job 句柄,OS 应连带终止其成员进程。
drop(job);
assert!(
wait_until_exited(&mut child, Duration::from_secs(5))?,
"child should be terminated after the job handle is dropped"
);
Ok(())
}
// 失败路径:对一个不存在的 PID 调用时 OpenProcess 应失败,函数返回 Err。
#[test]
fn returns_err_for_invalid_pid() {
// PID 必须为 4 的倍数且极不可能存在;0xFFFF_FFFC 对应不到真实进程。
let result = create_and_assign_sidecar_job(0xFFFF_FFFC);
assert!(result.is_err(), "expected Err for a non-existent PID");
}
}