diff --git a/AGENTS.md b/AGENTS.md index 14727b2..85c2242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,4 +4,4 @@ - **vz-smoke.sh 的 pkill 治標可升級為斷言**:helper stdin-EOF 自我了結(vz.rs + vz-helper/main.swift)合併後,`scripts/vz-smoke.sh` 收尾的 `pkill -f chefer-vz-helper`(目前在主 checkout 未 commit 的變更中)可改成「等 ~10s 斷言無 chefer-vz-helper 殘留」,讓實機一鍵驗證直接覆蓋這個修復。 - **runtime 單發 SIGINT 下 helper 收攤有 ~5s 延遲**:runtime 的 ctrlc handler 等 5 秒才 `exit(130)`,stdin EOF 要到行程死亡才發生(實測 SIGINT→helper 收攤約 6s;SIGTERM/SIGKILL 即時)。若要即時收攤,可讓 vz 後端把 helper stdin 寫端交給訊號處理路徑、收訊號時主動關閉。屬優化,非正確性問題。 -- **Windows whp-helper 疑有同款孤兒化問題**:`crates/vmm-backend/src/whp.rs` spawn `chefer-whp-helper` 時無 Job Object、無 stdin liveness——對 runtime 單殺(`taskkill /PID`,非 console Ctrl+C)時 helper/VM 推測會殘留(程式碼層面確認無任何防護;未在實機重現)。建議用 Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)或比照 vz 的 stdin-EOF 做法。 +- **whp 防孤兒修復待實機驗證**:Job Object(KILL_ON_JOB_CLOSE)+ helper stdin-EOF 自我了結(`crates/vmm-backend/src/whp.rs` + `crates/whp-helper/src/main.rs`)已完成、機制各有 CI 單元測試,但「單殺 runtime 後 helper/VM 不殘留」的端到端行為需實體 Windows(WHP)驗證:以 `CHEFER_BACKEND=whp` 跑一個 bundle → `taskkill /F /PID `(TerminateProcess,孤兒化的可靠重現法;原始問題本身也尚未在實機重現過)→ 確認 `chefer-whp-helper` 行程數秒內消失、無殘留。通過後刪除本項。 diff --git a/crates/vmm-backend/Cargo.toml b/crates/vmm-backend/Cargo.toml index a59689a..a17ac69 100644 --- a/crates/vmm-backend/Cargo.toml +++ b/crates/vmm-backend/Cargo.toml @@ -12,7 +12,13 @@ tar = "0.4.46" [target."cfg(windows)".dependencies] tempfile = "3.27.0" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_System_Threading"] } +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + # WHP helper 防孤兒:KILL_ON_JOB_CLOSE Job Object(whp.rs,DESIGN §6「Helper 生命週期」)。 + "Win32_System_JobObjects", +] } [dev-dependencies] serde_json = "1.0.145" diff --git a/crates/vmm-backend/src/whp.rs b/crates/vmm-backend/src/whp.rs index 36a34cc..f42b8a4 100644 --- a/crates/vmm-backend/src/whp.rs +++ b/crates/vmm-backend/src/whp.rs @@ -8,12 +8,18 @@ use std::ffi::c_void; use std::io::{BufRead, BufReader}; use std::mem::size_of; +use std::os::windows::io::AsRawHandle; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use chefer_bundle::PortProto; -use windows_sys::Win32::Foundation::FreeLibrary; +use windows_sys::Win32::Foundation::{CloseHandle, FreeLibrary, HANDLE}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, +}; use windows_sys::Win32::System::LibraryLoader::{ GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExA, }; @@ -116,6 +122,7 @@ impl ExecBackend for WhpBackend { let mut child = Command::new(&invocation.helper) .args(invocation.args()) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .with_context(|| { @@ -125,6 +132,28 @@ impl ExecBackend for WhpBackend { ) })?; + // 防孤兒(DESIGN §6 whp「Helper 生命週期」):runtime 的 Ctrl+C 處理仰賴 helper + // 同 console 收到同一個 console 事件,但對 runtime 單殺(taskkill /PID,非 console + // Ctrl+C)時 helper 收不到任何訊號,micro-VM 會殘留。雙保險: + // ① Job Object(KILL_ON_JOB_CLOSE):job handle 故意洩漏、隨本行程存亡——runtime + // 無論怎麼死(taskkill /F、當機、正常結束)OS 都會關閉 handle → job 關閉 → + // helper 連同 VM 被系統終結。spawn 與掛入之間的微小空窗與掛入失敗由 ② 後援。 + if let Err(e) = assign_to_kill_on_close_job(&child) { + eprintln!( + "[chefer] warning: failed to put the WHP helper in a kill-on-close job: {e}; \ + relying on the helper's stdin-EOF self-termination" + ); + } + + // ② stdin liveness(與 vz 同契約):helper 讀到 stdin EOF 即自我了結。寫端由本 + // 行程握住不寫——WHP 的 guest console 無輸入路徑(serial 僅 TX),無 vz 的 + // terminal stdin 泵送需求——mem::forget 讓 fd 隨行程存亡。 + let helper_stdin = child + .stdin + .take() + .expect("child stdin was requested as piped"); + std::mem::forget(helper_stdin); + let stdout = child .stdout .take() @@ -154,6 +183,39 @@ impl ExecBackend for WhpBackend { } } +// --------------------------------------------------------------------------- +// Helper 防孤兒(Job Object) +// --------------------------------------------------------------------------- + +/// 把 child 掛進 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Job Object,回傳 job handle +///(防孤兒 ①,DESIGN §6 whp「Helper 生命週期」)。呼叫端**故意洩漏** handle(不 +/// CloseHandle)——handle 隨本行程存亡,行程死亡(任何方式)時 OS 關閉它 → job 關閉 +/// → child 連同其中的 micro-VM 被系統終結。Windows 8+ 支援巢狀 job:本行程已在別的 +/// job(如 CI runner)也掛得進去。 +fn assign_to_kill_on_close_job(child: &std::process::Child) -> std::io::Result { + unsafe { + let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if job.is_null() { + return Err(std::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 configured = SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&raw const info).cast(), + size_of::() as u32, + ) != 0 + && AssignProcessToJobObject(job, child.as_raw_handle() as HANDLE) != 0; + if !configured { + let err = std::io::Error::last_os_error(); + CloseHandle(job); + return Err(err); + } + Ok(job) + } +} + // --------------------------------------------------------------------------- // Host capability probing // --------------------------------------------------------------------------- @@ -363,4 +425,46 @@ mod tests { assert!(reason.contains("missing agents/chefer-whp-helper-x86_64.exe")); } + + /// 鎖住防孤兒 ① 的核心語意(DESIGN §6 whp「Helper 生命週期」):關掉唯一的 + /// job handle → job 內的行程被系統終結(KILL_ON_JOB_CLOSE)。真 WHP VM 情境 + ///(runtime 被 taskkill)只能實機驗證;此測試以長命 dummy child 在 CI 的 + /// Windows runner 上鎖住機制本身。 + #[test] + fn kill_on_close_job_terminates_child_when_handle_closes() { + use std::time::Duration; + + // 長命 child(約 60 秒自我了結——測試失敗時不殘留)。 + let mut child = Command::new("ping") + .args(["-n", "60", "127.0.0.1"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to spawn the dummy child (ping)"); + + let job = assign_to_kill_on_close_job(&child).expect("failed to create/assign the job"); + assert!( + child.try_wait().expect("try_wait failed").is_none(), + "the child died right after being assigned to the job" + ); + + // 關掉唯一的 handle:模擬 runtime 行程死亡時 OS 的行為。 + unsafe { CloseHandle(job) }; + + let mut exited = false; + for _ in 0..100 { + if child.try_wait().expect("try_wait failed").is_some() { + exited = true; + break; + } + std::thread::sleep(Duration::from_millis(100)); + } + if !exited { + let _ = child.kill(); + } + assert!( + exited, + "the child survived closing the job handle; KILL_ON_JOB_CLOSE did not take effect" + ); + } } diff --git a/crates/whp-helper/src/main.rs b/crates/whp-helper/src/main.rs index 2de4e44..deeab8a 100644 --- a/crates/whp-helper/src/main.rs +++ b/crates/whp-helper/src/main.rs @@ -64,9 +64,40 @@ fn run(args: impl IntoIterator) -> Result<(), String> { } let request = HelperRequest::parse(args)?; + + // stdin liveness(防孤兒 ②,與 vz-helper 同契約;DESIGN §6 whp「Helper 生命週期」): + // chefer-runtime 以 piped stdin spawn 本程式並讓寫端 fd 隨其行程存亡——runtime 無論 + // 怎麼死(taskkill、當機、正常結束)OS 都會關 fd,這裡讀到 EOF 即自我了結(VM 在 + // 本行程內,exit 即拆)。WHP 的 guest console(ttyS0)無輸入路徑(serial.rs 僅 TX), + // 讀到的資料直接丟棄;未來若接 console 輸入,這條執行緒就是轉發點。僅 boot 模式 + // 安裝——--preflight/--gui-selftest 由 `.output()` 驅動(stdin 立即 EOF),裝了會 + // 誤殺;手動在終端跑 helper 時 stdin 是 console、不會 EOF,行為不變。cfg(not(test)): + // 單元測試走進 run() 時不得讀走測試行程的 stdin(cargo test 下常已關閉→立即 EOF + // →誤殺整個測試行程)。 + #[cfg(not(test))] + std::thread::spawn(|| { + drain_until_eof(std::io::stdin().lock()); + eprintln!("chefer-whp-helper: stdin closed (chefer-runtime is gone); shutting down"); + std::process::exit(0); + }); + run_boot(request) } +/// 讀 `reader` 直到 EOF,資料一律丟棄;`Interrupted` 重試,其他讀取錯誤視同 EOF +///(stdin 壞掉時同樣不能再當存活訊號)。回傳=寫端已全數消失。 +fn drain_until_eof(mut reader: impl std::io::Read) { + let mut buf = [0u8; 1024]; + loop { + match reader.read(&mut buf) { + Ok(0) => return, + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return, + } + } +} + fn print_help() { println!( "chefer-whp-helper\n\ @@ -3283,4 +3314,51 @@ mod tests { let err = run_preflight(args).unwrap_err(); assert!(err.contains("--cpus requires a value")); } + + // --- stdin liveness(drain_until_eof,DESIGN §6 whp「Helper 生命週期」)--- + + /// 先 Interrupted、再給資料、最後 EOF 的 reader——鎖住「Interrupted 重試、 + /// 資料丟棄、EOF 才返回」的契約。 + struct FlakyReader { + interrupts_left: u32, + data_left: usize, + } + + impl std::io::Read for FlakyReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.interrupts_left > 0 { + self.interrupts_left -= 1; + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + if self.data_left == 0 { + return Ok(0); // EOF + } + let n = self.data_left.min(buf.len()); + self.data_left -= n; + Ok(n) + } + } + + #[test] + fn drain_until_eof_retries_interrupted_and_returns_on_eof() { + // 返回本身就是斷言:Interrupted 沒把它打斷、資料沒被當 EOF 提早返回、 + // EOF 後不再讀(卡住則測試逾時失敗)。 + drain_until_eof(FlakyReader { + interrupts_left: 3, + data_left: 8192, + }); + drain_until_eof(std::io::empty()); // 空輸入:立即 EOF + } + + #[test] + fn drain_until_eof_treats_read_errors_as_eof() { + struct BrokenReader; + impl std::io::Read for BrokenReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe)) + } + } + // 讀取錯誤視同 EOF:返回而非無窮重試。 + drain_until_eof(BrokenReader); + } } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9f40d6f..fbb6cba 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -265,6 +265,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai 7. **免 WSL 的 `whp` 後端**:以 **Windows Hypervisor Platform(WHP)** 開機 bundle 內附的 Linux micro-VM appliance(與 macOS `vz` 共用同一 kernel/initramfs/guest-agent),作為 `wsl2` 的替代後端,移除對 WSL2 的依賴(仍需硬體虛擬化 + WHP 功能)。對完全無虛擬化的機器,另可選擇性 bundle 軟體模擬(QEMU/TCG)作為最終備援(可跑但慢)。backend 抽象(`ExecBackend`)已納入 `whp`,Windows 後端排序為 `wsl2` → `whp`;`whp` 以 `WinHvPlatform.dll` + `WHvGetCapability(HypervisorPresent)` 做 host preflight,並在有 bundle context 時檢查 `vm/chefer-vmlinuz-`、`vm/chefer-initramfs-`、`agents/chefer-whp-helper-.exe` 是否存在——三者皆備且 hypervisor 可用時回傳 `Available`,由 runtime 的 `run()` spawn helper 執行完整開機,stdout 逐行解析 `CHEFER_GUEST_IP=` / `CHEFER_GUEST_EXIT=` 標記。helper CLI contract:`chefer-whp-helper --kernel

