diff --git a/AGENTS.md b/AGENTS.md index ae15291..ee97dba 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 上 app 執行滿 300 秒就被 helper 砍掉**:`whp_util::helper_invocation` 預設 `timeout_secs = 300`,而 helper 的 vCPU 迴圈把它當成**整段執行**的上限(`crates/whp-helper/src/main.rs` 的 `start.elapsed() > timeout`),時間到就以 `Guest boot timed out after 300 seconds` 結束——錯誤訊息寫 boot,實際上連已經跑很久的常駐服務也一起砍。等於 Windows-without-WSL 的 app 五分鐘就會無故中止(`CHEFER_WHP_TIMEOUT` 可蓋過,但使用者不會知道要設)。2026-07-28 於實機發現(`scripts/whp-smoke.ps1` 的常駐服務檢查撞到,該腳本目前自行把上限拉到 900 秒繞開)。修法方向:逾時只該保護「開機到 guest 回報就緒(`CHEFER_GUEST_IP`/首次 guest-agent 輸出)」,之後就解除;順便把訊息改成講得出是哪個階段逾時。vz 後端沒有對應的上限,行為也不一致。 +(目前無。) diff --git a/crates/whp-helper/src/main.rs b/crates/whp-helper/src/main.rs index 8343c36..9862962 100644 --- a/crates/whp-helper/src/main.rs +++ b/crates/whp-helper/src/main.rs @@ -347,6 +347,8 @@ mod whp_api { // ── Console markers ── const GUEST_EXIT_MARKER: &str = "CHEFER_GUEST_EXIT="; + /// appliance init 在交棒給 guest-agent 前印出;用來解除開機看門狗。 + const GUEST_IP_MARKER: &str = "CHEFER_GUEST_IP="; // ── Function pointer types ── @@ -2544,12 +2546,17 @@ mod whp_api { let mut last_reason = EXIT_NONE; let mut last_rip = 0u64; let mut exit_trace_seq = 0u64; + // `timeout` 是**開機**看門狗,不是整段執行的上限:guest 一進 userspace 就解除, + // 之後常駐服務要跑多久都行(macOS vz 後端本來就沒有上限,這樣兩邊才一致)。 + // 解除條件見 guest_reached_userspace;設在下方 HALT 分支——那裡本來就已經把 + // console 輸出取出來解析標記,不必為此在熱迴圈裡多抓一次字串。 + let mut booted = false; loop { - if start.elapsed() > timeout { + if !booted && start.elapsed() > timeout { flush_serial(serial, last_printed); return Err(format!( - "Guest boot timed out after {} seconds (last exit={} 0x{last_reason:04X}, RIP=0x{last_rip:016X}, counts: io={} mem={} halt={} canceled={} other={})", + "Guest boot timed out after {} seconds — the VM never reached userspace (no CHEFER_GUEST_IP / guest-agent output). Raise it with CHEFER_WHP_TIMEOUT if this machine just boots slowly. (last exit={} 0x{last_reason:04X}, RIP=0x{last_rip:016X}, counts: io={} mem={} halt={} canceled={} other={})", timeout.as_secs(), exit_reason_name(last_reason), exit_stats.io, @@ -2623,6 +2630,9 @@ mod whp_api { } flush_serial(serial, last_printed); let output = serial.output_str(); + if !booted && guest_reached_userspace(&output) { + booted = true; + } if let Some(code) = parse_guest_exit(&output) { if code != 0 { return Err(format!("Guest exited with code {code}")); @@ -2928,6 +2938,16 @@ mod whp_api { } } + /// guest 是否已經走完開機、進到 userspace——`--timeout` 這個**開機**看門狗的解除條件。 + /// + /// 兩個標記任一出現即算數:`CHEFER_GUEST_IP=`(appliance init 交棒給 guest-agent 前印, + /// 走 /dev/kmsg,舊 appliance 也有)或 guest-agent 自己的 `[guest-agent]` 前綴輸出。 + /// 看門狗只保護「VM 開不起來/init 卡住」,**不是**整段執行的上限——曾經誤用成後者, + /// WHP 上的常駐 app 一律跑滿 300 秒就被砍(實機 2026-07-28 發現)。 + fn guest_reached_userspace(output: &str) -> bool { + output.contains(GUEST_IP_MARKER) || output.contains("[guest-agent]") + } + fn parse_guest_exit(output: &str) -> Option { for line in output.lines().rev() { if let Some(rest) = line.strip_prefix(GUEST_EXIT_MARKER) { @@ -2986,6 +3006,22 @@ mod whp_api { assert_eq!(std::mem::align_of::(), 16); } + /// 開機看門狗的解除條件。timeout 只該保護「開機到 guest 進 userspace」那一段—— + /// 曾經拿它當整段執行的上限,結果 WHP 上的 app 一律跑滿 300 秒就被砍,錯誤訊息還 + /// 寫 boot timed out(實機 2026-07-28 發現)。 + #[test] + fn guest_readiness_disarms_the_boot_watchdog() { + assert!(!guest_reached_userspace( + "[ 0.000000] Linux version 6.6.32\n[ 0.3] i8042: Can't read CTR" + )); + assert!(guest_reached_userspace( + "[ 0.7] CHEFER_GUEST_IP=10.0.2.15\n" + )); + assert!(guest_reached_userspace( + "boot log\n[guest-agent] rootfs via overlayfs\n" + )); + } + #[test] fn parse_guest_exit_code() { assert_eq!(parse_guest_exit("CHEFER_GUEST_EXIT=0"), Some(0)); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bf23d14..9556b9e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -263,7 +263,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result; // 取第一個 Avai 4. 注意命令注入:所有外部參數都走 argv 陣列(`std::process::Command` 個別 arg),絕不組 shell 字串。 5. **網路(實測)**:WSL2 的 localhost 轉送(wslrelay)只綁 IPv6 `[::1]`;runtime TCP 埠代理後端因此「先試 127.0.0.1、再退 [::1]」,且對 `host == guest` 的 TCP 埠在 Windows 上加 best-effort 的 IPv4 補橋(127.0.0.1:port → [::1]:port)。 6. **UDP 埠映射(已解決,實測)**:wslrelay 不轉 UDP,故 wsl2 後端在啟動 guest-agent 前先以 `wsl -d --exec /bin/guest-agent vmip` 取得 VM eth0 的 IPv4,對每個 UDP PortSpec(含 `host == guest`)於 host 端起 `127.0.0.1:host → :guest` 的 session relay;並以 `--udp-bridge` 旗標叫 guest-agent 在 VM 內補起 `:guest → 127.0.0.1:guest` 橋接(涵蓋服務只綁 loopback 的情形;服務綁 0.0.0.0 則直接命中、橋接以 EADDRINUSE 略過)。VM IP 每次啟動現查(NAT 模式下會變)。已知殘留:服務若綁 0.0.0.0 且 bind 時機晚於 VM 內橋接(橋接已設寬限期降低機率),可能與橋接搶埠——屬罕見且會以明確 bind 錯誤呈現、可重試。 - 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 自身持有)。 + 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 自身持有)。**`--timeout` 是開機看門狗,不是整段執行的上限**:guest 一進 userspace(console 出現 `CHEFER_GUEST_IP=`,或 guest-agent 的 `[guest-agent]` 輸出)就解除,之後常駐服務要跑多久都行——與完全沒有上限的 vz 後端一致。預設 300 秒,`CHEFER_WHP_TIMEOUT` 可調。曾經誤當成整段執行的上限,導致 WHP 上的 app 一律五分鐘就被砍、錯誤訊息還寫 boot timed out(2026-07-28 實機發現後修正);`scripts/whp-smoke.ps1` 用「20 秒看門狗 + 40 秒停留」當回歸守門。 **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,寫端交給 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 秒內**消失、無殘留行程(兩次重現一致)。 diff --git a/scripts/whp-smoke.ps1 b/scripts/whp-smoke.ps1 index 7283c10..75fd138 100644 --- a/scripts/whp-smoke.ps1 +++ b/scripts/whp-smoke.ps1 @@ -106,10 +106,10 @@ function Build-App { } $env:CHEFER_BACKEND = "whp" -# The helper caps the whole VM run, not just boot (default 300s, see -# whp_util::helper_invocation). The long-running check below needs more headroom -# than that -- see the "WHP 5-minute cap" follow-up in AGENTS.md. -$env:CHEFER_WHP_TIMEOUT = "900" +# Deliberately tiny: --timeout is a *boot* watchdog that disarms once the guest +# reaches userspace. If it ever regresses back into a whole-run cap, the dwell in +# check 2 below will outlive it and the script fails. +$env:CHEFER_WHP_TIMEOUT = "20" Note "2/4 check 1: exit-code propagation (fail_fast non-zero -> single file exits with the same code)" $exitExe = Build-App -Name "whp-exit" -Yaml @" @@ -213,7 +213,17 @@ try { Start-Sleep -Seconds 1 } if (-not $sawPort) { Fail "TCP port forwarding failed (127.0.0.1:18080 unreachable); log: $runLog" } - Note "check 2 passed: guest userspace stdout, service stays up, TCP forward" + + # Outlive the boot watchdog on purpose: CHEFER_WHP_TIMEOUT is 20s above, so a + # helper that still treats --timeout as a whole-run cap kills the VM here. + $dwell = 40 + Note "dwelling ${dwell}s to prove the boot watchdog disarmed (CHEFER_WHP_TIMEOUT=$env:CHEFER_WHP_TIMEOUT)" + Start-Sleep -Seconds $dwell + if ($app.HasExited) { + Get-Content $runLog -Tail 20 | Write-Host + Fail "the app died while idling past the boot watchdog; --timeout is capping the whole run again, not just boot" + } + Note "check 2 passed: guest userspace stdout, service stays up past the watchdog, TCP forward" Note "4/4 check 3: helper anti-orphan (hard-kill the runtime; only the Job Object can save us)" if (-not (Get-Process -Id $helperId -ErrorAction SilentlyContinue)) {