From cc05bb712df2bd2b9a30c0d021e04f65cf86ff6f Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Sun, 26 Jul 2026 14:59:47 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(runtime):=20=E4=B8=AD=E6=96=B7=E8=A8=8A?= =?UTF-8?q?=E8=99=9F=E5=8D=B3=E6=99=82=E9=97=9C=20helper=20liveness=20?= =?UTF-8?q?=E5=AF=AB=E7=AB=AF=E2=80=94=E2=80=94=E6=94=B6=E6=94=A4=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=AD=89=205=20=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vmm-backend 新增行程級 liveness 登記簿(hold_liveness_handle / close_liveness_handles),vz、whp 兩個後端的 helper stdin 寫端改為交它保管 (語意等同原本的 mem::forget:handle 隨行程存亡)。runtime 的 Ctrl-C handler 進來第一件事就關掉它,helper 立刻讀到 EOF 自我了結、run_app 立刻返回,不必等 5 秒寬限跑完 exit(130)(實測單發 SIGINT 原本約 6 秒才收攤)。 有 terminal/both 服務時不登記——寫端在 stdin 泵送執行緒手上,訊號路徑關它會 與 write 競爭 fd;那種 app 跑在終端機裡,Ctrl-C 走 process group 沒這問題。 同時把 vz-smoke.sh 收尾的 pkill 治標改成斷言:等 10 秒內 chefer-vz-helper 必須 消失,否則報「helper 變孤兒」——實機一鍵驗證直接覆蓋 stdin-EOF 這條修復。 AGENTS.md 的 follow-ups 收掉四項(vz-smoke 斷言、SIGINT 收攤延遲、whp 防孤兒 實機驗證、branch protection 檢查名稱),新增一項實機觀察待查。 --- AGENTS.md | 5 +-- crates/chefer-runtime/src/run.rs | 5 +++ crates/vmm-backend/src/lib.rs | 56 ++++++++++++++++++++++++++++++++ crates/vmm-backend/src/vz.rs | 5 +-- crates/vmm-backend/src/whp.rs | 5 +-- docs/DESIGN.md | 4 +-- scripts/vz-smoke.sh | 16 ++++++--- 7 files changed, 82 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4758477..9b38653 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,4 @@ Validation rules live in `crates/appcipe-spec/src/validate.rs`; it collects **al ## Follow-ups -- **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 寫端交給訊號處理路徑、收訊號時主動關閉。屬優化,非正確性問題。 -- **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` 行程數秒內消失、無殘留。通過後刪除本項。 -- **branch protection 的必要檢查名稱失效,每個 PR 都得 admin bypass**:main 的 required status checks 含一條 `whp-helper (windows compile-check)`(無後綴),但該 CI job 從建立起就是 matrix(`.github/workflows/ci.yml`),實際回報名稱是 `whp-helper (windows compile-check) (x86_64-pc-windows-msvc)` 與 `(aarch64-pc-windows-msvc)`——這條 context 永遠不會被滿足,所有 PR 的 merge state 都是 BLOCKED,只能靠 owner `--admin`/網頁勾「bypass」合併(2026-07-11 PR #126 實測確認)。修法(需 repo 管理權限,Settings → Branches → main 的 required status checks,或 `gh api` PATCH `branches/main/protection/required_status_checks`):把無後綴那條換成上述兩條帶 target 後綴的。修好後刪除本項。 +- **WHP 上 guest 服務起不來(實機觀察,未定因)**:2026-07-26 在實體 Windows 11 以 `CHEFER_BACKEND=whp` 跑 alpine:3.20 單服務 bundle,VM 開機正常、console 出到 `CHEFER_GUEST_IP=10.0.2.15` 就停住,服務的 stdout 標記從未出現(等 2~4 分鐘,兩次重現)。已排除 guest-agent 過舊(換成 HEAD 的 musl build 仍相同);HEAD 的 appliance QEMU E2E 在 CI 是綠的,故懷疑是本機 `kit/` 的 appliance 過舊(2026-07-03 建,早於 `ac9ec98` init PATH export 與 `440a535` 的 `dns0=` cmdline),但**未證實**。下一步:在 Linux/WSL 跑 `CHEFER_LINUX_REF=v6.6.32 bash scripts/build-appliance.sh --arch x86_64` 建 HEAD appliance 後重跑同一個 bundle;若仍卡在同一點,就是 WHP 專屬且 CI 覆蓋不到的迴歸,需正式查。(防孤兒行為本身不受影響,已於同一輪實機驗過。) diff --git a/crates/chefer-runtime/src/run.rs b/crates/chefer-runtime/src/run.rs index 2fbd838..a3acb0b 100644 --- a/crates/chefer-runtime/src/run.rs +++ b/crates/chefer-runtime/src/run.rs @@ -47,11 +47,16 @@ pub fn run(bundle_dir: &Path, keep_tmp: bool) -> Result { // Ctrl-C:印訊息後等 run_app 自然返回(後端子行程共享 console,會收到 // 同號訊號自行結束);若 5 秒內未返回則以 130 強制退出。 + // + // 對 runtime **單發**訊號(`kill -INT `、非終端機整個 process group)時 VM helper + // 收不到訊號,只能靠 stdin EOF 自我了結——那要等本行程真的死掉,等於白等 5 秒。故先 + // 主動關掉 helper 的 liveness 寫端,讓它立刻收攤、run_app 立刻返回。 let finished = Arc::new(AtomicBool::new(false)); { let finished = Arc::clone(&finished); ctrlc::set_handler(move || { tracing::info!("Received interrupt (Ctrl-C); waiting for services to stop…"); + vmm_backend::close_liveness_handles(); for _ in 0..50 { if finished.load(Ordering::SeqCst) { return; diff --git a/crates/vmm-backend/src/lib.rs b/crates/vmm-backend/src/lib.rs index e24d088..0e8348c 100644 --- a/crates/vmm-backend/src/lib.rs +++ b/crates/vmm-backend/src/lib.rs @@ -35,6 +35,9 @@ mod vz_util; #[allow(dead_code)] mod whp_util; +use std::process::ChildStdin; +use std::sync::Mutex; + use anyhow::Result; /// 後端可用性檢查結果。 @@ -114,6 +117,32 @@ pub fn whp_availability() -> Availability { } } +/// helper 的 stdin liveness 寫端(見 DESIGN §6 vz/whp 的「Helper 生命週期」)。 +/// +/// helper 讀到 stdin EOF 即自我了結,所以寫端只要活著 helper 就活著。這裡收下寫端相當於 +/// 舊的 `mem::forget`(fd 隨 runtime 行程存亡,OS 保底),另外多一條「訊號路徑主動關閉」的 +/// 快捷路徑——見 [`close_liveness_handles`]。 +static LIVENESS_HANDLES: Mutex> = Mutex::new(Vec::new()); + +/// 交出 helper stdin 寫端由本模組保管,直到行程結束或 [`close_liveness_handles`] 被呼叫。 +pub(crate) fn hold_liveness_handle(handle: ChildStdin) { + lock_liveness().push(handle); +} + +/// 中斷訊號路徑呼叫:立刻關掉所有 helper liveness 寫端,讓 helper 讀到 EOF 自我了結。 +/// +/// 沒有這條路徑時,helper 要等到 runtime 行程真的死亡(Ctrl-C handler 的 5 秒寬限跑完、 +/// `exit(130)`)才會收到 EOF——對 runtime **單發** SIGINT 時實測約 6 秒才收攤。呼叫本函式 +/// 讓 helper 立刻結束,`run_app` 也就立刻返回、走正常結束路徑。 +pub fn close_liveness_handles() { + lock_liveness().clear(); +} + +/// 取鎖;中毒時照樣取用內部值——這是訊號路徑,不能因為別處 panic 過就放棄收攤。 +fn lock_liveness() -> std::sync::MutexGuard<'static, Vec> { + LIVENESS_HANDLES.lock().unwrap_or_else(|e| e.into_inner()) +} + /// 取第一個可用的後端執行 app;全部不可用時彙整每個後端的名稱與原因報錯。 /// /// `CHEFER_BACKEND` 環境變數可強制只用某個後端(例如 `CHEFER_BACKEND=whp`):用於在 @@ -279,6 +308,33 @@ mod tests { list.iter().map(|backend| backend.name()).collect() } + /// helper 的防孤兒契約:liveness 寫端一被 [`close_liveness_handles`] 關掉,讀端就拿到 + /// EOF 並自我了結——不必等 runtime 行程死亡。這裡用 `cat`(讀 stdin 到 EOF 就結束)代打 + /// helper,直接驗「關閉 → 子行程結束」這條因果。 + #[cfg(unix)] + #[test] + fn closing_liveness_handles_gives_the_child_eof() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cat") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .expect("spawn cat"); + hold_liveness_handle(child.stdin.take().expect("piped stdin")); + + // 寫端還握著 → cat 應該還在等輸入。 + std::thread::sleep(std::time::Duration::from_millis(200)); + assert!( + child.try_wait().expect("try_wait").is_none(), + "child exited while the liveness handle was still held" + ); + + close_liveness_handles(); + let status = child.wait().expect("wait"); + assert!(status.success(), "child exited abnormally: {status:?}"); + } + #[cfg(not(target_os = "windows"))] #[test] fn cleanup_distros_errors_off_windows() { diff --git a/crates/vmm-backend/src/vz.rs b/crates/vmm-backend/src/vz.rs index 0cbffac..51b40de 100644 --- a/crates/vmm-backend/src/vz.rs +++ b/crates/vmm-backend/src/vz.rs @@ -151,8 +151,9 @@ impl ExecBackend for VzBackend { }); } else { // 無 terminal 服務:不讀使用者終端的 stdin(避免把輸入吞進 guest console), - // 只讓寫端 fd 隨行程存亡。 - std::mem::forget(helper_stdin); + // 只讓寫端 fd 隨行程存亡;另交由 crate 保管,讓中斷訊號路徑能提前關閉它 + // (見 crate::close_liveness_handles),不必等 runtime 行程真的死掉。 + crate::hold_liveness_handle(helper_stdin); } let stdout = child diff --git a/crates/vmm-backend/src/whp.rs b/crates/vmm-backend/src/whp.rs index f42b8a4..c540708 100644 --- a/crates/vmm-backend/src/whp.rs +++ b/crates/vmm-backend/src/whp.rs @@ -147,12 +147,13 @@ impl ExecBackend for WhpBackend { // ② stdin liveness(與 vz 同契約):helper 讀到 stdin EOF 即自我了結。寫端由本 // 行程握住不寫——WHP 的 guest console 無輸入路徑(serial 僅 TX),無 vz 的 - // terminal stdin 泵送需求——mem::forget 讓 fd 隨行程存亡。 + // terminal stdin 泵送需求——交由 crate 保管,寫端 handle 隨行程存亡,並讓 + // 中斷訊號路徑能提前關閉它(見 crate::close_liveness_handles)。 let helper_stdin = child .stdin .take() .expect("child stdin was requested as piped"); - std::mem::forget(helper_stdin); + crate::hold_liveness_handle(helper_stdin); let stdout = child .stdout diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e6d8e4f..87b3301 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -266,7 +266,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 情境的孤兒化重現與修復驗證同「驗證邊界」慣例,待實機。 + **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,寫端交給 vmm-backend 的行程級登記簿(`hold_liveness_handle`;handle 隨行程存亡,另可由 Ctrl-C handler 經 `close_liveness_handles()` 提前關閉——見 vz 節「訊號路徑的快捷關閉」);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 情境的修復驗證**已於 2026-07-26 實體 Windows 11(WHP)通過**:`CHEFER_BACKEND=whp` 跑一個 bundle 開起 VM 後,對 runtime 下 `taskkill /F /PID`(TerminateProcess,不跑任何 handler,只剩 Job Object 這條保底)→ `chefer-whp-helper` 於 **0.5 秒內**消失、無殘留行程(兩次重現一致)。 **`--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 記憶體管理路徑); @@ -299,7 +299,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - arch 對應同 `layout::platform_to_arch`(x86_64 / aarch64);Apple Silicon 上以 arm64 guest 為主。 - **開機機制 — 內附 Swift helper(`chefer-vz-helper-`)**:實際驅動 Virtualization.framework 的是一支以 **Swift(Apple 第一方 VZ API)** 寫的小 helper,與 guest-agent/pasta/appliance 同屬 kit 內附的協力執行檔,打包 macOS 目標時嵌進 bundle(`agents/chefer-vz-helper-`,host macho、host 架構)。選 Swift 而非 in-process objc2 綁定:VZ 的 Swift API 文件完整、正確性高、可獨立編譯/簽章;Rust 後端只負責 spawn helper、串接其 stdout、解析 console 標記、做 host 端埠轉發——這條 Rust 路徑在任何平台都可單元測試。helper 與 chefer-runtime 以 **CLI 介面 + stdout 標記**鬆耦合(見下)。 - **VM 組態(helper 內,`VZVirtualMachineConfiguration`)**:`VZLinuxBootLoader(kernelURL = vmlinuz, initialRamdiskURL = initramfs, commandLine = "console=hvc0 quiet ip=dhcp panic=-1 ...")`;CPU/記憶體由 helper 參數帶入(CPU = min(host, 4)、RAM ≥512MiB,可由 env 覆寫);share 用 `VZVirtioFileSystemDeviceConfiguration`(virtiofs)把 bundle(唯讀)與 data dir(讀寫)掛進 guest;`VZVirtioConsoleDeviceSerialPortConfiguration` + `VZFileHandleSerialPortAttachment` 把 guest 的 hvc0 接到 helper 的 stdout(guest console → helper stdout → chefer-runtime 讀取解析);`VZNATNetworkDeviceConfiguration` + entropy/记忆体气球等預設裝置。helper CLI:`chefer-vz-helper --kernel