--initramfs

--cmdline --bundle-dir

--data-dir

--cpus --memory-mib [--timeout ] [--forward-tcp ]... [--forward-udp ]...`(`--forward-tcp`/`--forward-udp` 可重複,宣告 host→guest TCP/UDP 埠轉發;見下 virtio-net 說明,WHP 的埠轉發 listener 由 helper 自身持有)。 **WHP 專屬 kernel 參數(已實測確認)**:`nolapic`(WHP LAPIC emulation 不完整,改由 host 直接注入 timer interrupt)、`lpj=1000000`(跳過 calibration busy-wait)、`notsc clocksource=jiffies`(TSC 在 minimal VM 不可靠)。此外 initramfs 內的 init 必須為 **非 PIE 靜態 ELF**(static non-PIE,`ET_EXEC`;PIE 的 `ET_DYN` 在無 dynamic linker 的 minimal VM 會 segfault),且 initramfs 必須包含 `/dev/console`(char device major=5, minor=1)供 kernel 開啟 init 的 stdio fd 0/1/2。 **Guest→host exit code 通道(`/dev/kmsg`)**:guest init 結束前將 `CHEFER_GUEST_EXIT=` 寫入 **`/dev/kmsg`**(非 stdout),因 user-space 寫 `/dev/console`(tty 路徑)需 serial IRQ4 驅動 TX,而 WHP 的最小 8250 模擬不含 IRQ 觸發——`/dev/kmsg` 走 kernel printk polled I/O 路徑,可直接出現在 serial console。 + **Helper 生命週期(防孤兒:Job Object + stdin liveness)**:runtime 的 Ctrl+C 處理仰賴「helper 與 runtime 同 console、console 事件同時送達」,但對 runtime **單殺**(`taskkill /PID `,非 console Ctrl+C)時 helper 收不到任何訊號——與 vz 實機發現的孤兒化同款問題(見 vz 節),helper/micro-VM 會殘留繼續吃 CPU。修法雙保險:① **Job Object**——whp.rs spawn helper 後立刻把它掛進 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Job Object 並**故意洩漏 job handle**(不 CloseHandle);handle 隨 runtime 行程存亡,runtime 無論怎麼死(taskkill /F、當機、正常結束)OS 都會關閉 handle → job 關閉 → helper 連同 VM 被系統終結(Windows 8+ 支援巢狀 job,runtime 本身已在別的 job(如 CI runner)也能掛;掛入失敗僅警告不阻斷,由 ② 後援)。② **stdin liveness(與 vz 同契約)**——whp.rs 以 `Stdio::piped()` spawn helper 並讓 stdin 寫端 fd 隨行程存亡(`mem::forget`);helper 端 boot 模式由專屬執行緒讀 stdin,讀到 **EOF 即自我了結**(VM 在 helper 行程內,exit 即拆)。與 vz 的差異:WHP 的 guest console(ttyS0)**沒有輸入路徑**(serial 模擬僅 TX),故無 terminal stdin 泵送、讀到的資料一律丟棄;若未來接上 console 輸入,這條執行緒就是轉發點。`--preflight`/`--gui-selftest` 模式**不**安裝 stdin 監看(它們由 `.output()` 驅動、stdin 立即 EOF,裝了會誤殺)。兩機制皆不觸發 vdb data 回寫(同 M3 既有限制:僅 guest 乾淨關機回寫)。**狀態**:程式碼完成;Job Object 的 kill-on-close 語意與 helper 的 EOF 偵測純邏輯由 CI 單元測試鎖住(前者僅 Windows runner 執行);真 WHP VM 情境的孤兒化重現與修復驗證同「驗證邊界」慣例,待實機。 **`--preflight` 模式**(`chefer-whp-helper --preflight [--cpus ]`):動態載入 `WinHvPlatform.dll` → 走一輪 WHP device model 基礎生命週期,不開機、不需 appliance 或 bundle 路徑: 1. partition 建立與設定:`WHvCreatePartition` → `WHvSetPartitionProperty(ProcessorCount)` → `WHvSetupPartition`; 2. GPA 記憶體映射:`VirtualAlloc` 配置一頁(4 KiB)零值記憶體 → `WHvMapGpaRange` 映射到 GPA 0(驗證 host→guest 記憶體管理路徑);