Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,252 @@
//! Windows Job Objectbased child-process-tree cleanup.
//!
//! `kill_on_drop(true)` only terminates the *direct* child, and the explicit
//! `taskkill /T` teardown in [`crate::spawn`] only runs when our code gets a
//! chance to run. Neither helps when this process is force-killed — which is
//! exactly what `tauri dev` does on rebuild/Ctrl+C — so descendant trees like
//! `bunx → codex-acp → MCP stdio bridges` survive as orphans. The bridges are
//! this very executable (`nomifun-desktop.exe mcp-*-stdio`), so a leftover
//! tree keeps the binary locked and the next build dies with os error 5.
//!
//! A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` is the OS-level fix:
//! every process assigned to the job — and all descendants, which inherit
//! membership automatically — is terminated by the kernel when the last job
//! handle closes. The process-global job handle lives for the lifetime of
//! this process and is closed by the OS on process death *of any kind*,
//! including TerminateProcess.
//!
//! Residual window: membership is granted by `AssignProcessToJobObject`
//! *after* CreateProcess returns, so descendants the child manages to create
//! in those few microseconds — or anything it spawned if it exits before the
//! assignment — land outside the job. Closing it would need
//! CREATE_SUSPENDED → assign → ResumeThread, which `tokio::process` does not
//! expose; real CLI children spend far longer in loader/runtime init than
//! the window lasts, so this is accepted.
use std::io;
use std::os::windows::io::RawHandle;
use std::sync::OnceLock;
use tokio::process::Child;
use tracing::warn;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
};
/// An owned kill-on-close Job Object. Dropping it (closing the last handle)
/// makes the kernel terminate every process still assigned to it.
pub struct CleanupJob {
handle: *mut core::ffi::c_void,
}
// SAFETY: the wrapped value is an opaque kernel handle; the Win32 job APIs
// called on it are documented thread-safe.
unsafe impl Send for CleanupJob {}
unsafe impl Sync for CleanupJob {}
impl CleanupJob {
/// Create an anonymous job object configured with KILL_ON_JOB_CLOSE.
pub fn new() -> io::Result<Self> {
// SAFETY: plain Win32 calls. The handle is checked before use and
// closed on every early-exit path.
unsafe {
let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if handle.is_null() {
return Err(io::Error::last_os_error());
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
(&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if ok == 0 {
let err = io::Error::last_os_error();
CloseHandle(handle);
return Err(err);
}
Ok(Self { handle })
}
}
/// Assign a live process to this job. Descendants spawned by the process
/// afterwards inherit membership automatically.
pub fn assign_raw(&self, process: RawHandle) -> io::Result<()> {
// SAFETY: `self.handle` is live for `'self`; `process` is a live
// process handle owned by the caller for the duration of the call.
let ok = unsafe { AssignProcessToJobObject(self.handle, process.cast()) };
if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) }
}
#[cfg(test)]
pub(crate) fn raw(&self) -> *mut core::ffi::c_void {
self.handle
}
}
impl Drop for CleanupJob {
fn drop(&mut self) {
// SAFETY: `handle` is owned by `self` and closed exactly once. With
// KILL_ON_JOB_CLOSE this terminates all processes still in the job.
unsafe { CloseHandle(self.handle) };
}
}
/// The process-global cleanup job. Created lazily on first spawn; lives until
/// this process dies, at which point the OS closes the handle and reaps every
/// assigned child tree. `None` if creation failed (we degrade to the existing
/// kill_on_drop / taskkill behaviour rather than refusing to spawn).
pub(crate) fn global_cleanup_job() -> Option<&'static CleanupJob> {
static JOB: OnceLock<Option<CleanupJob>> = OnceLock::new();
JOB.get_or_init(|| match CleanupJob::new() {
Ok(job) => Some(job),
Err(e) => {
warn!(
error = %e,
"Failed to create cleanup job object; child process trees will leak if this process is force-killed"
);
None
}
})
.as_ref()
}
/// Best-effort: put `child` into the global cleanup job. Failure is logged,
/// never fatal — the child still runs, we just lose the force-kill safety net.
pub(crate) fn assign_to_cleanup_job(child: &Child) {
let Some(job) = global_cleanup_job() else { return };
// `raw_handle` is `None` once the child has already been reaped — nothing
// left to clean up in that case.
let Some(raw) = child.raw_handle() else { return };
if let Err(e) = job.assign_raw(raw) {
warn!(pid = ?child.id(), error = %e, "Failed to assign child process to cleanup job");
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::*;
/// `true` while the OS still has a live (not yet terminated) process with
/// this pid. `tasklist /FI` prints an INFO line and nothing else when no
/// task matches.
fn pid_alive(pid: u32) -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.expect("tasklist should run");
String::from_utf8_lossy(&out.stdout).contains(&pid.to_string())
}
fn wait_until(timeout: Duration, mut check: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
loop {
if check() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(100));
}
}
#[tokio::test]
async fn dropping_job_kills_child_and_grandchild() {
// A plain path inside a temp dir — NOT NamedTempFile, whose open
// handle would block PowerShell's Set-Content under Windows share
// semantics.
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("grandchild-pid.txt");
// Leader powershell spawns a grandchild powershell, records its pid
// into the marker file, then blocks. Mirrors the real-world shape:
// Builder child (ACP CLI) spawning its own descendants. The marker
// path travels via env var, not string interpolation — temp paths
// contain the user profile, where an apostrophe would break a PS
// single-quoted literal.
let script = "$p = Start-Process powershell -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 120' \
-PassThru -WindowStyle Hidden; \
Set-Content -Path $env:NOMI_JOB_TEST_MARKER -Value $p.Id; Start-Sleep -Seconds 120";
let mut leader = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command", script])
.env("NOMI_JOB_TEST_MARKER", &marker)
.spawn()
.expect("spawn leader powershell");
let job = CleanupJob::new().expect("create job");
job.assign_raw(leader.raw_handle().expect("leader handle"))
.expect("assign leader");
// Wait for the grandchild pid to land in the marker file.
assert!(
wait_until(Duration::from_secs(20), || {
std::fs::read_to_string(&marker)
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}),
"grandchild pid marker should appear"
);
let grandchild_pid: u32 = std::fs::read_to_string(&marker)
.unwrap()
.trim()
.parse()
.expect("marker should contain a pid");
assert!(pid_alive(grandchild_pid), "grandchild should be running");
// Closing the last job handle must reap the whole tree — this is the
// force-kill safety net (the OS does this even when no userland
// cleanup code runs).
drop(job);
assert!(
wait_until(Duration::from_secs(5), || leader.try_wait().ok().flatten().is_some()),
"leader should be terminated by job close"
);
assert!(
wait_until(Duration::from_secs(5), || !pid_alive(grandchild_pid)),
"grandchild pid={grandchild_pid} should be terminated by job close"
);
}
#[tokio::test]
async fn assigned_child_runs_to_completion_normally() {
// The brief sleep keeps the child alive across the assign call — a
// bare `exit 0` could finish first on a loaded machine, and
// AssignProcessToJobObject fails with ACCESS_DENIED on a terminated
// process.
let mut child = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command", "Start-Sleep -Milliseconds 500; exit 0"])
.spawn()
.expect("spawn powershell");
let job = CleanupJob::new().expect("create job");
job.assign_raw(child.raw_handle().expect("child handle"))
.expect("assign child");
let status = child.wait().await.expect("wait child");
assert!(status.success(), "job membership must not disturb a normal run");
}
#[tokio::test]
async fn global_job_is_created_once_and_assign_helper_is_silent() {
let job1 = global_cleanup_job().expect("global job should create on a normal system") as *const _;
let job2 = global_cleanup_job().expect("second call returns the same job") as *const _;
assert_eq!(job1, job2, "global job must be a singleton");
let child = tokio::process::Command::new("powershell")
.args(["-NoProfile", "-Command", "exit 0"])
.spawn()
.expect("spawn powershell");
// Must not panic / error loudly.
assign_to_cleanup_job(&child);
}
}