From 6e9288437f0004c152f1de7ee862be49245a65e2 Mon Sep 17 00:00:00 2001 From: cjc <94501466+Jianchi-Chen@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:30:38 +0800 Subject: [PATCH] Related to #6759 (windows): ensure sidecar process dies with the main app (#6853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 的独立函数,任一 Win32 调用失败时 终止已 spawn 的子进程并返回 Err,不再静默降级。句柄由裸 isize 改为 OwnedHandle, 借助 RAII 自动关闭。新增 Windows 单元测试,覆盖 KILL_ON_JOB_CLOSE 生命周期 及无效 PID 的失败路径。 --------- Co-authored-by: wonfen <96291150+wonfen@users.noreply.github.com> --- Cargo.lock | 1 + Cargo.toml | 5 + src-tauri/Cargo.toml | 1 + src-tauri/src/core/manager/mod.rs | 17 +++ src-tauri/src/core/manager/state.rs | 165 ++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 0c3c07fdc..4b817ca97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 16261a76b..f65b670b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 864be4408..f5ffcdc58 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index c95fa5158..03f7e46f9 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -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> = Lazy::new(|| Arc::new(AsyncLogger::new())); @@ -41,6 +43,8 @@ impl fmt::Display for RunningMode { pub struct CoreManager { state: ArcSwap, last_update: ArcSwapOption, + #[cfg(target_os = "windows")] + job_handle: ArcSwapOption, 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) { + self.job_handle.store(handle.map(Arc::new)); + } + fn try_start_config_update(&self) -> bool { !self.config_update_in_progress.swap(true, Ordering::AcqRel) } diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 3b105e280..e952e891d 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -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> { 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 { + 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::() 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 { + 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 { + 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"); + } +}