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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <crate>` 才爆(實際發生過:
# 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
Expand Down
6 changes: 2 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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>` 跑單一 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 後端沒有對應的上限,行為也不一致。
4 changes: 4 additions & 0 deletions crates/vmm-backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 12 additions & 1 deletion crates/whp-helper/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
42 changes: 42 additions & 0 deletions crates/whp-helper/src/pic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
57 changes: 25 additions & 32 deletions crates/whp-helper/src/serial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
}

Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -96,23 +100,15 @@ 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 => {
if self.dlab() {
self.dlh = value;
} else {
self.ier = value;
if value & IER_THRI != 0 {
// guest 剛開啟 TX 中斷,而 THR 本來就是空的——立刻給它第一次踢。
self.thre_pending = true;
}
}
}
2 => {}
Expand Down Expand Up @@ -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);
}

Expand All @@ -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]
Expand Down
Loading