diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9b0035..806cf49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,23 @@ jobs: - name: Clippy (deny warnings) run: cargo clippy --workspace --all-targets -- -D warnings + # workspace 建置會把同一個 crate 的 feature 做聯集,因此某個 crate 少宣告的 + # feature 會被別人補上、直到有人單獨 `cargo build -p ` 才爆(實際發生過: + # vmm-backend 少了 windows-sys 的 Win32_Security,CreateJobObjectW 找不到)。 + # AGENTS.md 明寫單 crate 建置是支援用法,這裡逐包建一次把它鎖住。 + - name: Build each crate on its own (feature-unification guard) + shell: bash + run: | + # tr -d '\r':Windows runner 的 python stdout 是文字模式,會把 \n 寫成 \r\n, + # 不清掉的話每個名字尾巴都黏一個 CR(cargo 會抱怨 invalid character in package name)。 + for p in $(cargo metadata --no-deps --format-version 1 \ + | python3 -c "import json,sys; print('\n'.join(p['name'] for p in json.load(sys.stdin)['packages']))" \ + | tr -d '\r'); do + echo "::group::cargo build -p $p" + cargo build -p "$p" || { echo "::error::cargo build -p $p failed"; exit 1; } + echo "::endgroup::" + done + vz-helper: name: vz-helper (swiftc compile-check) runs-on: macos-latest diff --git a/AGENTS.md b/AGENTS.md index a37d4d9..ae15291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ cargo fmt cargo clippy ``` -Testing: `cargo test` needs neither Docker nor WSL — integration tests (`crates/chefer-cli/tests/cli_e2e.rs`, `crates/chefer-pack/tests/pack_tests.rs`) synthesize minimal docker-archive/OCI image tars in-test and run init/check/build/inspect against the real CLI binary. A *full* manual E2E (real image → single-file exe → actually running it) additionally requires Docker (`docker save` an image) and, on a Windows host, WSL2. The macOS appliance path is validated on Linux with `scripts/qemu-e2e.sh`; the actual VZ boot path cannot be exercised on GitHub-hosted macOS runners and must be verified on a physical Mac (`scripts/vz-smoke.sh`). +Testing: `cargo test` needs neither Docker nor WSL — integration tests (`crates/chefer-cli/tests/cli_e2e.rs`, `crates/chefer-pack/tests/pack_tests.rs`) synthesize minimal docker-archive/OCI image tars in-test and run init/check/build/inspect against the real CLI binary. A *full* manual E2E (real image → single-file exe → actually running it) additionally requires Docker (`docker save` an image) and, on a Windows host, WSL2. The macOS appliance path is validated on Linux with `scripts/qemu-e2e.sh`; the actual VZ boot path cannot be exercised on GitHub-hosted macOS runners and must be verified on a physical Mac (`scripts/vz-smoke.sh`). The **whp** backend is in the same position for the opposite reason — GitHub's windows runners have no nested virtualization, and the QEMU E2E uses virtio-console (hvc0) so it never exercises WHP's 8250 serial path — so run `scripts/whp-smoke.ps1` on a physical Windows machine with the Windows Hypervisor Platform enabled; it asserts exit-code propagation, **guest userspace stdout actually reaching the host**, TCP forwarding, and helper anti-orphan. CI ([.github/workflows/ci.yml](.github/workflows/ci.yml)) builds and tests on Linux/Windows/macOS and verifies the musl guest-agent links statically. Linux E2E ([.github/workflows/e2e-linux.yml](.github/workflows/e2e-linux.yml)) covers native namespaces, the QEMU appliance path, and a boot smoke of the *cross-built* aarch64 appliance (`scripts/appliance-boot-smoke.sh` on an x86_64 runner — the same cross-build path release.yml uses, which the native-arm64 E2E job can't cover). Releases ([.github/workflows/release.yml](.github/workflows/release.yml)) build chefer-cli + chefer-runtime for 6 targets, guest-agent for both musl arches, and the Linux appliance for both guest arches — boot-smoking each appliance before upload — then attach one complete kit per host platform (CLI + all runtimes + both agents + appliance) to the GitHub Release. @@ -67,6 +67,4 @@ 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」變成斷言。 +- **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/vmm-backend/Cargo.toml b/crates/vmm-backend/Cargo.toml index b654100..25db4ef 100644 --- a/crates/vmm-backend/Cargo.toml +++ b/crates/vmm-backend/Cargo.toml @@ -18,6 +18,10 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Threading", # WHP helper 防孤兒:KILL_ON_JOB_CLOSE Job Object(whp.rs,DESIGN §6「Helper 生命週期」)。 "Win32_System_JobObjects", + # CreateJobObjectW 的第一個參數是 SECURITY_ATTRIBUTES,windows-sys 因此把它額外 + # gate 在 Win32_Security 上。少了這條,`cargo build -p vmm-backend` 單獨建會 + # E0432;workspace 建置時被其他 crate 的 feature 聯集蓋過去而看不出來。 + "Win32_Security", ] } [dev-dependencies] diff --git a/crates/whp-helper/src/main.rs b/crates/whp-helper/src/main.rs index 9436334..8343c36 100644 --- a/crates/whp-helper/src/main.rs +++ b/crates/whp-helper/src/main.rs @@ -2661,7 +2661,18 @@ mod whp_api { } } if rflags & 0x200 != 0 { - pic1.request_irq(0); + // COM1 的 THRE 是準位訊號:guest 在 HLT 等 TX 續傳中斷時不會有 + // 任何 IO exit,只在這裡重新拉線才不會卡死(見 serial.rs)。 + if serial.irq_pending() { + pic1.request_irq(super::serial::COM1_IRQ); + } + // timer tick 只在沒有別的裝置 IRQ 在等時才補。deliver 每次只送 + // 一條、由小到大挑,無條件每輪補 IRQ 0 會讓 IRQ 4 永遠輪不到 + // ——實機實測就是這樣把序列埠餓死:guest 送完服務輸出後停在 + // HLT 等 THRE,timer 一直贏,CHEFER_GUEST_EXIT 永遠印不出來。 + if pic1.pending_unmasked() == 0 { + pic1.request_irq(0); + } let _ = deliver_pending_pic_irq(api, partition, pic1); } } diff --git a/crates/whp-helper/src/pic.rs b/crates/whp-helper/src/pic.rs index 60f09a8..53a2d53 100644 --- a/crates/whp-helper/src/pic.rs +++ b/crates/whp-helper/src/pic.rs @@ -105,6 +105,15 @@ impl Pic { } } + /// 目前「已提出且未被遮罩」的 IRQ 線(bit0..bit7)。 + /// + /// 呼叫端用它決定要不要再補一條 timer tick:[`take_pending_vector`] 每次只送一條、 + /// 且由小到大挑,所以無條件每輪都補 IRQ 0 會讓編號較大的裝置永遠輪不到(實機實測 + /// 會餓死 COM1 的 IRQ 4,見 main.rs 的 HLT 分支)。 + pub fn pending_unmasked(&self) -> u8 { + self.irr & !self.imr + } + /// 讓外部裝置提出一條 PIC IRQ 線。 pub fn request_irq(&mut self, irq: u8) { if irq < 8 { @@ -269,6 +278,39 @@ pub fn pit_handles(port: u16) -> bool { mod tests { use super::*; + #[test] + fn pending_unmasked_reports_requested_and_unmasked_lines() { + let mut pic = Pic::new(); + pic.write_data(0x00); // 解除全部遮罩 + pic.request_irq(4); + assert_eq!(pic.pending_unmasked(), 1 << 4); + pic.write_data(1 << 4); // 遮罩 IRQ 4 + assert_eq!(pic.pending_unmasked(), 0); + } + + /// 回歸測試(實機 2026-07-28 卡死):take_pending_vector 由小到大挑、每次只送一條, + /// 所以只要還有裝置 IRQ 在等,呼叫端就**不能**再補一條 timer IRQ 0——否則編號較大的 + /// 線永遠輪不到。實機症狀是 guest 送完服務輸出後停在 HLT 等 COM1 的 THRE,timer 每輪 + /// 都贏,CHEFER_GUEST_EXIT 永遠印不出來、helper 逾時。 + #[test] + fn a_timer_tick_added_every_round_would_starve_higher_numbered_irqs() { + let mut pic = Pic::new(); + pic.write_data(0x00); + pic.request_irq(4); + assert_ne!( + pic.pending_unmasked(), + 0, + "呼叫端要據此判斷「還有東西在等,先別補 timer」" + ); + + pic.request_irq(0); // 若呼叫端照舊無條件補 timer tick…… + assert_eq!( + pic.take_pending_vector(), + Some(0), + "……IRQ 0 一定先出線,IRQ 4 被壓在後面" + ); + } + #[test] fn pic_init_sequence() { let mut pic = Pic::new(); diff --git a/crates/whp-helper/src/serial.rs b/crates/whp-helper/src/serial.rs index 3c45374..92b1d7d 100644 --- a/crates/whp-helper/src/serial.rs +++ b/crates/whp-helper/src/serial.rs @@ -28,9 +28,6 @@ pub struct SerialPort { scr: u8, dll: u8, dlh: u8, - /// THR 已空、尚未被 guest 讀 IIR 認掉的中斷。本模擬的 TX 即時完成,所以只要 - /// guest 開了 THRI 或剛送完一個 byte,THR 就是空的。 - thre_pending: bool, output: Vec, } @@ -43,21 +40,29 @@ impl SerialPort { scr: 0, dll: 0, dlh: 0, - thre_pending: false, output: Vec::new(), } } - /// 現在是否該對 PIC 拉 COM1 的 IRQ(guest 開了 THRI 且有未認的 THR-empty)。 + /// 現在是否該對 PIC 拉 COM1 的 IRQ。 + /// + /// THRE 是**準位**訊號不是邊緣:只要 guest 開著 THRI,而 THR 是空的,線就一直拉著。 + /// 本模擬的 TX 即時完成 → THR 恆空 → 條件等同「THRI 開著」。實機教訓:曾經把它做成 + /// 「讀 IIR 就清掉」的一次性旗標,結果 Linux 只要讀到一次 IIR 卻沒接著寫 THR/IER + /// (例如那次中斷被判定為 spurious),這條線就再也拉不起來——driver 的 `ier` 快取裡 + /// THRI 仍是開的,於是 `serial8250_start_tx` 不會重寫 IER、也就永遠沒有新的 IO exit + /// 可以觸發中斷,guest 卡在等一個不會來的 THRE 上(實機 2026-07-28:VM 開完機、服務 + /// 跑完,卻停在 `CHEFER_GUEST_EXIT` 之前不動,helper 逾時)。Linux 沒東西要送時會自己 + /// 清掉 THRI(`__stop_tx`),所以持續拉線不會變成中斷風暴——真硬體也是這個行為。 pub fn irq_pending(&self) -> bool { - self.ier & IER_THRI != 0 && self.thre_pending + self.ier & IER_THRI != 0 } pub fn handles(port: u16) -> bool { (COM1_BASE..=COM1_END).contains(&port) } - pub fn read(&mut self, port: u16) -> u8 { + pub fn read(&self, port: u16) -> u8 { match port - COM1_BASE { 0 => { if self.dlab() { @@ -74,9 +79,8 @@ impl SerialPort { } } 2 => { - // 讀 IIR = guest 認掉這次中斷(真硬體同語意)。 + // IIR 照實回報目前的準位,讀取不改變狀態(見 irq_pending 的說明)。 if self.irq_pending() { - self.thre_pending = false; IIR_THR_EMPTY } else { IIR_NO_PENDING @@ -96,12 +100,8 @@ impl SerialPort { 0 => { if self.dlab() { self.dll = value; - } else { - if self.output.len() < MAX_SERIAL_OUTPUT { - self.output.push(value); - } - // TX 即時完成 → THR 立刻又是空的,該通知 guest 續傳下一個 byte。 - self.thre_pending = true; + } else if self.output.len() < MAX_SERIAL_OUTPUT { + self.output.push(value); } } 1 => { @@ -109,10 +109,6 @@ impl SerialPort { self.dlh = value; } else { self.ier = value; - if value & IER_THRI != 0 { - // guest 剛開啟 TX 中斷,而 THR 本來就是空的——立刻給它第一次踢。 - self.thre_pending = true; - } } } 2 => {} @@ -158,13 +154,13 @@ mod tests { #[test] fn lsr_reports_tx_ready() { - let mut sp = SerialPort::new(); + let sp = SerialPort::new(); assert_eq!(sp.read(COM1_BASE + 5), 0x60); } #[test] fn iir_no_pending_interrupt() { - let mut sp = SerialPort::new(); + let sp = SerialPort::new(); assert_eq!(sp.read(COM1_BASE + 2), 0x01); } @@ -179,22 +175,19 @@ mod tests { 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); } + /// 回歸測試(實機 2026-07-28 卡死):THRE 是準位不是邊緣。讀 IIR **不會**把線清掉, + /// 否則 Linux 讀到一次 IIR 卻沒接著寫 THR/IER 時,driver 的 ier 快取裡 THRI 仍開著、 + /// 不會重寫 IER,也就再也沒有 IO exit 能重新拉線——guest 永遠等不到下一次 THRE。 #[test] - fn transmitting_re_arms_thre() { + fn reading_iir_does_not_drop_the_line() { 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); + for _ in 0..3 { + assert_eq!(sp.read(COM1_BASE + 2), 0x02); + assert!(sp.irq_pending(), "THRI 還開著,這條線就不該掉"); + } } #[test] diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7a3fcc8..bf23d14 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。**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` 的單元測試鎖住狀態機、端到端只能實機驗。 + - **裝置清單**:① **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] …` 皆正常出現,埠轉發不受影響)。**THRE 是準位不是邊緣**:只要 guest 開著 THRI、THR 是空的(本模擬 TX 即時完成 → 恆空),線就一直拉著;讀 IIR **不**清掉它。做成「讀 IIR 就清」的一次性旗標會死在這裡:Linux 讀到一次 IIR 卻沒接著寫 THR/IER 時,driver 的 `ier` 快取仍認為 THRI 開著、不會重寫 IER,於是再也沒有 IO exit 能重新拉線。**還要注意 PIC 餓死**:`take_pending_vector` 每次只送一條、由小到大挑,所以 HLT 分支不能無條件每輪補 timer IRQ 0——那樣 IRQ 0 永遠壓過 IRQ 4(實機 trace:`irr=0x11 isr=0x00 imr=0x0A`,序列埠一直排在後面),guest 送完輸出後停在 HLT 等一個永遠不來的 THRE,連 `CHEFER_GUEST_EXIT` 都印不出來。現行策略:HLT 時先補序列埠的線,只有 `pic1.pending_unmasked() == 0` 才補 timer tick。QEMU E2E 覆蓋不到這條(它走 virtio-console/hvc0),故以 `serial.rs` / `pic.rs` 的單元測試鎖住狀態機與餓死條件,端到端由實機的 `scripts/whp-smoke.ps1` 驗(斷言 guest 服務的 `[svc]` 前綴輸出出現在 host)。 - **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。 diff --git a/scripts/whp-smoke.ps1 b/scripts/whp-smoke.ps1 new file mode 100644 index 0000000..7283c10 --- /dev/null +++ b/scripts/whp-smoke.ps1 @@ -0,0 +1,246 @@ +#requires -version 5.1 +<# +.SYNOPSIS +One-shot validation of the chefer `whp` backend on a real Windows machine. + +.DESCRIPTION +Runs every whp check that is gated on real hardware: VM boot, exit-code +propagation, guest userspace stdout actually reaching the host, TCP port +forwarding, and helper anti-orphan behavior. + +Why this script exists (do not delete): GitHub's windows runners cannot do +nested virtualization, so CI can only compile-check whp-helper. The appliance +QEMU E2E uses virtio-console (hvc0) and therefore never exercises the WHP 8250 +serial path. That path once shipped without a THRE interrupt, which silently +dropped every byte of guest userspace stdout and looked exactly like "the +services never started" (see DESIGN section 6, whp, item 4). Check 2 below is +the regression guard for that fix. + +.PARAMETER KitDir +Kit directory holding the appliance (chefer-vmlinuz-, +chefer-initramfs-) and guest-agent-. Defaults to +$env:CHEFER_KIT_DIR, then /kit. + +.PARAMETER Keep +Keep the work directory (dist/whp-smoke) for inspection. + +.EXAMPLE +powershell -ExecutionPolicy Bypass -File scripts/whp-smoke.ps1 +#> +[CmdletBinding()] +param( + [string]$KitDir = "", + [switch]$Keep +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Fail { + param([string]$Message) + Write-Error "whp-smoke: $Message" + exit 1 +} + +function Note { + param([string]$Message) + Write-Host "==> $Message" +} + +if ($env:OS -ne "Windows_NT") { Fail "this script only runs on Windows (whp = Windows Hypervisor Platform)" } +if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Fail "cargo not found; install Rust (https://rustup.rs)" } +if (-not (Test-Path "$env:SystemRoot\System32\WinHvPlatform.dll")) { + Fail "WinHvPlatform.dll not found; enable 'Windows Hypervisor Platform' in Windows Features and reboot" +} + +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { $arch = "x86_64" } + "ARM64" { $arch = "aarch64" } + default { Fail "unsupported architecture: $env:PROCESSOR_ARCHITECTURE" } +} +$triple = "$arch-pc-windows-msvc" +$platform = if ($arch -eq "aarch64") { "linux/arm64" } else { "linux/amd64" } + +if (-not $KitDir) { + $KitDir = if ($env:CHEFER_KIT_DIR) { $env:CHEFER_KIT_DIR } else { Join-Path $root "kit" } +} +foreach ($f in @("chefer-vmlinuz-$arch", "chefer-initramfs-$arch", "guest-agent-$arch")) { + if (-not (Test-Path (Join-Path $KitDir $f))) { + Fail "kit is missing $f (looked in $KitDir); use a release kit/ or run scripts/build-appliance.sh" + } +} + +$work = Join-Path $root "dist\whp-smoke" +if (Test-Path $work) { Remove-Item $work -Recurse -Force } +New-Item -ItemType Directory -Path $work | Out-Null + +Note "1/4 building chefer-cli + chefer-runtime + chefer-whp-helper (release)" +& cargo build --release -p chefer-cli -p chefer-runtime -p whp-helper --manifest-path (Join-Path $root "Cargo.toml") +if ($LASTEXITCODE -ne 0) { Fail "cargo build failed" } + +# Smoke kit: appliance / guest-agent / pasta come from the supplied kit, but the +# runtime and helper are always the ones just built from source -- never validate +# a fresh fix against a stale binary. +$smokeKit = Join-Path $work "kit" +New-Item -ItemType Directory -Path $smokeKit | Out-Null +foreach ($f in @("chefer-vmlinuz-$arch", "chefer-initramfs-$arch", "guest-agent-$arch", "pasta-$arch")) { + $src = Join-Path $KitDir $f + if (Test-Path $src) { Copy-Item $src $smokeKit } +} +Copy-Item (Join-Path $root "target\release\chefer-runtime.exe") (Join-Path $smokeKit "chefer-runtime-$triple.exe") +Copy-Item (Join-Path $root "target\release\chefer-whp-helper.exe") (Join-Path $smokeKit "chefer-whp-helper-$arch.exe") +$cli = Join-Path $root "target\release\chefer-cli.exe" + +function Build-App { + param([string]$Name, [string]$Yaml) + $ymlPath = Join-Path $work "$Name.yml" + # Windows PowerShell 5.1's `-Encoding utf8` writes a BOM, which the YAML parser + # rejects ("missing field `name` at line 1 column 2"). Write UTF-8 without one. + [System.IO.File]::WriteAllText($ymlPath, $Yaml, (New-Object System.Text.UTF8Encoding($false))) + & $cli build $ymlPath --out (Join-Path $work "dist") --kit-dir $smokeKit | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "chefer build failed for $Name" } + $exe = Join-Path $work "dist\$Name\${Name}_$triple.exe" + if (-not (Test-Path $exe)) { Fail "build did not produce $exe" } + return $exe +} + +$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" + +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 @" +version: "0.1" +name: whp-exit +network: internal +services: + one: + image: + source: image + file: alpine:3.20 + platform: $platform + interface_mode: none + cmd: ["sh", "-c", "echo WHP_SMOKE_ONESHOT; exit 7"] +"@ +$exitLog = Join-Path $work "exit.log" +$oneShot = Start-Process -FilePath $exitExe -ArgumentList "--extract-dir", (Join-Path $work "extract-exit") ` + -RedirectStandardOutput $exitLog -RedirectStandardError (Join-Path $work "exit.err.log") ` + -NoNewWindow -PassThru -Wait +if (-not (Select-String -Path $exitLog -Pattern "CHEFER_GUEST_EXIT=" -Quiet)) { + Get-Content $exitLog -Tail 40 | Write-Host + Fail "no CHEFER_GUEST_EXIT marker on the console; log: $exitLog" +} +if ($oneShot.ExitCode -ne 7) { + Fail "exit-code propagation failed: expected 7, got $($oneShot.ExitCode); log: $exitLog" +} +Note "check 1 passed: VM boots, CHEFER_GUEST_EXIT marker present, exit code 7 propagated" + +Note "3/4 check 2: guest userspace stdout reaches the host (8250 THRE regression guard) + service stays up + TCP forward" +$smokeExe = Build-App -Name "whp-smoke" -Yaml @" +version: "0.1" +name: whp-smoke +services: + web: + image: + source: image + file: alpine:3.20 + platform: $platform + interface_mode: none + ports: ["18080:8080"] + # The alpine base has no httpd (it lives in busybox-extras); busybox nc is + # enough for a minimal HTTP responder. + cmd: ["sh", "-c", "echo WHP_SMOKE_SERVICE_UP; while true; do printf 'HTTP/1.0 200 OK\r\n\r\nok\n' | nc -l -p 8080; done"] +"@ +$runLog = Join-Path $work "run.log" +$app = Start-Process -FilePath $smokeExe -ArgumentList "--extract-dir", (Join-Path $work "extract") ` + -RedirectStandardOutput $runLog -RedirectStandardError (Join-Path $work "run.err.log") ` + -NoNewWindow -PassThru + +try { + # guest-agent prefixes service stdout with the service name. Seeing "[web] ..." + # on the host proves guest userspace output crossed the 8250 tty path -- kernel + # printk uses a polled write and would show up even with the interrupt missing. + $sawStdout = $false + for ($i = 0; $i -lt 180; $i++) { + if ((Test-Path $runLog) -and (Select-String -Path $runLog -Pattern "\[web\] WHP_SMOKE_SERVICE_UP" -Quiet)) { + $sawStdout = $true + break + } + if ($app.HasExited) { break } + Start-Sleep -Seconds 1 + } + if (-not $sawStdout) { + Get-Content $runLog -Tail 40 | Write-Host + Fail "no guest service stdout within 180s. A console that stops after the kernel/init messages usually means the 8250 THRE interrupt regressed (DESIGN section 6, whp, item 4); console tail above" + } + + # Grab the helper PID while the VM is demonstrably up; check 3 needs it later and + # a lookup done only at kill time has proven flaky. + $helperId = $null + for ($i = 0; $i -lt 20; $i++) { + $helper = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -like "chefer-whp-helper*" } | Select-Object -First 1 + if ($helper) { $helperId = [int]$helper.Id; break } + Start-Sleep -Milliseconds 500 + } + if (-not $helperId) { Fail "no chefer-whp-helper process while the guest is running; cannot validate anti-orphan" } + + # Raw TCP rather than Invoke-WebRequest: busybox nc serves one connection and + # waits for the peer to hang up, while Invoke-WebRequest asks for keep-alive and + # waits for the server -- they deadlock until the timeout, once per attempt. + $sawPort = $false + for ($i = 0; $i -lt 60; $i++) { + try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect("127.0.0.1", 18080) + $stream = $client.GetStream() + $stream.ReadTimeout = 3000 + $req = [System.Text.Encoding]::ASCII.GetBytes("GET / HTTP/1.0`r`n`r`n") + $stream.Write($req, 0, $req.Length) + $buf = New-Object byte[] 256 + $read = $stream.Read($buf, 0, $buf.Length) + $client.Close() + if ($read -gt 0 -and [System.Text.Encoding]::ASCII.GetString($buf, 0, $read) -match "200 OK") { + $sawPort = $true + break + } + } catch { + if ($client) { $client.Close() } + } + 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" + + 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)) { + Fail "the helper (pid $helperId) disappeared before the anti-orphan check; the VM died early" + } + # taskkill /F is TerminateProcess: no handler runs and the stdin EOF has not + # happened yet, so survival depends purely on KILL_ON_JOB_CLOSE. + & taskkill /F /PID $app.Id | Out-Null + $gone = $false + for ($i = 0; $i -lt 20; $i++) { + Start-Sleep -Milliseconds 500 + if (-not (Get-Process -Id $helperId -ErrorAction SilentlyContinue)) { + $gone = $true + break + } + } + if (-not $gone) { + Stop-Process -Id $helperId -Force -ErrorAction SilentlyContinue + Fail "helper was orphaned: runtime is dead but chefer-whp-helper is still alive after 10s (Job Object anti-orphan broken)" + } + Note "check 3 passed: no helper left behind after the runtime was hard-killed" +} finally { + if (-not $app.HasExited) { Stop-Process -Id $app.Id -Force -ErrorAction SilentlyContinue } + Get-Process "chefer-whp-helper-$arch" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + Remove-Item Env:\CHEFER_BACKEND -ErrorAction SilentlyContinue + Remove-Item Env:\CHEFER_WHP_TIMEOUT -ErrorAction SilentlyContinue + if (-not $Keep) { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue } +} + +Note "all checks passed."