Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 後端沒有對應的上限,行為也不一致。
(目前無。)
40 changes: 38 additions & 2 deletions crates/whp-helper/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}"));
Expand Down Expand Up @@ -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<i32> {
for line in output.lines().rev() {
if let Some(rest) = line.strip_prefix(GUEST_EXIT_MARKER) {
Expand Down Expand Up @@ -2986,6 +3006,22 @@ mod whp_api {
assert_eq!(std::mem::align_of::<WhvRegisterValue>(), 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));
Expand Down
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result<i32>; // 取第一個 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 <distro> --exec /bin/guest-agent vmip` 取得 VM eth0 的 IPv4,對每個 UDP PortSpec(含 `host == guest`)於 host 端起 `127.0.0.1:host → <vm_ip>:guest` 的 session relay;並以 `--udp-bridge` 旗標叫 guest-agent 在 VM 內補起 `<vm_ip>: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-<arch>`、`vm/chefer-initramfs-<arch>`、`agents/chefer-whp-helper-<arch>.exe` 是否存在——三者皆備且 hypervisor 可用時回傳 `Available`,由 runtime 的 `run()` spawn helper 執行完整開機,stdout 逐行解析 `CHEFER_GUEST_IP=<ipv4>` / `CHEFER_GUEST_EXIT=<code>` 標記。helper CLI contract:`chefer-whp-helper --kernel <p> --initramfs <p> --cmdline <s> --bundle-dir <p> --data-dir <p> --cpus <n> --memory-mib <n> [--timeout <secs>] [--forward-tcp <host:guest>]... [--forward-udp <host:guest>]...`(`--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-<arch>`、`vm/chefer-initramfs-<arch>`、`agents/chefer-whp-helper-<arch>.exe` 是否存在——三者皆備且 hypervisor 可用時回傳 `Available`,由 runtime 的 `run()` spawn helper 執行完整開機,stdout 逐行解析 `CHEFER_GUEST_IP=<ipv4>` / `CHEFER_GUEST_EXIT=<code>` 標記。helper CLI contract:`chefer-whp-helper --kernel <p> --initramfs <p> --cmdline <s> --bundle-dir <p> --data-dir <p> --cpus <n> --memory-mib <n> [--timeout <secs>] [--forward-tcp <host:guest>]... [--forward-udp <host:guest>]...`(`--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=<code>` 寫入 **`/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 <runtime>`,非 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 秒內**消失、無殘留行程(兩次重現一致)。
Expand Down
20 changes: 15 additions & 5 deletions scripts/whp-smoke.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 @"
Expand Down Expand Up @@ -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)) {
Expand Down