--initramfs

--cmdline --bundle-dir

--data-dir

--cpus --memory-mib `;開機成功→0、VZ 設定/開機錯誤→非 0(guest 整體 exit code 仍由 console 的 `CHEFER_GUEST_EXIT` 標記回傳)。 - - **Helper 生命週期(stdin liveness 契約;實機發現後補)**:runtime 的 Ctrl-C 處理仰賴「後端子行程同屬 process group、收到同號訊號自行結束」,但對 runtime **單發** SIGINT/SIGTERM(如 `kill -INT `;`vz-smoke.sh` 即如此收尾)時 helper 收不到訊號,實機發現 helper/VM 會殘留繼續吃 CPU。修法:vz.rs 以 `Stdio::piped()` spawn helper 並讓 stdin 寫端 fd 隨 runtime 行程存亡(`mem::forget`);helper 端由專屬執行緒代讀 stdin、轉發進 VZ serial(hvc0 輸入路徑不變),讀到 **EOF 即自我了結**(VZ 的 VM 在 helper 行程內,exit 即拆 VM)——runtime 無論怎麼死(單發訊號、SIGKILL、panic)OS 都會關 fd,helper 不可能殘留。stdin 泵送只在 manifest 有 terminal/both 服務時才做(terminal 服務 stdin 直通,見 §7 stdio 模型);其他 app runtime 不讀自己的 stdin、只握住寫端,避免把使用者終端輸入吞進 guest console。純函式 `vz_util::needs_console_stdin_pump` 決定是否泵送(含單元測試)。 + - **Helper 生命週期(stdin liveness 契約;實機發現後補)**:runtime 的 Ctrl-C 處理仰賴「後端子行程同屬 process group、收到同號訊號自行結束」,但對 runtime **單發** SIGINT/SIGTERM(如 `kill -INT `;`vz-smoke.sh` 即如此收尾)時 helper 收不到訊號,實機發現 helper/VM 會殘留繼續吃 CPU。修法:vz.rs 以 `Stdio::piped()` spawn helper 並讓 stdin 寫端 fd 隨 runtime 行程存亡(`mem::forget`);helper 端由專屬執行緒代讀 stdin、轉發進 VZ serial(hvc0 輸入路徑不變),讀到 **EOF 即自我了結**(VZ 的 VM 在 helper 行程內,exit 即拆 VM)——runtime 無論怎麼死(單發訊號、SIGKILL、panic)OS 都會關 fd,helper 不可能殘留。**訊號路徑的快捷關閉**:光靠 OS 關 fd 的話,EOF 要等 runtime 行程真的死掉才發生——Ctrl-C handler 有 5 秒寬限才 `exit(130)`,實測單發 SIGINT 約 6 秒才收攤。故寫端不再 `mem::forget`,改交給 vmm-backend 的行程級登記簿(`hold_liveness_handle`,語意等同 forget:handle 隨行程存亡),runtime 的 Ctrl-C handler 進來第一件事就呼叫 `vmm_backend::close_liveness_handles()` 關掉它,helper 立刻收攤、`run_app` 立刻返回、走正常結束路徑(不必 `exit(130)`)。OS 關 fd 仍是 SIGKILL/panic 的保底。**有 terminal/both 服務時不登記**(寫端在泵送執行緒手上,訊號路徑關它會與 write 競爭 fd)——那種 app 本來就跑在終端機裡,Ctrl-C 走 process group 沒有這個問題。stdin 泵送只在 manifest 有 terminal/both 服務時才做(terminal 服務 stdin 直通,見 §7 stdio 模型);其他 app runtime 不讀自己的 stdin、只握住寫端,避免把使用者終端輸入吞進 guest console。純函式 `vz_util::needs_console_stdin_pump` 決定是否泵送(含單元測試)。 - **Console 標記契約(host 解析 guest 狀態的唯一管道)**:appliance `init` 在 hvc0 印出兩個標記,host 端以子字串比對解析(`vz_util::parse_guest_exit_code` / `parse_guest_ip`,取最後一個、容忍前綴): - `CHEFER_GUEST_EXIT=`:guest-agent 結束後印出,隨即關機 → host 以此作為整個 app 的 exit code。 - `CHEFER_GUEST_IP=`:開機掛載後、跑 app 前印出 guest eth0 的對外 IPv4,供 host 規劃 host→guest 埠轉發。 diff --git a/scripts/vz-smoke.sh b/scripts/vz-smoke.sh index 71fddc9..571798f 100644 --- a/scripts/vz-smoke.sh +++ b/scripts/vz-smoke.sh @@ -171,12 +171,20 @@ done kill -INT "$app_pid" 2>/dev/null || true wait "$app_pid" 2>/dev/null || true trap - EXIT -# 對 runtime 單發 SIGINT(非終端機 Ctrl+C 的整個 process group)不會帶走 helper/VM, -# 實測會殘留吃 CPU——收尾把本次 work dir 下的 helper 一併清掉。 -pkill -f "$work/.*chefer-vz-helper" 2>/dev/null || true +# 對 runtime 單發 SIGINT(非終端機 Ctrl+C 的整個 process group)時,helper 靠 stdin EOF +# 自我了結(寫端見 crates/vmm-backend/src/vz.rs、讀端見 vz-helper/main.swift)——runtime +# 一結束 EOF 就到。直接斷言「helper 不會變孤兒」,不再用 pkill 打掃殘留。 +for _ in $(seq 1 10); do + pgrep -f "$work/.*chefer-vz-helper" >/dev/null 2>&1 || break + sleep 1 +done +if pgrep -f "$work/.*chefer-vz-helper" >/dev/null 2>&1; then + pkill -f "$work/.*chefer-vz-helper" 2>/dev/null || true + die "helper 變孤兒:runtime 已結束,chefer-vz-helper 10 秒後仍在(stdin-EOF 自我了結失效)" +fi grep -q "CHEFER_GUEST_IP=" "$log" || die "console 沒出現 CHEFER_GUEST_IP 標記(埠轉發不會啟動);log: $log" [[ $ok_port == 1 ]] || die "TCP 埠轉發失敗(curl 127.0.0.1:18080 不通);log: $log" -note "驗證二通過:服務常駐 ✓ CHEFER_GUEST_IP 標記 ✓ TCP 轉發 ✓" +note "驗證二通過:服務常駐 ✓ CHEFER_GUEST_IP 標記 ✓ TCP 轉發 ✓ helper 無殘留 ✓" if [[ $gui == 1 ]]; then note "5/5 驗證三(GUI,手動檢核):即將開 xclock 視窗(bridge 出網裝 xclock,順帶驗 pasta NAT)" From 77370fa00650505747606bd47e30674d10f7b9f9 Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Sun, 26 Jul 2026 21:23:02 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(repo):=20=E9=87=98=E4=BD=8F=20shell=20?= =?UTF-8?q?=E8=85=B3=E6=9C=AC=E8=88=87=20appliance=20init=20=E7=82=BA=20LF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows checkout 的 core.autocrlf=true 會把 working tree 全轉成 CRLF,導致 repo 內所有 .sh 在 WSL/Linux 下一跑就 `set: pipefail: invalid option name` (CRLF 讓 bash 當成 sh 語法錯誤),而 scripts/appliance/init 更嚴重——它會被 原樣烤進 initramfs,`#!/bin/busybox sh\r` 等於 guest 開機直接壞掉。 加 .gitattributes 明確釘 LF,與 CI(Linux runner)及 release 產物一致。 --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e5a0565 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Windows checkout 的 core.autocrlf=true 會把 working tree 轉成 CRLF,但這些檔案一定要在 +# Linux/macOS 側以 bash/sh 執行——CRLF 會讓 `set -Eeuo pipefail` 之類直接語法錯誤, +# scripts/appliance/init 更會被烤進 initramfs(`#!/bin/busybox sh\r` → guest 開不起來)。 +# 明確釘成 LF,與 CI(Linux runner)和 release 產物一致。 +*.sh text eol=lf +scripts/appliance/init text eol=lf From 172d376b56422e536234d4ce8574a64e7060bce1 Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Sun, 26 Jul 2026 21:53:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs(agents):=20WHP=20=E6=9C=8D=E5=8B=99?= =?UTF-8?q?=E8=B5=B7=E4=B8=8D=E4=BE=86=E5=B7=B2=E5=AE=9A=E5=9B=A0=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=98=AF=208250=20=E6=B2=92=E6=9C=89=20THRE=20?= =?UTF-8?q?=E4=B8=AD=E6=96=B7=EF=BC=8Cuserspace=20stdout=20=E9=80=81?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實機驗證翻案:服務其實正常啟動、埠轉發也通,看不到輸出是 whp-helper 的 serial 模擬 IIR 永遠回 no-pending、COM1 沒接 PIC,Linux 8250 tty 的 interrupt-driven TX 因此一個 byte 都送不出去;只有 kernel printk 的 polled console write 到得了 host。 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9b38653..c3ec096 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,4 +67,4 @@ Validation rules live in `crates/appcipe-spec/src/validate.rs`; it collects **al ## Follow-ups -- **WHP 上 guest 服務起不來(實機觀察,未定因)**:2026-07-26 在實體 Windows 11 以 `CHEFER_BACKEND=whp` 跑 alpine:3.20 單服務 bundle,VM 開機正常、console 出到 `CHEFER_GUEST_IP=10.0.2.15` 就停住,服務的 stdout 標記從未出現(等 2~4 分鐘,兩次重現)。已排除 guest-agent 過舊(換成 HEAD 的 musl build 仍相同);HEAD 的 appliance QEMU E2E 在 CI 是綠的,故懷疑是本機 `kit/` 的 appliance 過舊(2026-07-03 建,早於 `ac9ec98` init PATH export 與 `440a535` 的 `dns0=` cmdline),但**未證實**。下一步:在 Linux/WSL 跑 `CHEFER_LINUX_REF=v6.6.32 bash scripts/build-appliance.sh --arch x86_64` 建 HEAD appliance 後重跑同一個 bundle;若仍卡在同一點,就是 WHP 專屬且 CI 覆蓋不到的迴歸,需正式查。(防孤兒行為本身不受影響,已於同一輪實機驗過。) +- **WHP:guest userspace 的 stdout/stderr 完全不會到 host(已定因,待修)**:2026-07-26 實體 Windows 11 驗證。症狀是 console 出到 `CHEFER_GUEST_IP` 就沒下文,看起來像服務起不來——實際上**服務跑得好好的**(同一個 bundle 的埠轉發 curl 得到正確回應;把 init 改成把 guest-agent 輸出導進 `/dev/kmsg` 後,`service 'web' started`、`[web] …` 全都出現)。真因在 `crates/whp-helper/src/serial.rs`:8250 模擬的 IIR 永遠回 `0x01`(no pending interrupt)、COM1 完全沒接 PIC,於是 Linux 的 8250 **tty** 送字路徑(interrupt-driven,靠 THRE IRQ 續傳)一個 byte 都送不出去;kernel printk 走的是 polled console write,所以只有 kernel 與 init 的 `/dev/kmsg` 訊息看得到。修法:serial 實作 THRE 中斷(guest 設 IER bit1 時對 PIC 拉 IRQ 4、IIR 回 `0x02`、讀 IIR/寫 THR 後清除),並補一條「guest userspace stdout 會到 host」的 e2e 斷言(QEMU E2E 覆蓋不到,它用的是 virtio-console/hvc0)。影響範圍只有 whp 後端(vz 走 hvc0、WSL2 走 pipe,皆不受影響),但對 Windows-without-WSL 的使用者等於**所有 app 輸出都看不到**。 From cc188535a60f75635443e57f09068441e546b887 Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Sun, 26 Jul 2026 22:55:12 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(whp):=208250=20=E8=A3=9C=E4=B8=8A=20THR?= =?UTF-8?q?E=20=E4=B8=AD=E6=96=B7=E2=80=94=E2=80=94guest=20userspace=20?= =?UTF-8?q?=E7=9A=84=20stdout=20=E7=B5=82=E6=96=BC=E5=87=BA=E5=BE=97?= =?UTF-8?q?=E4=BE=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實體 Windows 11 抓到:WHP 上跑 app,console 只看得到 kernel 與 init 的 kmsg 訊息,服務輸出全黑,看起來像服務起不來。實際上服務跑得好好的(埠轉發 curl 通、 把 init 輸出導進 /dev/kmsg 就看得到 guest-agent 全部日誌)。 真因:serial.rs 的 IIR 永遠回 0x01(no pending interrupt),COM1 也沒接 PIC。 kernel 的 printk console 走 polled write 所以沒事,但 Linux 的 8250 tty 送字是 interrupt-driven——start_tx 開 IER bit1 之後就等 THRE 中斷才續傳,等不到就一個 byte 都送不出去。 修法照真硬體語意:寫 IER 開 THRI 或寫完 THR 就把 thre_pending 立起來(本模擬的 TX 即時完成),IO exit 後對 PIC 拉 IRQ 4,guest 讀 IIR 得 0x02 並認掉。Linux 沒 東西可送時會自己清掉 THRI(__stop_tx),所以不會變成中斷風暴——四條單元測試把 這個狀態機鎖住。 實機驗證(同一個 bundle,修前修後):修後 `[guest-agent] service 'web' started`、 `[web] WHP_NC_SERVICE_UP` 皆正常出現,埠轉發與防孤兒行為不受影響。 --- AGENTS.md | 2 +- crates/whp-helper/src/main.rs | 6 ++ crates/whp-helper/src/serial.rs | 101 ++++++++++++++++++++++++++++++-- docs/DESIGN.md | 2 +- 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c3ec096..8f85a64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,4 +67,4 @@ Validation rules live in `crates/appcipe-spec/src/validate.rs`; it collects **al ## Follow-ups -- **WHP:guest userspace 的 stdout/stderr 完全不會到 host(已定因,待修)**:2026-07-26 實體 Windows 11 驗證。症狀是 console 出到 `CHEFER_GUEST_IP` 就沒下文,看起來像服務起不來——實際上**服務跑得好好的**(同一個 bundle 的埠轉發 curl 得到正確回應;把 init 改成把 guest-agent 輸出導進 `/dev/kmsg` 後,`service 'web' started`、`[web] …` 全都出現)。真因在 `crates/whp-helper/src/serial.rs`:8250 模擬的 IIR 永遠回 `0x01`(no pending interrupt)、COM1 完全沒接 PIC,於是 Linux 的 8250 **tty** 送字路徑(interrupt-driven,靠 THRE IRQ 續傳)一個 byte 都送不出去;kernel printk 走的是 polled console write,所以只有 kernel 與 init 的 `/dev/kmsg` 訊息看得到。修法:serial 實作 THRE 中斷(guest 設 IER bit1 時對 PIC 拉 IRQ 4、IIR 回 `0x02`、讀 IIR/寫 THR 後清除),並補一條「guest userspace stdout 會到 host」的 e2e 斷言(QEMU E2E 覆蓋不到,它用的是 virtio-console/hvc0)。影響範圍只有 whp 後端(vz 走 hvc0、WSL2 走 pipe,皆不受影響),但對 Windows-without-WSL 的使用者等於**所有 app 輸出都看不到**。 +- **WHP 的 guest console 沒有自動化端到端保護**:8250 THRE 中斷(userspace stdout 的唯一出路,見 DESIGN §6 whp ④)只由 `serial.rs` 的單元測試鎖住狀態機;「guest 服務的 stdout 真的會到 host」目前只能在實體 Windows(WHP)手動驗——QEMU E2E 走 virtio-console/hvc0,覆蓋不到這條路徑,GitHub 的 windows runner 也開不了巢狀虛擬化。若之後有自架 WHP runner,補一支 whp-smoke(對照 `scripts/vz-smoke.sh`)把「`[svc]` 前綴輸出出現在 host」變成斷言。 diff --git a/crates/whp-helper/src/main.rs b/crates/whp-helper/src/main.rs index 8be27e2..9436334 100644 --- a/crates/whp-helper/src/main.rs +++ b/crates/whp-helper/src/main.rs @@ -2839,6 +2839,12 @@ mod whp_api { let val = rax as u8; if super::serial::SerialPort::handles(port) { serial.write(port, val); + // 寫 THR(送完一個 byte)或寫 IER 開 THRI 都會讓 THR-empty 中斷成立。 + // 這條線是 userspace stdout 唯一的出路——Linux 的 8250 tty 靠 THRE IRQ + // 續傳,不拉就一個 byte 都送不出來(見 serial.rs 模組說明)。 + if serial.irq_pending() { + pic1.request_irq(super::serial::COM1_IRQ); + } } else if port == 0x70 { *cmos_addr = val & 0x7F; } else if port == super::pic::PIC1_CMD { diff --git a/crates/whp-helper/src/serial.rs b/crates/whp-helper/src/serial.rs index 54dc187..3c45374 100644 --- a/crates/whp-helper/src/serial.rs +++ b/crates/whp-helper/src/serial.rs @@ -2,11 +2,25 @@ //! //! 僅實作 guest → host transmit;不支援 host → guest receive。 //! 跨平台可編譯與測試。 +//! +//! **THRE 中斷是必要的,不是選配**:kernel 的 printk console 走 polled write(讀 LSR 直接 +//! 塞 THR),但 Linux 的 8250 **tty** 送字路徑是 interrupt-driven——`start_tx` 開 IER bit1 +//! 之後就等 THRE 中斷才續傳。少了這條線,userspace 寫進 /dev/console 的位元組一個都到不了 +//! host(實機症狀:只看得到 kernel 與 init 的 kmsg 訊息,服務輸出全黑,看起來像服務沒起來)。 pub const COM1_BASE: u16 = 0x3F8; pub const COM1_END: u16 = 0x3FF; +/// COM1 在 8259 master 上的 legacy IRQ 線。 +pub const COM1_IRQ: u8 = 4; const MAX_SERIAL_OUTPUT: usize = 16 * 1024 * 1024; // 16 MiB +/// IER bit1:THR empty 中斷致能。 +const IER_THRI: u8 = 0x02; +/// IIR:無待處理中斷。 +const IIR_NO_PENDING: u8 = 0x01; +/// IIR:THR empty(本模擬唯一會產生的中斷源)。 +const IIR_THR_EMPTY: u8 = 0x02; + pub struct SerialPort { ier: u8, lcr: u8, @@ -14,6 +28,9 @@ pub struct SerialPort { scr: u8, dll: u8, dlh: u8, + /// THR 已空、尚未被 guest 讀 IIR 認掉的中斷。本模擬的 TX 即時完成,所以只要 + /// guest 開了 THRI 或剛送完一個 byte,THR 就是空的。 + thre_pending: bool, output: Vec, } @@ -26,15 +43,21 @@ impl SerialPort { scr: 0, dll: 0, dlh: 0, + thre_pending: false, output: Vec::new(), } } + /// 現在是否該對 PIC 拉 COM1 的 IRQ(guest 開了 THRI 且有未認的 THR-empty)。 + pub fn irq_pending(&self) -> bool { + self.ier & IER_THRI != 0 && self.thre_pending + } + pub fn handles(port: u16) -> bool { (COM1_BASE..=COM1_END).contains(&port) } - pub fn read(&self, port: u16) -> u8 { + pub fn read(&mut self, port: u16) -> u8 { match port - COM1_BASE { 0 => { if self.dlab() { @@ -50,7 +73,15 @@ impl SerialPort { self.ier } } - 2 => 0x01, // IIR: no pending interrupt + 2 => { + // 讀 IIR = guest 認掉這次中斷(真硬體同語意)。 + if self.irq_pending() { + self.thre_pending = false; + IIR_THR_EMPTY + } else { + IIR_NO_PENDING + } + } 3 => self.lcr, 4 => self.mcr, 5 => 0x60, // LSR: THRE + TEMT (ready) @@ -65,8 +96,12 @@ impl SerialPort { 0 => { if self.dlab() { self.dll = value; - } else if self.output.len() < MAX_SERIAL_OUTPUT { - self.output.push(value); + } else { + if self.output.len() < MAX_SERIAL_OUTPUT { + self.output.push(value); + } + // TX 即時完成 → THR 立刻又是空的,該通知 guest 續傳下一個 byte。 + self.thre_pending = true; } } 1 => { @@ -74,6 +109,10 @@ impl SerialPort { self.dlh = value; } else { self.ier = value; + if value & IER_THRI != 0 { + // guest 剛開啟 TX 中斷,而 THR 本來就是空的——立刻給它第一次踢。 + self.thre_pending = true; + } } } 2 => {} @@ -119,13 +158,63 @@ mod tests { #[test] fn lsr_reports_tx_ready() { - let sp = SerialPort::new(); + let mut sp = SerialPort::new(); assert_eq!(sp.read(COM1_BASE + 5), 0x60); } #[test] fn iir_no_pending_interrupt() { - let sp = SerialPort::new(); + let mut sp = SerialPort::new(); + assert_eq!(sp.read(COM1_BASE + 2), 0x01); + } + + // 以下四項鎖住 THRE 中斷契約。Linux 的 8250 **tty** 送字是 interrupt-driven: + // start_tx 開 IER bit1 後就等 THRE IRQ 才續傳。少了這條線,userspace 寫到 + // /dev/console 的位元組一個都出不來(kernel printk 走 polled write,不受影響), + // 實機症狀是 guest 服務看起來沒起來——實際上只是輸出全丟。 + + #[test] + fn enabling_the_tx_interrupt_raises_thre() { + let mut sp = SerialPort::new(); + sp.write(COM1_BASE + 1, 0x02); // IER: THRI on + assert!(sp.irq_pending()); + assert_eq!(sp.read(COM1_BASE + 2), 0x02); // IIR: THR empty + assert!(!sp.irq_pending(), "讀 IIR 應清掉本次中斷"); + assert_eq!(sp.read(COM1_BASE + 2), 0x01); + } + + #[test] + fn transmitting_re_arms_thre() { + let mut sp = SerialPort::new(); + sp.write(COM1_BASE + 1, 0x02); + sp.read(COM1_BASE + 2); // 清掉開啟中斷那次 + + sp.write(COM1_BASE, b'X'); // TX 即時完成 → THR 又空了 + assert!( + sp.irq_pending(), + "送完一個 byte 要再拉一次中斷,否則續傳會停住" + ); + assert_eq!(sp.read(COM1_BASE + 2), 0x02); + } + + #[test] + fn no_interrupt_while_the_guest_keeps_it_masked() { + let mut sp = SerialPort::new(); + sp.write(COM1_BASE, b'X'); + assert!(!sp.irq_pending()); + assert_eq!(sp.read(COM1_BASE + 2), 0x01); + } + + #[test] + fn disabling_the_tx_interrupt_stops_it() { + let mut sp = SerialPort::new(); + sp.write(COM1_BASE + 1, 0x02); + sp.write(COM1_BASE, b'X'); + sp.write(COM1_BASE + 1, 0x00); // Linux 的 __stop_tx:沒東西可送就關掉 + assert!( + !sp.irq_pending(), + "關掉 THRI 後不得再拉中斷,否則變成中斷風暴" + ); assert_eq!(sp.read(COM1_BASE + 2), 0x01); } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 87b3301..7a3fcc8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -288,7 +288,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai - **GPA 佈局**:在 kernel/initramfs/LAPIC 之外另闢 virtio-mmio 暫存器區(每裝置 0x200 bytes,例如 base `0xD000_0000` 起依序 +0x200),各配一個 PIC IRQ(如 5/6/7,避開既用線)。 - **MMIO transport 與指令解碼(關鍵決策)**:WHP 的 `EXIT_MEM_ACCESS` 只給 GPA + access type、**不給指令語意**(不像 PIO 的 `IoPortAccess` 已帶 port/方向/size/rax)。故 **用 WHP 內建指令模擬器 `WinHvEmulation.dll`**(`WHvEmulatorCreateEmulator` + `WHvEmulatorTryMmioEmulation`/`TryIoEmulation`,透過 Get/SetVirtualProcessorRegisters、TranslateGvaPage、Memory/IoPort callback 解碼存取),**不自寫 x86 decoder**。callback 內依 GPA 落在哪個 virtio-mmio 視窗 dispatch 給對應裝置。 - **virtqueue**:split virtqueue(desc/avail/used ring 在 guest RAM,host 以 GPA 讀寫);feature 協商至少 `VIRTIO_F_VERSION_1`。used ring 更新後經 PIC 注入該裝置 IRQ(沿用既有 inject 機制)。ring 解析與 register state machine 為跨平台純邏輯,進 CI 單元測試。 - - **裝置清單**:① **virtio-blk(bundle, ro)**+② **virtio-blk(data, rw)**——取代 vz 的 virtiofs。bundle/data 各以 **sector(512) 對齊的 tar image** 當 backing(`virtio::image::pack_dir`/`unpack_image`,純 Rust、跨平台可產生,免在 Windows host 備 mke2fs/mksquashfs);guest 端 busybox `tar` 展開到 tmpfs;data 於關機時把 image 解回 host 持久化。**已知取捨**:tar 需展開佔 guest RAM——對 data(小)合適,bundle(大)若 RAM 吃緊,後續可換唯讀 squashfs image。③ **virtio-net**——host→guest 埠轉發,host 端走 **純 Rust user-mode TCP/IP(smoltcp)**:helper 內以 smoltcp 當 guest 的 gateway(gateway `10.0.2.2`、guest 靜態 `10.0.2.15/24`,與 appliance init 約定),net 裝置兩個 queue(0=rx、1=tx)在 base `0xD000_0200` / IRQ 6。**埠轉發歸屬(WHP 專屬,與 vz 不同)**:smoltcp 的 guest IP 是 helper process 內的虛擬位址、host kernel 無路由可達,故 host→guest 轉發的 listener **必須由 helper 自身持有**——但 helper 並非綁使用者的 host 埠,而是按 WSL2 wslrelay 慣例把**每個 guest 埠暴露在 host `[::1]:`**(`--forward-tcp :` → `NetBackend::add_forward` 在 helper 內 bind `[::1]:listen`,再以 smoltcp TCP socket 橋接到 `guest_ip:guest`)。host≠guest 的對外 remap 仍交給 **chefer-runtime 既有的埠代理**(`proxy.rs`:bind `127.0.0.1:host` → 轉 `127.0.0.1`/`[::1]:guest`)——故 vmm-backend 只把要暴露的 guest 埠(去重)以 `:` 傳給 helper,**不可**像 vz 那樣直連 guest IP relay、也不重綁使用者 host 埠(會與 runtime proxy 撞埠)。綁 `[::1]` 而非 `127.0.0.1` 是為了同時相容 runtime 的 host==guest Windows 補橋(它佔 `127.0.0.1:guest` 並轉 `[::1]:guest`)。**UDP 同理**(`--forward-udp` → `add_udp_forward` 綁 `[::1]:listen` UDP,per-client smoltcp UDP socket 以 unique local port demux guest 回程),guest 端由 guest-agent 的 `start_vm_udp_bridges`(eth0→loopback UDP 橋接)承接。guest 主動對外(outbound NAT)已實作(見下方 M7):helper 的 `drain_tx` 把外部 dst 的 UDP/TCP 分流到 NAT 引擎(per-flow host socket),guest 內 `shared` 服務直走 eth0、`bridge` 服務經 pasta 到 eth0,皆可出網。④ console 沿用 ttyS0 + `/dev/kmsg` exit channel(§4 不變),暫不引入 virtio-console。 + - **裝置清單**:① **virtio-blk(bundle, ro)**+② **virtio-blk(data, rw)**——取代 vz 的 virtiofs。bundle/data 各以 **sector(512) 對齊的 tar image** 當 backing(`virtio::image::pack_dir`/`unpack_image`,純 Rust、跨平台可產生,免在 Windows host 備 mke2fs/mksquashfs);guest 端 busybox `tar` 展開到 tmpfs;data 於關機時把 image 解回 host 持久化。**已知取捨**:tar 需展開佔 guest RAM——對 data(小)合適,bundle(大)若 RAM 吃緊,後續可換唯讀 squashfs image。③ **virtio-net**——host→guest 埠轉發,host 端走 **純 Rust user-mode TCP/IP(smoltcp)**:helper 內以 smoltcp 當 guest 的 gateway(gateway `10.0.2.2`、guest 靜態 `10.0.2.15/24`,與 appliance init 約定),net 裝置兩個 queue(0=rx、1=tx)在 base `0xD000_0200` / IRQ 6。**埠轉發歸屬(WHP 專屬,與 vz 不同)**:smoltcp 的 guest IP 是 helper process 內的虛擬位址、host kernel 無路由可達,故 host→guest 轉發的 listener **必須由 helper 自身持有**——但 helper 並非綁使用者的 host 埠,而是按 WSL2 wslrelay 慣例把**每個 guest 埠暴露在 host `[::1]:`**(`--forward-tcp :` → `NetBackend::add_forward` 在 helper 內 bind `[::1]:listen`,再以 smoltcp TCP socket 橋接到 `guest_ip:guest`)。host≠guest 的對外 remap 仍交給 **chefer-runtime 既有的埠代理**(`proxy.rs`:bind `127.0.0.1:host` → 轉 `127.0.0.1`/`[::1]:guest`)——故 vmm-backend 只把要暴露的 guest 埠(去重)以 `:` 傳給 helper,**不可**像 vz 那樣直連 guest IP relay、也不重綁使用者 host 埠(會與 runtime proxy 撞埠)。綁 `[::1]` 而非 `127.0.0.1` 是為了同時相容 runtime 的 host==guest Windows 補橋(它佔 `127.0.0.1:guest` 並轉 `[::1]:guest`)。**UDP 同理**(`--forward-udp` → `add_udp_forward` 綁 `[::1]:listen` UDP,per-client smoltcp UDP socket 以 unique local port demux guest 回程),guest 端由 guest-agent 的 `start_vm_udp_bridges`(eth0→loopback UDP 橋接)承接。guest 主動對外(outbound NAT)已實作(見下方 M7):helper 的 `drain_tx` 把外部 dst 的 UDP/TCP 分流到 NAT 引擎(per-flow host socket),guest 內 `shared` 服務直走 eth0、`bridge` 服務經 pasta 到 eth0,皆可出網。④ console 沿用 ttyS0 + `/dev/kmsg` exit channel(§4 不變),暫不引入 virtio-console。**8250 必須實作 THRE 中斷**(`serial.rs`:guest 寫 IER bit1 或寫完 THR → `thre_pending`,IO exit 後對 PIC 拉 **IRQ 4**,guest 讀 IIR 得 `0x02` 並認掉)——kernel 的 printk console 走 polled write,但 Linux 的 8250 **tty** 送字路徑是 interrupt-driven(`start_tx` 開 THRI 後等中斷才續傳)。缺這條線時 kernel 訊息照常出現、**userspace 寫進 /dev/console 的位元組一個都到不了 host**,實機症狀是服務輸出全黑、看起來像服務沒起來(2026-07-26 實體 Windows 11 抓到並修復:修後同一個 bundle 的 `[guest-agent] service 'web' started`、`[web] …` 皆正常出現,埠轉發不受影響)。QEMU E2E 覆蓋不到這條(它走 virtio-console/hvc0),故以 `serial.rs` 的單元測試鎖住狀態機、端到端只能實機驗。 - **appliance 相容**:需要能在 WHP 環境開機的 appliance。kernel config 加 `CONFIG_VIRTIO_MMIO`/`_BLK`/`_NET`/`CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES`;cmdline 追加各 `virtio_mmio.device=...`。**init 改為自適應**(偵測 `/dev/hvc0` → vz 路徑掛 virtiofs;否則 WHP 路徑掛 `/dev/vda`(bundle ro)+`/dev/vdb`(data rw)、console 用 ttyS0),維持「whp 與 vz 共用一份 appliance」。init 另負責 `bridge` 出網(pasta)的環境前提:開機早期 `switch_root` 到 tmpfs 根(rootfs 上 `pivot_root` EINVAL)、確保 `/dev/net/tun` 存在且 0666;kernel config 含 `CONFIG_TUN`。 - **分階段里程碑(每個可獨立驗證;✅=已實機達成)**:✅ M1 transport(WHvEmulator 接線 + virtqueue)→ ✅ M2 virtio-blk(bundle ro)(guest 從 `/dev/vda` 讀 bundle 解 tar)→ ✅ M3 virtio-blk(data rw) 關機回寫(**實機達成**:helper 第二顆 virtio-blk vdb(base `0xD000_0400`/IRQ 7)以 `pack_dir(data_dir)` 補零到容量上限為 backing;guest 開機 `dd /dev/vdb | tar -x`、關機 `tar data_dir | dd of=/dev/vdb`,helper 關機 `unpack_image(vdb)→data_dir`;本機 WHP 實測 persist counter 跨重啟 1→2→3。**限制**:只在 guest 乾淨關機(服務全退出)時回寫,長駐 server 被 kill/timeout 不回寫;vdb 容量固定 `CHEFER_WHP_DATA_MIB`(預設 256MiB);tar 解壓不刪除 host 端已移除的檔)→ ✅ M4 virtio-net + host→guest 埠轉發(**實機達成**:net 裝置接上 run loop/MMIO 模擬器、smoltcp user-mode gateway、helper 綁 `[::1]:guest`、guest 靜態 IP `10.0.2.15`、guest-agent eth0→loopback TCP 橋接;本機 WHP 實測 redis bundle,host `redis PING` 經整條鏈得 `+PONG`)→ ✅ M5 appliance 自適應 init(偵測 /dev/hvc0 → vz virtiofs;否則 WHP `dd /dev/vda | tar -x`)+ kernel config + build → ✅ M6 端到端真 bundle(實機 `CHEFER_GUEST_EXIT=0`,`interface_mode: none` 服務)。**剩餘**:guest 主動對外 outbound NAT(🚧 分階段:✅ **M7-a 出網 UDP(實機達成)**——`whp-helper/src/virtio/nat.rs` 解析 guest IPv4/UDP frame + 合成回程 frame;net_backend NAT 引擎(per-flow host UDP socket)+ `drain_tx` 分流(外部 dst → NAT、其餘 → smoltcp)+ 回程注入 guest rx;本機 WHP 實測 `network: shared` 服務 `nslookup example.com 1.1.1.1` 經整條 NAT 鏈解析成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-b 出網 TCP(實機達成)**——drain_tx 對外部 dst 的 TCP 仍交 smoltcp,但 SYN 先 `nat_tcp_syn` 預註冊:把 dst IP 動態加進 iface `ip_addrs`(X/32;`.cargo/config.toml` 設 `SMOLTCP_IFACE_MAX_ADDR_COUNT=64` 提高容量 + idle dst LRU 驅逐)、建 smoltcp listen socket(dst,port)、**背景執行緒** connect host `TcpStream`(不阻塞 VM loop);`tcp_nat_pump` 完成連線後雙向橋接 smoltcp socket ↔ host stream;本機 WHP 實測 `network: shared` 服務 `wget http://example.com`(DNS UDP NAT 解析 + TCP NAT 連 104.20.x:80)成功(`CHEFER_GUEST_EXIT=0`)。✅ **M7-c bridge 模式出網(實機達成,預設模式全通)**——不需取代 pasta:與其他後端同一條 pasta 路徑(app netns → pasta tap → guest root netns socket → eth0 → helper NAT)。實機打通靠三個 guest 端修正:① appliance kernel 補 `CONFIG_TUN`(x86_64 defconfig 不含,pasta 無 tap 可建);② appliance init 補 `/dev/net/tun` 節點 0666(devtmpfs 對 misc 裝置預設 0600,pasta 以非特權 uid 開不了)並於開機早期 `switch_root` 到 tmpfs 根(initramfs 的 rootfs 上 `pivot_root` 一律 EINVAL,pasta 自我沙箱會直接退出——此問題 vz 同 appliance 也會中,一併修掉);③ guest-agent `start_pasta` 對 exec `EACCES` 補救(Windows host 打包無法記錄 unix 執行位 → bundle 內 pasta 是 0644;複製到 tmp 補 0755 重試)。本機 WHP 實測預設 `bridge` app:alpine 服務 `wget http://example.com`(DNS UDP + HTTP TCP 經 pasta→NAT 鏈)`CHEFER_GUEST_EXIT=0`;redis bundle 宣告埠 `+PONG`、未宣告埠拒絕。✅ **M7-d 容器內 DNS(10.0.2.3 pivot;實機達成)**——後查(實體 Mac vz 工作時發現):M7-b/c 當時容器內**沒有任何 resolv.conf 來源**(裸 alpine 不自帶、repo 內無注入程式碼;musl 缺檔會 fallback `127.0.0.1:53`,封包根本到不了 NAT——本機以 alpine 拔掉 resolv.conf 實證 `wget: bad address`),故當時的 wget 解析必是測試另行提供了 nameserver(未記錄入 repo);NAT 轉發機制的驗證仍有效,但「裸 image 開箱即可解析」**尚未成立**。正式來源已補上:kernel cmdline `ip=` dns0=`10.0.2.3` + helper DNS pivot(見 §「容器內 DNS」),host 端邏輯有跨平台單元測試;**本機 WHP 實機驗證通過**(2026-07-11,Windows 11 Pro 26200:裸 alpine:3.20、不含任何 resolv.conf 手段,預設 `bridge` 與 `shared` 皆容器內斷言 nslookup Server=`10.0.2.3` + `wget http://example.com`,`CHEFER_GUEST_EXIT=0`;trace `dns: upstreams` = host Wi-Fi DNS `192.168.1.1`(GetNetworkParams 探測正確),`CHEFER_WHP_DNS=1.1.1.1` 覆寫後 upstreams 跟隨;VPN/內網 hostname 情境未涵蓋——驗證機無 VPN,詳 whp-virtio-roadmap M7-d)。)。剩餘:GUI 顯示通道(virtio-gpu,另一條線)。 - **驗證邊界(誠實)**:同 vz——**GitHub runner 開不了 WHP**,transport/virtqueue 純邏輯進 CI 單元測試,真開 VM 的 e2e 只能在實機(Windows + 硬體虛擬化 + WHP 功能)手動跑。前置:`CHEFER_BACKEND=whp` 後端覆寫開關(已實作)讓有 WSL 的機器能選到 whp。 From 0673f07f407c0d0bb32332b3feb8c520eec7d6bf Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Tue, 28 Jul 2026 16:36:39 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(vmm-backend):=20Linux=20=E4=B8=8A=20hol?= =?UTF-8?q?d=5Fliveness=5Fhandle=20=E6=98=AF=20dead=5Fcode=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=8A=A0=20cfg=5Fattr=20=E8=B1=81=E5=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 -D warnings 擋下來的:只有 vz(macOS)與 whp(Windows)會登記 helper 的 liveness 寫端,Linux 的 namespaces 後端是 in-process 呼叫 guest-agent、沒有 helper 行程,該平台只有單元測試用得到這個函式。比照本檔既有的 wsl_util/vz_util 慣例豁免。 --- crates/vmm-backend/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/vmm-backend/src/lib.rs b/crates/vmm-backend/src/lib.rs index 0e8348c..1e046d2 100644 --- a/crates/vmm-backend/src/lib.rs +++ b/crates/vmm-backend/src/lib.rs @@ -125,6 +125,10 @@ pub fn whp_availability() -> Availability { static LIVENESS_HANDLES: Mutex> = Mutex::new(Vec::new()); /// 交出 helper stdin 寫端由本模組保管,直到行程結束或 [`close_liveness_handles`] 被呼叫。 +/// +/// 只有會 spawn helper 的 VM 後端(macOS `vz`、Windows `whp`)用得到;Linux 的 `namespaces` +/// 後端是 in-process 呼叫 guest-agent,沒有 helper 行程可登記——故該平台只有單元測試會用到。 +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] pub(crate) fn hold_liveness_handle(handle: ChildStdin) { lock_liveness().push(handle); } From fe473f9bee77486bf3c917cd9b7b5d95af842cf1 Mon Sep 17 00:00:00 2001 From: TimLai666 Date: Tue, 28 Jul 2026 16:41:56 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(agents):=20follow-up=E2=80=94=E2=80=94?= =?UTF-8?q?cargo=20test=20-p=20vmm-backend=20=E5=9C=A8=20Windows=20?= =?UTF-8?q?=E5=96=AE=E7=8D=A8=E8=B7=91=E7=B7=A8=E4=B8=8D=E9=81=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace 建置與 CI 都正常,只有指定 -p 時 windows-sys 的 feature 聯集不同, whp.rs 找不到 CreateJobObjectW。影響 AGENTS.md Commands 節寫的單 crate 測試用法。 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8f85a64..a37d4d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,4 +67,6 @@ Validation rules live in `crates/appcipe-spec/src/validate.rs`; it collects **al ## Follow-ups +- **`cargo test -p vmm-backend` 在 Windows 上單獨跑會編不過**:`error[E0432]: unresolved import windows_sys::Win32::System::JobObjects::CreateJobObjectW`(`crates/vmm-backend/src/whp.rs:19`)。`cargo build --workspace` / `cargo test --workspace` 都正常,CI 也綠——單獨指定 `-p` 時 windows-sys 的 feature 聯集不同(`Win32_System_JobObjects` 已在 `crates/vmm-backend/Cargo.toml` 宣告,但單包解析下這個符號仍不見),2026-07-26 於工作區乾淨、重跑仍穩定重現。影響的是本檔 Commands 節寫的「`cargo test -p ` 跑單一 crate」用法,不影響產品。查清楚 feature 到底少在哪再修(可能要補宣告,或是 windows-sys 0.61 的 API 分組變動)。 + - **WHP 的 guest console 沒有自動化端到端保護**:8250 THRE 中斷(userspace stdout 的唯一出路,見 DESIGN §6 whp ④)只由 `serial.rs` 的單元測試鎖住狀態機;「guest 服務的 stdout 真的會到 host」目前只能在實體 Windows(WHP)手動驗——QEMU E2E 走 virtio-console/hvc0,覆蓋不到這條路徑,GitHub 的 windows runner 也開不了巢狀虛擬化。若之後有自架 WHP runner,補一支 whp-smoke(對照 `scripts/vz-smoke.sh`)把「`[svc]` 前綴輸出出現在 host」變成斷言。