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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
@CLAUDE.md

## 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 寫端交給訊號處理路徑、收訊號時主動關閉。屬優化,非正確性問題。
- **Windows whp-helper 疑有同款孤兒化問題**:`crates/vmm-backend/src/whp.rs` spawn `chefer-whp-helper` 時無 Job Object、無 stdin liveness——對 runtime 單殺(`taskkill /PID`,非 console Ctrl+C)時 helper/VM 推測會殘留(程式碼層面確認無任何防護;未在實機重現)。建議用 Job Object(`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`)或比照 vz 的 stdin-EOF 做法。
26 changes: 26 additions & 0 deletions crates/vmm-backend/src/vz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,38 @@ impl ExecBackend for VzBackend {
);
let mut child = Command::new(&helper)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.with_context(|| {
format!("failed to spawn the macOS VM helper: {}", helper.display())
})?;

// helper 的 stdin 同時是 guest console(hvc0)輸入與 **liveness 通道**:helper 讀到
// stdin EOF 即自我了結(見 vz-helper/main.swift)。寫端由本行程握住——runtime 無論
// 怎麼死(單發 SIGINT/SIGTERM、SIGKILL、panic),OS 都會關掉這個 fd,helper/VM 就
// 不會殘留。終端 Ctrl+C 靠同 process group 收訊號沒這問題;實機發現單發訊號給
// runtime 時 helper 收不到,VM 會孤兒化繼續吃 CPU。
let helper_stdin = child
.stdin
.take()
.expect("child stdin was requested as piped");
if vz_util::needs_console_stdin_pump(ctx.manifest) {
// 有 terminal/both 服務:把 runtime 自己的 stdin 泵給 helper(terminal 服務
// stdin 直通)。runtime stdin 先到 EOF(如 < /dev/null)不能關掉 helper 端
// ——那等於殺掉整個 app——改為 mem::forget 讓寫端 fd 隨行程存亡。
std::thread::spawn(move || {
let mut src = std::io::stdin();
let mut dst = helper_stdin;
let _ = std::io::copy(&mut src, &mut dst);
std::mem::forget(dst);
});
} else {
// 無 terminal 服務:不讀使用者終端的 stdin(避免把輸入吞進 guest console),
// 只讓寫端 fd 隨行程存亡。
std::mem::forget(helper_stdin);
}

let stdout = child
.stdout
.take()
Expand Down
35 changes: 35 additions & 0 deletions crates/vmm-backend/src/vz_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ pub fn parse_guest_ip(console: &str) -> Option<Ipv4Addr> {
})
}

/// manifest 是否需要把 runtime 自己的 stdin 泵給 helper(helper 的 stdin = guest console
/// hvc0 輸入 + liveness 通道,見 vz.rs)。只有 terminal/both 服務存在時才泵——
/// 其他 app 不該讀使用者的終端 stdin(會把輸入吞進 guest console);此時 runtime
/// 只握住 helper stdin 寫端不寫,供 helper 以 EOF 偵測 runtime 行程死亡。
pub fn needs_console_stdin_pump(manifest: &chefer_bundle::Manifest) -> bool {
manifest
.services
.iter()
.any(|s| s.interface_mode.wants_terminal())
}

/// 一條 host→guest 埠轉發(VM host 端把 `127.0.0.1:host` relay 到 `<guest_ip>:guest`)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PortForward {
Expand Down Expand Up @@ -378,6 +389,30 @@ mod tests {
);
}

#[test]
fn stdin_pump_only_for_terminal_services() {
let manifest = |mode: &str| -> chefer_bundle::Manifest {
serde_json::from_str(&format!(
r#"{{
"format_version": 1,
"app": {{"name": "t", "spec_version": "0.1", "generated_at_utc": ""}},
"services": [
{{"name": "a", "platform": "linux/amd64", "layers": [], "image_config": {{}},
"interface_mode": "{mode}"}},
{{"name": "b", "platform": "linux/amd64", "layers": [], "image_config": {{}}}}
]
}}"#
))
.unwrap()
};
// terminal / both → 需要泵 stdin(terminal 服務 stdin 直通,DESIGN §7 stdio 模型)
assert!(needs_console_stdin_pump(&manifest("terminal")));
assert!(needs_console_stdin_pump(&manifest("both")));
// gui / none → 不讀使用者終端 stdin,只握住寫端當 liveness
assert!(!needs_console_stdin_pump(&manifest("gui")));
assert!(!needs_console_stdin_pump(&manifest("none")));
}

