Skip to content
Merged
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ 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 <runtime 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 後綴的。修好後刪除本項。
- **`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」變成斷言。
5 changes: 5 additions & 0 deletions crates/chefer-runtime/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,16 @@ pub fn run(bundle_dir: &Path, keep_tmp: bool) -> Result<i32> {

// Ctrl-C:印訊息後等 run_app 自然返回(後端子行程共享 console,會收到
// 同號訊號自行結束);若 5 秒內未返回則以 130 強制退出。
//
// 對 runtime **單發**訊號(`kill -INT <pid>`、非終端機整個 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;
Expand Down
60 changes: 60 additions & 0 deletions crates/vmm-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ mod vz_util;
#[allow(dead_code)]
mod whp_util;

use std::process::ChildStdin;
use std::sync::Mutex;

use anyhow::Result;

/// 後端可用性檢查結果。
Expand Down Expand Up @@ -114,6 +117,36 @@ 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<Vec<ChildStdin>> = 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);
}

/// 中斷訊號路徑呼叫:立刻關掉所有 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<ChildStdin>> {
LIVENESS_HANDLES.lock().unwrap_or_else(|e| e.into_inner())
}

/// 取第一個可用的後端執行 app;全部不可用時彙整每個後端的名稱與原因報錯。
///
/// `CHEFER_BACKEND` 環境變數可強制只用某個後端(例如 `CHEFER_BACKEND=whp`):用於在
Expand Down Expand Up @@ -279,6 +312,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() {
Expand Down
5 changes: 3 additions & 2 deletions crates/vmm-backend/src/vz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions crates/vmm-backend/src/whp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions crates/whp-helper/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
101 changes: 95 additions & 6 deletions crates/whp-helper/src/serial.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,35 @@
//!
//! 僅實作 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,
mcr: u8,
scr: u8,
dll: u8,
dlh: u8,
/// THR 已空、尚未被 guest 讀 IIR 認掉的中斷。本模擬的 TX 即時完成,所以只要
/// guest 開了 THRI 或剛送完一個 byte,THR 就是空的。
thre_pending: bool,
output: Vec<u8>,
}

Expand All @@ -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() {
Expand All @@ -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)
Expand All @@ -65,15 +96,23 @@ 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 => {
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 @@ -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);
}

Expand Down
Loading