fix(tauri): own Windows CLI subtree with a job object (#320)
## Summary - Follow-up to #240 to make Windows desktop shutdown reliable this time, even when the tracked CLI wrapper PID exits before its descendants - Attach the spawned CLI process to a Windows Job Object with `KILL_ON_JOB_CLOSE`, so the desktop app owns the whole subtree instead of relying only on `taskkill /PID <wrapper> /T` - Keep the current graceful-then-force shutdown path, but add a robust OS-level fallback that reaps orphaned workspace processes when the wrapper is already gone ## Root Cause The previous Windows shutdown logic still depended on the PID tracked by Tauri. In practice that PID can be a short-lived Node wrapper. Once that wrapper exits, `taskkill` can report success or PID-not-found while descendants remain alive, and the desktop app no longer has a reliable handle to reap them. ## Validation - `cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml` - `cargo build --release --manifest-path packages/tauri-app/src-tauri/Cargo.toml` - Manual local test: orphaned processes are cleaned up after desktop shutdown
This commit is contained in:
@@ -28,4 +28,4 @@ url = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::ffi::c_void;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::mem::{size_of, zeroed};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
@@ -19,12 +23,95 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::System::JobObjects::{
|
||||||
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||||
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WindowsJobObject {
|
||||||
|
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||||
|
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||||
|
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||||
|
handle: HANDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl WindowsJobObject {
|
||||||
|
fn create() -> anyhow::Result<Self> {
|
||||||
|
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||||
|
if handle.is_null() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"CreateJobObjectW failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||||
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
|
||||||
|
let ok = unsafe {
|
||||||
|
SetInformationJobObject(
|
||||||
|
handle,
|
||||||
|
JobObjectExtendedLimitInformation,
|
||||||
|
&mut info as *mut _ as *mut c_void,
|
||||||
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||||
|
let process_handle = child.as_raw_handle() as HANDLE;
|
||||||
|
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"AssignProcessToJobObject failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl Drop for WindowsJobObject {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Send for WindowsJobObject {}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Sync for WindowsJobObject {}
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
@@ -363,6 +450,8 @@ impl Default for CliStatus {
|
|||||||
pub struct CliProcessManager {
|
pub struct CliProcessManager {
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
@@ -372,6 +461,8 @@ impl CliProcessManager {
|
|||||||
Self {
|
Self {
|
||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -394,6 +485,8 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_arc = self.job.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.clone();
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -401,6 +494,8 @@ impl CliProcessManager {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
status_arc.clone(),
|
status_arc.clone(),
|
||||||
child_arc,
|
child_arc,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job_arc,
|
||||||
ready_flag,
|
ready_flag,
|
||||||
token_arc,
|
token_arc,
|
||||||
dev,
|
dev,
|
||||||
@@ -420,11 +515,12 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _job = self.job.lock().take();
|
||||||
|
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
@@ -446,18 +542,16 @@ impl CliProcessManager {
|
|||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if !forced_tree_shutdown
|
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||||
child.id()
|
child.id()
|
||||||
));
|
));
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
@@ -476,11 +570,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,6 +581,9 @@ impl CliProcessManager {
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(windows)]
|
||||||
|
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
@@ -511,6 +604,7 @@ impl CliProcessManager {
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
@@ -592,6 +686,22 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
log_line(&format!("spawned pid={pid}"));
|
log_line(&format!("spawned pid={pid}"));
|
||||||
|
#[cfg(windows)]
|
||||||
|
match WindowsJobObject::create().and_then(|job| {
|
||||||
|
job.assign_child(&child)?;
|
||||||
|
Ok(job)
|
||||||
|
}) {
|
||||||
|
Ok(job) => {
|
||||||
|
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||||
|
*job_holder.lock() = Some(job);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!(
|
||||||
|
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
locked.pid = Some(pid);
|
locked.pid = Some(pid);
|
||||||
@@ -665,6 +775,8 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let child_holder_clone = child_holder.clone();
|
let child_holder_clone = child_holder.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_holder_clone = job_holder.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let timeout = Duration::from_secs(60);
|
let timeout = Duration::from_secs(60);
|
||||||
thread::sleep(timeout);
|
thread::sleep(timeout);
|
||||||
@@ -719,6 +831,10 @@ impl CliProcessManager {
|
|||||||
// Drop the handle after the process exits so other callers
|
// Drop the handle after the process exits so other callers
|
||||||
// don't attempt to stop/kill a finished process.
|
// don't attempt to stop/kill a finished process.
|
||||||
*guard = None;
|
*guard = None;
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = job_holder_clone.lock().take();
|
||||||
|
}
|
||||||
Some(status)
|
Some(status)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -776,7 +892,8 @@ impl CliProcessManager {
|
|||||||
auth_cookie_name: &str,
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
let local_url_regex =
|
||||||
|
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -818,7 +935,6 @@ impl CliProcessManager {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -1022,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|||||||
let cwd = std::env::current_dir().ok();
|
let cwd = std::env::current_dir().ok();
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let mut candidates = vec![
|
let mut candidates = vec![
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||||
workspace
|
workspace
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
|||||||
Reference in New Issue
Block a user