#[test]
fn tcp_forward_relays_bidirectionally() {
use std::io::{Read, Write};
Expand Down
3 changes: 2 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result<i32>; // 取第一個 Avai
- arch 對應同 `layout::platform_to_arch`(x86_64 / aarch64);Apple Silicon 上以 arm64 guest 為主。
- **開機機制 — 內附 Swift helper(`chefer-vz-helper-<arch>`)**:實際驅動 Virtualization.framework 的是一支以 **Swift(Apple 第一方 VZ API)** 寫的小 helper,與 guest-agent/pasta/appliance 同屬 kit 內附的協力執行檔,打包 macOS 目標時嵌進 bundle(`agents/chefer-vz-helper-<arch>`,host macho、host 架構)。選 Swift 而非 in-process objc2 綁定:VZ 的 Swift API 文件完整、正確性高、可獨立編譯/簽章;Rust 後端只負責 spawn helper、串接其 stdout、解析 console 標記、做 host 端埠轉發——這條 Rust 路徑在任何平台都可單元測試。helper 與 chefer-runtime 以 **CLI 介面 + stdout 標記**鬆耦合(見下)。
- **VM 組態(helper 內,`VZVirtualMachineConfiguration`)**:`VZLinuxBootLoader(kernelURL = vmlinuz, initialRamdiskURL = initramfs, commandLine = "console=hvc0 quiet ip=dhcp panic=-1 ...")`;CPU/記憶體由 helper 參數帶入(CPU = min(host, 4)、RAM ≥512MiB,可由 env 覆寫);share 用 `VZVirtioFileSystemDeviceConfiguration`(virtiofs)把 bundle(唯讀)與 data dir(讀寫)掛進 guest;`VZVirtioConsoleDeviceSerialPortConfiguration` + `VZFileHandleSerialPortAttachment` 把 guest 的 hvc0 接到 helper 的 stdout(guest console → helper stdout → chefer-runtime 讀取解析);`VZNATNetworkDeviceConfiguration` + entropy/记忆体气球等預設裝置。helper CLI:`chefer-vz-helper --kernel <p> --initramfs <p> --cmdline <s> --bundle-dir <p> --data-dir <p> --cpus <n> --memory-mib <n>`;開機成功→0、VZ 設定/開機錯誤→非 0(guest 整體 exit code 仍由 console 的 `CHEFER_GUEST_EXIT` 標記回傳)。
- **Helper 生命週期(stdin liveness 契約;實機發現後補)**:runtime 的 Ctrl-C 處理仰賴「後端子行程同屬 process group、收到同號訊號自行結束」,但對 runtime **單發** SIGINT/SIGTERM(如 `kill -INT <pid>`;`vz-smoke.sh` 即如此收尾)時 helper 收不到訊號,實機發現 helper/VM 會殘留繼續吃 CPU。修法:vz.rs 以 `Stdio::piped()` spawn helper 並讓 stdin 寫端 fd 隨 runtime 行程存亡(`mem::forget`);helper 端由專屬執行緒代讀 stdin、轉發進 VZ serial(hvc0 輸入路徑不變),讀到 **EOF 即自我了結**(VZ 的 VM 在 helper 行程內,exit 即拆 VM)——runtime 無論怎麼死(單發訊號、SIGKILL、panic)OS 都會關 fd,helper 不可能殘留。stdin 泵送只在 manifest 有 terminal/both 服務時才做(terminal 服務 stdin 直通,見 §7 stdio 模型);其他 app runtime 不讀自己的 stdin、只握住寫端,避免把使用者終端輸入吞進 guest console。純函式 `vz_util::needs_console_stdin_pump` 決定是否泵送(含單元測試)。
- **Console 標記契約(host 解析 guest 狀態的唯一管道)**:appliance `init` 在 hvc0 印出兩個標記,host 端以子字串比對解析(`vz_util::parse_guest_exit_code` / `parse_guest_ip`,取最後一個、容忍前綴):
- `CHEFER_GUEST_EXIT=<code>`:guest-agent 結束後印出,隨即關機 → host 以此作為整個 app 的 exit code。
- `CHEFER_GUEST_IP=<ipv4>`:開機掛載後、跑 app 前印出 guest eth0 的對外 IPv4,供 host 規劃 host→guest 埠轉發。
Expand All @@ -315,7 +316,7 @@ pub fn run_app(ctx: &AppRunContext) -> anyhow::Result<i32>; // 取第一個 Avai
- **Guest(appliance)**:kernel 開 `CONFIG_DRM_VIRTIO_GPU`(+ DRM/evdev);initramfs 內嵌極小 kiosk Wayland compositor **`cage`** + Mesa(llvmpipe 軟算繪,或 virtio-gpu 加速)+ **Xwayland**(讓 X11 app 也能跑)。guest-agent 對 `interface_mode: gui/both` 的服務改以 `cage -- <服務命令>` 啟動(設好 `WAYLAND_DISPLAY`),算繪到 virtio-gpu scanout。`cage` 的「全螢幕單一 app」模型恰好對上 chefer「一個 app 至多一個介面服務」。
- **Host(vz 後端,`#[cfg(target_os = "macos")]`)——已實作於 `vz-helper/main.swift` 的 `--gui` 模式**:VM 組態加 `VZVirtioGraphicsDeviceConfiguration`(一個 scanout,預設 1280×800、`CHEFER_VZ_GUI_SIZE=WxH` 覆寫)+ `VZUSBKeyboardConfiguration` + `VZUSBScreenCoordinatePointingDeviceConfiguration`(絕對座標,同 WHP 的 tablet 選擇);開一個 AppKit 視窗(標題 = app 名,`--gui-title`)承載 `VZVirtualMachineView` 綁該 VM(VM 綁主 queue、`NSApplication` 事件迴圈)→ 顯示 guest framebuffer,VZ 自動把鍵鼠 HID 轉進 guest。**關窗 = app 結束**(與 WHP 同語意;先試 `vm.stop`、2 秒保底退出,runtime 對「helper 乾淨退出且無 exit 標記」回 0)。vz.rs 依 manifest 有 gui/both 服務傳 `--gui`/`--gui-title`(`vz_util::helper_args`,含單元測試)。GUI 模式下 console 經 helper 內部 pipe tee 直通 stdout(runtime 標記解析不變)並掃 `CHEFER_GUEST_IP` 以啟動剪貼簿;非 gui 維持 headless 原路徑(console 直寫 stdout)。
- **生命週期 / 輸入**:`cage` 內 app 結束 → 沿用 guest-agent 既有「介面服務結束=收掉 app」→ VM 關、視窗關。鍵鼠走 VZ→USB HID→evdev→cage,免額外處理。
- **剪貼簿(host↔guest,vz 與 whp 共用設計)——vz host 端已實作(`ClipboardHost`,`vz-helper/main.swift`)**:**不走 vsock**(whp 得再寫一個 virtio-vsock 裝置模型,成本高)——改走**既有網路通道**:guest-agent 起剪貼簿同步服務(guest 側以 wl-clipboard 對接 cage 的 Wayland 剪貼簿;與 WHP 完全共用 `clipboard.rs`),host 側經該後端既有的 host→guest 通道連入(vz:**直連 guest IP:55381**(NAT 可達,IP 自 console `CHEFER_GUEST_IP` 標記);whp:helper 的 `[::1]` 轉發),雙向同步。安全:以 **kernel cmdline 帶入的隨機 token** 握手(vz helper 以 `SecRandomCopyBytes` 產生、附 `chefer.clip_token`/`chefer.clip_port`),未帶 token 的連線一律拒絕。線協定與 WHP 同版:token 行 + `<u8 kind><u32 be len><payload>`(kind 0=text、1=PNG,上限 32MiB);macOS 端 NSPasteboard 讀寫 `.png`(現代 app)與 `.tiff`(傳統 app,`NSBitmapImageRep` 轉換——對應 whp 的 "PNG"/CF_DIB 雙格式),回音以 changeCount + (kind,payload) 記憶抑制。
- **剪貼簿(host↔guest,vz 與 whp 共用設計)——vz host 端已實作(`ClipboardHost`,`vz-helper/main.swift`)**:**不走 vsock**(whp 得再寫一個 virtio-vsock 裝置模型,成本高)——改走**既有網路通道**:guest-agent 起剪貼簿同步服務(guest 側以 wl-clipboard 對接 cage 的 Wayland 剪貼簿;與 WHP 完全共用 `clipboard.rs`),host 側經該後端既有的 host→guest 通道連入(vz:**直連 guest IP:55381**(NAT 可達,IP 自 console `CHEFER_GUEST_IP` 標記);whp:helper 的 `[::1]` 轉發),雙向同步。安全:以 **kernel cmdline 帶入的隨機 token** 握手(vz helper 以 `SecRandomCopyBytes` 產生、附 `chefer.clip_token`/`chefer.clip_port`),未帶 token 的連線一律拒絕。線協定與 WHP 同版:token 行 + `<u8 kind><u32 be len><payload>`(kind 0=text、1=PNG,上限 32MiB);macOS 端 NSPasteboard 讀寫 `.png`(現代 app)與 `.tiff`(傳統 app,`NSBitmapImageRep` 轉換——對應 whp 的 "PNG"/CF_DIB 雙格式),回音以 changeCount + (kind,payload) 記憶抑制。**`.waiting` 可見性(實機發現後補)**:NWConnection 連 guest:55381 偶發卡在 `.waiting` 數十秒或永不成功(同一時間 shell 的 `nc` 可秒連),疑為 macOS「區域網路」隱私權對 ad-hoc 簽章 helper 的閘門;此狀態原本落在 stateUpdateHandler 的 default 分支完全靜默。現在 helper 對 `.waiting` 印一次性 stderr 提示(引導到 系統設定 → 隱私權與安全性 → 區域網路 放行)並在 3 秒未 ready 時棄連重連(新連線常可立即成功);`build-vz-helper.sh` 另支援 `CHEFER_CODESIGN_IDENTITY` 以穩定簽章身分讓 TCC 記住授權(預設仍 ad-hoc)。
- **分期**:① vz 後端先點亮(非 GUI)✅ → ② appliance 加 virtio-gpu + cage + Xwayland,先在 **QEMU** 驗 GUI app 能算繪 ✅ → ③ 真 Mac 接 `VZVirtualMachineView` + HID —— **host 側程式已完成(`--gui` 模式,CI swiftc compile-check 把關),端到端顯示/輸入待實體 Mac 驗證** → ④ 剪貼簿/縮放 —— **剪貼簿 host 端已完成(同上待實機)**;**縮放(動態解析度)已實作**:macOS 14+ 開 `.resizable` 視窗 + `VZVirtualMachineView.automaticallyReconfiguresDisplay`(VZ 對 guest virtio-gpu 發顯示重組態、guest DRM hotplug 換模式、cage/wlroots 跟隨;macOS 13 無此 API → 維持固定尺寸)。GHA macOS runner 開不了 VZ guest,③④ 的執行驗證 gated on 實體 Mac——**一鍵驗證腳本 `scripts/vz-smoke.sh`**(開機/exit code 傳播/埠轉發自動斷言 + GUI 手動檢核清單),拿到真 Mac 跑一次即可收掉全部待驗項。
- **替代(不採用)**:X11 → 要求使用者裝 **XQuartz**、X11 經 vsock 轉發——較快但破壞零安裝、過 VM 邊界延遲高、XQuartz 老舊,故不採。

Expand Down
9 changes: 7 additions & 2 deletions scripts/build-vz-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
# --arch 省略時取 host 架構(uname -m)。swiftc 可在 Apple Silicon 上交叉編到 x86_64,
# 故 release 可在單一 arm64 runner 上分別產兩種架構的 helper。
# 產出:<out_dir>/chefer-vz-helper-<aarch64|x86_64>(已以 virtualization entitlement ad-hoc 簽章)
#
# 簽章身分:預設 ad-hoc(`-`),每次 build 身分都不同 → macOS TCC(如「區域網路」隱私權,
# 剪貼簿同步連 guest 會踩到)記不住授權、每個新 binary 都可能重新被閘。有 Apple Development
# / Developer ID 憑證時可設 CHEFER_CODESIGN_IDENTITY=<identity> 取得穩定簽章身分。
set -Eeuo pipefail

die() { echo "error: $*" >&2; exit 1; }
Expand Down Expand Up @@ -39,10 +43,11 @@ echo "==> swiftc → $bin (target=$swift_target)"
swiftc -O -target "$swift_target" -framework Virtualization \
-o "$bin" "$root/vz-helper/main.swift"

echo "==> codesign(ad-hoc)+ virtualization entitlement"
identity="${CHEFER_CODESIGN_IDENTITY:--}"
echo "==> codesign(identity: ${identity})+ virtualization entitlement"
codesign --force --options runtime \
--entitlements "$root/vz-helper/vz-helper.entitlements" \
--sign - "$bin"
--sign "$identity" "$bin"

echo "==> 完成:$bin"
codesign -d --entitlements - "$bin" 2>/dev/null || true
Loading
Loading