diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fbed96..bf8f731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ permissions: contents: read jobs: - linux: - name: linux build, unit, pack + hosted-linux: + name: hosted Linux build, unit, pack runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -19,13 +19,14 @@ jobs: with: node-version: "20" cache: npm + - run: sudo apt-get update && sudo apt-get install -y libslirp-dev libglib2.0-dev - run: npm ci --ignore-scripts - run: npm run build - run: npm run test:unit - run: npm run pack:check - windows: - name: windows build, import, pack + hosted-windows-js: + name: hosted Windows JS-only build, import, pack runs-on: windows-latest env: NODE_VMM_SKIP_NATIVE: "1" @@ -36,14 +37,14 @@ jobs: node-version: "20" cache: npm - run: npm ci --ignore-scripts - - run: npm run build + - run: npm run build:ts - shell: pwsh run: | node -e "import('./dist/src/index.js').then((m)=>{ console.log(m.features()[0]); })" - run: npm pack --dry-run --ignore-scripts - windows-whp-gate: - name: windows WHP native gate + self-hosted-windows-whp: + name: self-hosted Windows WHP native gate runs-on: [self-hosted, windows, x64, whp] if: github.event_name == 'workflow_dispatch' steps: @@ -54,13 +55,45 @@ jobs: cache: npm - run: npm ci --ignore-scripts - run: npm run build - - shell: pwsh + - name: Check required WHP doctor probes + shell: pwsh run: | - node -e "import('./dist/src/index.js').then(async (m)=>{ const doctor = await m.doctor(); console.log(JSON.stringify(doctor, null, 2)); if (!doctor.ok) process.exit(1); })" - node -e "import('./dist/src/kvm.js').then((m)=>{ const smoke = m.whpSmokeHlt(); console.log(JSON.stringify(smoke, null, 2)); if (smoke.exitReason !== 'hlt' || smoke.dirtyPages < 1) process.exit(1); })" + @' + import { doctor } from "./dist/src/index.js"; + + const result = await doctor(); + console.log(JSON.stringify(result, null, 2)); - kvm-release-gate: - name: kvm release gate + const checks = new Map(result.checks.map((check) => [check.name, check])); + const required = ["whp-api", "whp-dirty-pages"]; + const failed = required + .map((name) => checks.get(name) ?? { name, ok: false, label: "missing required doctor check" }) + .filter((check) => !check.ok); + + if (failed.length > 0) { + const details = failed.map((check) => `${check.name}: ${check.label}`).join("; "); + console.error(`Required WHP doctor checks failed: ${details}`); + process.exit(1); + } + '@ | node --input-type=module - + - name: Run WHP dirty-page smoke + shell: pwsh + run: | + @' + import { whpSmokeHlt } from "./dist/src/kvm.js"; + + const smoke = whpSmokeHlt(); + console.log(JSON.stringify(smoke, null, 2)); + + if (smoke.exitReason !== "hlt" || smoke.dirtyPages < 1 || smoke.dirtyTracking !== true) { + console.error("Required WHP dirty-page smoke failed"); + process.exit(1); + } + '@ | node --input-type=module - + - run: node --test dist/test/native.test.js + + self-hosted-linux-kvm: + name: self-hosted Linux KVM release gate runs-on: [self-hosted, linux, x64, kvm] if: github.event_name == 'workflow_dispatch' steps: @@ -69,6 +102,9 @@ jobs: with: node-version: "20" cache: npm + - run: sudo apt-get update && sudo apt-get install -y libslirp-dev libglib2.0-dev - run: npm ci --ignore-scripts + - run: npm run build + - run: node --test dist/test/native.test.js - run: echo "NODE_VMM_KERNEL=$(npm run -s kernel:fetch)" >> "$GITHUB_ENV" - run: npm run release:check diff --git a/.github/workflows/prebuilt-rootfs.yml b/.github/workflows/prebuilt-rootfs.yml new file mode 100644 index 0000000..910b27a --- /dev/null +++ b/.github/workflows/prebuilt-rootfs.yml @@ -0,0 +1,114 @@ +name: prebuilt-rootfs + +# Builds prebuilt ext4 rootfs files for the most common images and +# attaches them to the matching GitHub Release. Lets Windows users +# `node-vmm run --image alpine:3.20` boot without a local WSL2 install, +# and lets anyone download a sandbox-ready rootfs without the OCI fetch +# overhead. +# +# Triggered on tag push (v*) and on workflow_dispatch. The build runs +# inside ubuntu-latest with sudo because the pipeline uses mkfs.ext4 + +# mount + chroot. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release tag to attach prebuilt rootfs files to" + required: true + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.image }} rootfs + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image: alpine:3.20 + slug: alpine-3.20 + disk: 256 + - image: node:20-alpine + slug: node-20-alpine + disk: 1024 + - image: node:22-alpine + slug: node-22-alpine + disk: 1024 + - image: oven/bun:1-alpine + slug: oven-bun-1-alpine + disk: 1024 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "20" + cache: npm + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y g++ libslirp-dev e2fsprogs util-linux + - run: npm ci --ignore-scripts + - name: Build TS + native (Linux) + env: + NODE_VMM_FORCE_NATIVE_BUILD: "1" + run: npm run build + - name: Build prebuilt rootfs + run: | + mkdir -p dist-rootfs + sudo -E node scripts/build-prebuilt-rootfs.mjs \ + --image "${{ matrix.image }}" \ + --output "dist-rootfs/${{ matrix.slug }}.ext4" \ + --disk-mib ${{ matrix.disk }} \ + --slug "${{ matrix.slug }}" + - name: List generated assets + run: | + cd dist-rootfs + ls -la + - uses: actions/upload-artifact@v4 + with: + name: rootfs-${{ matrix.slug }} + path: | + dist-rootfs/${{ matrix.slug }}.ext4.gz + dist-rootfs/${{ matrix.slug }}.ext4.manifest.json + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + release: + name: Attach rootfs prebuilds to release + needs: [build] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Stage release assets + run: | + set -euo pipefail + mkdir -p release + for dir in artifacts/rootfs-*; do + cp -v "$dir"/*.ext4.gz release/ + cp -v "$dir"/*.ext4.manifest.json release/ + done + ls -la release + - name: Determine release tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "ref=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "ref=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + fi + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.ref }} + files: | + release/*.ext4.gz + release/*.ext4.manifest.json + fail_on_unmatched_files: true diff --git a/.github/workflows/release-prebuilds.yml b/.github/workflows/release-prebuilds.yml new file mode 100644 index 0000000..ba17d24 --- /dev/null +++ b/.github/workflows/release-prebuilds.yml @@ -0,0 +1,182 @@ +name: release-prebuilds + +# Builds the native addon for Windows, Linux, and Apple Silicon macOS on every +# tag push, attaches the prebuilds to the matching GitHub Release, then +# assembles those same prebuilds into the npm package before publishing. The +# addon uses Node-API, so one binary per OS/arch is enough for supported Node +# versions. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Release tag to attach prebuilds to (e.g. v0.1.4)" + required: true + +permissions: + contents: write + +jobs: + build-windows: + name: Windows x64 prebuild + runs-on: windows-2022 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "20" + cache: npm + # MSVC is preinstalled on windows-2022 runners. The build script picks + # up libslirp via third_party/libslirp (vendored from MSYS2 prebuilts). + - name: Vendor libslirp + run: node scripts/vendor-libslirp.mjs + - run: npm ci --ignore-scripts + - name: Build native addon + env: + NODE_VMM_FORCE_NATIVE_BUILD: "1" + run: npm run build:native + - name: Stage prebuild + run: node scripts/package-prebuild.mjs + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: prebuild-win32-x64 + path: prebuilds/win32-x64/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + build-linux: + name: Linux x64 prebuild + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "20" + cache: npm + - run: sudo apt-get update && sudo apt-get install -y g++ libslirp-dev libglib2.0-dev + - run: npm ci --ignore-scripts + - name: Build native addon + env: + NODE_VMM_FORCE_NATIVE_BUILD: "1" + run: npm run build:native + - name: Stage prebuild + run: node scripts/package-prebuild.mjs + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: prebuild-linux-x64 + path: prebuilds/linux-x64/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + build-macos: + name: macOS arm64 prebuild + runs-on: macos-15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "20" + cache: npm + - run: brew install pkg-config libslirp glib + - run: npm ci --ignore-scripts + - name: Build native addon + env: + NODE_VMM_FORCE_NATIVE_BUILD: "1" + run: npm run build:native + - name: Stage prebuild + run: node scripts/package-prebuild.mjs + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: prebuild-darwin-arm64 + path: prebuilds/darwin-arm64/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + release: + name: Attach prebuilds to release + needs: [build-windows, build-linux, build-macos] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Bundle prebuilds into tarballs + run: | + set -euo pipefail + mkdir -p release + for dir in artifacts/prebuild-*; do + name=$(basename "$dir") + tar -C "$dir" -czf "release/${name}.tar.gz" . + done + ls -la release + - name: Determine release tag + id: tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "ref=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "ref=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + fi + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.ref }} + files: release/*.tar.gz + fail_on_unmatched_files: true + + publish-npm: + name: Publish npm package with all prebuilds + needs: [build-windows, build-linux, build-macos] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: "20" + cache: npm + registry-url: "https://registry.npmjs.org" + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Restore release prebuilds + run: | + set -euo pipefail + rm -rf prebuilds/linux-x64 prebuilds/win32-x64 prebuilds/darwin-arm64 + mkdir -p prebuilds/linux-x64 prebuilds/win32-x64 prebuilds/darwin-arm64 + cp -a artifacts/prebuild-linux-x64/. prebuilds/linux-x64/ + cp -a artifacts/prebuild-win32-x64/. prebuilds/win32-x64/ + cp -a artifacts/prebuild-darwin-arm64/. prebuilds/darwin-arm64/ + find prebuilds -maxdepth 2 -type f -print | sort + - run: npm ci --ignore-scripts + - run: npm run build:ts + - name: Verify npm tarball contents + env: + NODE_VMM_PACK_REQUIRE_WIN32: "1" + NODE_VMM_PACK_REQUIRE_DARWIN: "1" + run: node scripts/pack-check.mjs + - name: Verify npm auth + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "${NODE_AUTH_TOKEN:-}" ]; then + echo "NPM_TOKEN secret is required to publish @misaelzapata/node-vmm" + exit 1 + fi + - run: npm publish --dry-run --ignore-scripts --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: npm publish --ignore-scripts --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 088abe9..6457b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ node-vmm-*.tgz .node-vmm/ .node-vmm-demo/ .codex +.tools/ +.npm-cache/ diff --git a/README.md b/README.md index 9fec601..a0803d2 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,29 @@ [![Types](https://img.shields.io/badge/types-TypeScript-blue.svg?style=flat)](docs/sdk.md) [![ESM only](https://img.shields.io/badge/module-ESM%20only-4b32c3.svg?style=flat)](docs/sdk.md) [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg?style=flat)](docs/testing.md) -[![Runtime](https://img.shields.io/badge/runtime-Linux%2FKVM-2ea44f.svg?style=flat)](#requirements) -[![Windows](https://img.shields.io/badge/Windows%20WHP-in%20progress-orange.svg?style=flat)](docs/windows.md) +[![Runtime](https://img.shields.io/badge/runtime-Linux%2FKVM%20%7C%20Windows%2FWHP%20%7C%20macOS%2FHVF-2ea44f.svg?style=flat)](#requirements) +[![Windows](https://img.shields.io/badge/Windows%20WHP-working-2ea44f.svg?style=flat)](docs/windows.md) +[![macOS](https://img.shields.io/badge/macOS%20HVF-working-2ea44f.svg?style=flat)](docs/macos.md) **Run real Node apps inside small Linux microVMs from TypeScript or JavaScript.** `node-vmm` is an ESM SDK and CLI that turns OCI images, Dockerfiles, or Git -repos into bootable ext4 rootfs images, then runs them through a compact native -KVM backend. It is built for sandbox-like workloads where Node developers want -process-shaped ergonomics with VM isolation: run code, boot app servers, publish -ports, pause/resume, and throw writable overlays away. - -The npm release target is Linux/KVM today. Windows Hypervisor Platform support -is in progress: Windows can build/import the JS package and the experimental WHP -backend is kept behind a manual native gate. +repos into bootable ext4 rootfs images, then runs them through compact native +Linux/KVM, Windows/WHP, and macOS/HVF backends. It is built for sandbox-like +workloads where Node developers want process-shaped ergonomics with VM +isolation: run code, boot app servers, publish ports, pause/resume, and throw +writable overlays away. + +Linux/KVM is the primary release path. Windows Hypervisor Platform is now a +working backend in this branch for x86_64 Linux guests: boot, interactive +console, DNS/networking, TCP port forwarding, RNG, clock, `apk`, pause, resume, +and stop are covered by WHP integration tests. On Windows, prebuilt rootfs +downloads and already-prepared ext4 rootfs disks boot without WSL2; WSL2 remains +the fallback for fresh OCI rootfs creation when no prebuilt disk is available. +macOS/HVF is now a working Apple Silicon backend for ARM64 Linux guests: Alpine +OCI rootfs builds, PL011 interactive console on `/dev/ttyAMA0`, Slirp +networking, TCP port forwarding, `apk`, pause/resume/stop, attached disks, and +SMP are covered by the macOS HVF test gate. ## Why node-vmm? @@ -32,6 +41,12 @@ backend is kept behind a manual native gate. - **No Docker Engine dependency:** the builder pulls OCI layers and assembles rootfs images directly. - **Docker-style ports:** publish guest TCP ports such as `3000:3000`. +- **Cross-host runtime:** Linux uses KVM; Windows uses Windows Hypervisor + Platform; Apple Silicon macOS uses Hypervisor.framework. +- **Windows-friendly disks:** common Alpine, Node, and Bun images can start + from prebuilt ext4 rootfs assets before falling back to WSL2 rootfs creation. +- **macOS-friendly boot path:** ARM64 OCI images build locally with Homebrew + `mkfs.ext4`, and `--net auto` uses Slirp by default. - **Fast warm lifecycle:** pause/resume already-running app VMs and reuse prepared rootfs templates for repeated boots. - **Release-gated apps:** plain Node, Express, Fastify, Next.js, Vite React, and @@ -70,6 +85,18 @@ candidate: | Cold build into cache | `alpine:3.20`, `echo` | miss | 4.091 s | | Cold build into cache | `node:22-alpine`, `node -e` | miss | 9.498 s | +Windows/WHP local measurements from this branch, measured on 2026-05-03 with +Node.js 24.15.0, WHP, one vCPU, `--net none` for command runs, and WSL2 used +only for cold OCI rootfs fallback: + +| Path | Image / command | Cache/build | Wall time | +| --- | --- | --- | ---: | +| Cold command | `alpine:3.20`, `tty`/`stty -F /dev/ttyS0` | miss, WSL2 OCI build | 12.280 s | +| Warm command | `alpine:3.20`, `true` | hit | 618-621 ms | +| Warm TTY sanity | `alpine:3.20`, `tty` + `/dev/ttyS0` check | hit | 779-819 ms | +| Live VM control | `alpine:3.20`, `sleep 30` | already running | resume 1-2 ms | +| Plain Node HTTP | `node:22-alpine` app server | cold WSL2 OCI build | pause 8 ms, resume-to-HTTP 58 ms | + Cold runs include `mkfs.ext4`, OCI pull/extract, rootfs materialization, guest Linux boot, and command execution. Warm runs reuse the prepared ext4 rootfs from `--cache-dir/rootfs` and boot it with a temporary sparse overlay, so guest writes @@ -146,6 +173,130 @@ const result = await sandbox.process.exec("echo hello"); await sandbox.delete(); ``` +### Windows Quick Start + +On Windows there are three moving parts, and the split is simple: + +- **WHP runs the VM.** This is the actual hypervisor backend. +- **Prebuilt rootfs disks avoid WSL2.** For published release assets such as + `alpine:3.20`, `node:20-alpine`, `node:22-alpine`, and + `oven/bun:1-alpine`, `node-vmm` downloads a checked ext4 disk and boots it + directly. +- **WSL2 is the fallback builder.** It is only used when `node-vmm` needs Linux + filesystem tools to create a fresh ext4 disk from an OCI image that was not + already cached or available as a prebuilt asset. + +For the smoothest first run, enable WHP first. Install WSL2 when you also want +the fallback builder for images that do not have a published prebuilt disk. + +One-time Windows setup: + +```powershell +# Run PowerShell as Administrator. +# Enable Windows Hypervisor Platform if it is not already enabled. +Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform -All +``` + +Restart Windows if the command asks for it. Optional WSL2 fallback setup: + +```powershell +wsl --install -d Ubuntu +wsl --set-default-version 2 +wsl --update +wsl -d Ubuntu -u root -- sh -lc "apt-get update && apt-get install -y e2fsprogs util-linux python3 coreutils g++" +``` + +Now run a VM from Windows: + +```powershell +npm install @misaelzapata/node-vmm + +$env:NODE_VMM_KERNEL = node-vmm kernel fetch +node-vmm doctor + +node-vmm run ` + --image alpine:3.20 ` + --interactive ` + --net auto ` + --cpus 1 ` + --mem 256 +``` + +Without WSL2, `doctor` can report a missing `rootfs-builder`; that does not +block `--rootfs` runs or an `--image` run that succeeds through the prebuilt +rootfs path. + +What happens on the first `--image` run for a supported prebuilt image: + +1. `node-vmm` checks the prepared rootfs cache. +2. On a miss, it tries the package-versioned GitHub Release asset, for example + `alpine-3.20.ext4.gz` plus `alpine-3.20.ext4.manifest.json`. +3. If the prebuilt asset verifies, WHP boots it directly from Windows with no + WSL2 process. +4. If the prebuilt is missing or the image is unsupported, `node-vmm` falls back + to WSL2 OCI rootfs creation when WSL2 is installed. + +Already have a rootfs disk? Then WSL2 is not involved: + +```powershell +node-vmm run ` + --rootfs .\alpine.ext4 ` + --interactive ` + --net none ` + --cpus 1 ` + --mem 256 +``` + +The current Windows path has been validated with Alpine boot, `apk add htop`, +`apk add nodejs && node`, DNS, Slirp networking, TCP port forwarding, RNG, +clocksource setup, pause/resume/stop, and interactive `Ctrl-C` delivery inside +the guest. + +### macOS Quick Start + +On Apple Silicon macOS, HVF runs ARM64 Linux guests. The default macOS network +path is Slirp, so `--net auto` works without Linux TAP setup. + +One-time macOS setup: + +```bash +xcode-select --install +brew install e2fsprogs pkg-config libslirp glib +export PATH="$(brew --prefix e2fsprogs)/sbin:$PATH" +``` + +From a repo checkout, build and let the HVF test runner create a signed Node +copy with the `com.apple.security.hypervisor` entitlement: + +```bash +npm ci --ignore-scripts +npm run build +npm run test:macos-hvf +``` + +Now run a VM through the signed Node binary: + +```bash +SIGNED_NODE="$HOME/.cache/node-vmm/node-hvf-signed" +KERNEL="$(node dist/src/main.js kernel fetch)" + +"$SIGNED_NODE" dist/src/main.js run \ + --image alpine:3.20 \ + --kernel "$KERNEL" \ + --interactive \ + --net auto \ + --disk 512 \ + --cpus 1 \ + --mem 256 +``` + +The current macOS path has been validated with Alpine ARM64 boot, local OCI +rootfs creation, `/dev/ttyAMA0` interactive console, `apk update`, +`apk add nodejs npm`, DNS, Slirp networking, TCP port forwarding, +pause/resume/stop, SMP, and attached disks. A 512 MiB disk is a comfortable +default for `nodejs+npm`; smaller disks such as 312 MiB work for Alpine Node but +leave less headroom for package caches and experiments. + ## Docs At A Glance - [CLI](#cli) @@ -156,10 +307,11 @@ await sandbox.delete(); - [Performance measurements](docs/performance.md) - [Publishing checklist](docs/publishing.md) - [Windows WHP status](docs/windows.md) +- [macOS HVF status](docs/macos.md) ## Requirements -Linux runtime: +Linux/KVM runtime: - Linux with `/dev/kvm` - KVM enabled in BIOS/UEFI and loaded by the host kernel @@ -167,6 +319,8 @@ Linux runtime: - Linux x64 npm installs use the bundled native prebuild by default - `python3`, `make`, and `g++` are only needed when forcing or falling back to a local `node-gyp` build +- `libslirp-dev` and `libglib2.0-dev` are needed when forcing a local build + that should support `network: "slirp"` / `--net slirp` - `mkfs.ext4`, `mount`, `umount`, `truncate`, `install` - `ip`, `iptables`, `sysctl` for `network: "auto"` / `--net auto` - `git` for `--repo` / SDK repo builds @@ -176,32 +330,80 @@ Linux runtime: - Kernel downloads are SHA-256 checked. For custom kernel URLs, set `NODE_VMM_KERNEL_SHA256` or publish a `.sha256` sidecar next to the `.gz`. -## What Can Run - -| Workflow | Works today | Needs KVM | Needs sudo | -| --- | --- | --- | --- | -| Import SDK / inspect `features()` | Yes | No | No | -| Build from OCI image or Dockerfile | Yes | No | Yes, for ext4 mount/chroot | -| Build from Git repo with `--repo` | Yes | No | Yes, for ext4 mount/chroot | -| Run a prepared rootfs with `--net none` | Yes | Yes | No, if user can open `/dev/kvm` | -| Run web apps with `--net auto -p 3000:3000` | Yes | Yes | Yes | -| Next.js, Vite React/Vue, Express, Fastify app servers | Yes | Yes | Yes | -| Multi-vCPU runtime with `--cpus > 1` | Yes, Linux/KVM | Yes | Depends on network/rootfs setup | -| Windows WHP runtime | In progress | Host WHP | No Linux sudo | - -Windows work in progress: +Windows/WHP runtime: - Windows 11 or Windows Server with Windows Hypervisor Platform enabled +- virtualization enabled in BIOS/UEFI - Node.js 18.19+ -- Visual Studio Build Tools / Windows SDK for `node-gyp` -- `npm run build` is prepared to compile the native WHP addon -- `node-vmm doctor` is prepared to check WHP and dirty-page tracking +- Windows x64 npm installs use the bundled WHP native prebuild and dependency + DLLs by default; Visual Studio Build Tools are only needed when forcing or + falling back to a local `node-gyp` build +- no Linux `sudo`; WHP runs as a normal Windows user +- native Slirp networking for `--net auto` and TCP port forwarding +- WSL2 is currently used only when Windows needs to build a fresh rootfs from an + OCI image and no prebuilt or cached rootfs can satisfy the request +- inside WSL2, the builder needs common Linux filesystem tools such as + `truncate`, `mkfs.ext4`, `mount`, `umount`, `python3`, and `install` +- `g++` inside WSL2 is only needed if the packaged Linux console helper prebuild + is missing or stale + +Windows without WSL2: + +- works today when you pass an existing ext4 disk with + `run --rootfs ./existing.ext4` +- also works when the prepared rootfs is already cached or when the release + prebuilt rootfs download succeeds +- keeps the same WHP runtime features: boot, interactive console, networking, + TCP ports, pause, resume, and stop +- fresh `run --image ...` for unsupported images still needs WSL2 until the + native Windows ext4 writer ships + +macOS/HVF runtime: + +- Apple Silicon macOS with Hypervisor.framework +- Node.js 18.19+ +- Darwin arm64 npm installs can use the bundled HVF native prebuild and dylibs + when present; Xcode Command Line Tools, Python, and `node-gyp` are only needed + when forcing or falling back to a local native build +- a Node binary signed with `com.apple.security.hypervisor`; `npm run + test:macos-hvf` creates `~/.cache/node-vmm/node-hvf-signed` +- `e2fsprogs`, `pkg-config`, `libslirp`, and `glib` from Homebrew for local + rootfs and native builds +- native Slirp networking for `--net auto` and TCP port forwarding +- local OCI rootfs builds for ARM64 images; Dockerfile and repo rootfs builds + still require Linux +- ARM64 Linux `Image` kernels; Linux/KVM and Windows/WHP use x86_64 `vmlinux` + instead + +## What Can Run + +| Workflow | Linux/KVM | Windows/WHP | macOS/HVF | Extra host dependency | +| --- | --- | --- | --- | --- | +| Import SDK / inspect `features()` | Yes | Yes | Yes | None | +| Build from OCI image | Yes | Yes, prebuilt first then WSL2 fallback | Yes, ARM64 local build | Linux: sudo/ext4 tools; Windows: WSL2 only on fallback; macOS: Homebrew `mkfs.ext4` | +| Build from Dockerfile or Git repo | Yes | Planned | Planned | Linux: sudo/ext4 tools | +| Run prepared rootfs with `--net none` | Yes | Yes | Yes | Linux needs `/dev/kvm`; Windows needs WHP; macOS needs signed Node with HVF entitlement | +| Run supported prebuilt image from cold cache | Yes | Yes, no WSL2 if release asset exists | Planned for ARM64 assets | Network access to GitHub Releases | +| Run `--image alpine:3.20` from cache | Yes | Yes | Yes | Cache must already contain the prepared rootfs | +| Run unsupported `--image ...` from cold cache | Yes | Yes, with WSL2 | Yes, when the OCI image has arm64 layers | OCI network access and ext4 builder | +| Interactive shell / getty console | Yes | Yes | Yes | Host terminal | +| `apk add`, DNS, outbound network | Yes | Yes | Yes | Linux TAP/NAT or explicit Slirp; Slirp on Windows/macOS | +| TCP port forwarding | Yes | Yes | Yes | Linux TAP/NAT or explicit Slirp; Slirp on Windows/macOS | +| Pause, resume, stop | Yes | Yes | Yes | Native backend | +| RNG device for guest entropy | Yes | Yes | Host kernel entropy path | Native backend | +| Multi-vCPU native runner | Yes | Yes | Yes | Full Linux guest SMP parity remains backend work | +| Persistent rootfs writes | Yes | Yes | Yes | Run an explicit `--rootfs` without `--sandbox` | +| Reset-on-exit rootfs writes | Yes | Yes | Yes | Use `--sandbox` / `--restore` or cached/prebuilt image runs | +| Persistent named root disks | Yes | Yes | Yes | `--persist` / SDK `persist` stores a root disk under `--cache-dir/disks`; `--reset` recreates it | +| Attached data disks | Yes | Yes | Yes | `--attach-disk` / SDK `attachDisks` maps existing disk files after `/dev/vda`, starting at `/dev/vdb` | +| Next.js, Vite React/Vue, Express, Fastify app servers | Yes | Smoke path in progress | Smoke path in progress | Rootfs/image support for the app | ## Running Without Sudo Yes, VM execution can run without `sudo` when the host user can open `/dev/kvm` and the VM does not need root-only setup. The root-only parts are rootfs -creation/mounting and `--net auto`. +creation/mounting and `--net auto`; `--net slirp` avoids TAP/iptables setup when +the Linux native addon was built with libslirp. One simple workflow is: @@ -242,6 +444,82 @@ image. Use `node-vmm build --output ./app.ext4` plus `run --rootfs ./app.ext4` when you want an explicit disk artifact you can move, inspect, or version outside the cache. +## Disk Persistence, Reset, And Attached Disks + +The public disk model has one ext4 root disk, optional sparse overlays, and +optional attached data disks: + +- `node-vmm run --rootfs ./state.ext4` without `--sandbox` opens that disk as + writable, so guest changes persist in `state.ext4`. +- `node-vmm run --rootfs ./state.ext4 --sandbox` resets on exit by writing guest + changes to a temporary overlay and deleting it afterward. +- `node-vmm run --image ...` uses the prepared-rootfs cache when possible and + automatically adds an overlay, so cache and prebuilt base disks are not + mutated by guest writes. +- `--persist NAME` / SDK `persist: "name"` creates or reuses a stateful root + disk under `--cache-dir/disks`. For example, `--persist node-work` creates + `disks/node-work.ext4` plus `disks/node-work.json` metadata. The name must be + a simple letters/numbers/`.`/`_`/`-` value. +- `--disk-path PATH` / SDK `diskPath` creates or reuses an explicit stateful + root disk path. It cannot be combined with `persist` or `rootfsPath`. +- `--disk-size MIB` / SDK `diskSizeMiB` chooses the ext4 size at build, cold + cache, or persistent-disk materialization time. Numeric `--disk MIB` still + works as a legacy alias. Persistent and explicit root disks can grow by + truncating the host file and asking the guest to run `resize2fs` when present; + shrinking is rejected. +- `--attach-disk PATH`, `--attach-disk-ro PATH`, and SDK `attachDisks` attach + existing data disk files after the root disk, starting at `/dev/vdb`. + Attached disks are not formatted or mounted for the guest; create the + filesystem you want inside the VM or ahead of time. + +Write and resize rules are intentionally boring: + +- `--persist`, `--disk-path`, and writable `--rootfs` run the guest against the + real root disk file as `/dev/vda`; writes to `/dev/vda` remain there. +- `--image` without `--persist`/`--disk-path` protects the shared cached or + prebuilt base image with a sparse overlay. The overlay receives writes and is + removed after the run unless you explicitly keep it for debugging. +- `--reset` only applies to `--persist` or `--disk-path`. It recreates that + stateful root disk from the currently selected source image/rootfs. +- A named persistent disk remembers the source it came from in its `.json` + metadata. Reusing the same name with a different image/rootfs fails until you + pass `--reset`, which prevents accidental state reuse across unrelated guests. +- Growing a root disk extends the host file first. On the next boot, node-vmm + passes `node_vmm.resize_rootfs=1`; the guest init script runs + `resize2fs /dev/vda` when the image provides it. Attached data disks are never + resized automatically. + +Examples: + +```bash +# Named persistent root disk in the node-vmm cache. +node-vmm run \ + --image node:22-alpine \ + --persist node-work \ + --disk-size 2048 \ + --cmd "node -e \"console.log(process.version)\"" \ + --net auto + +# Recreate that named disk from the current image. +node-vmm run --image node:22-alpine --persist node-work --reset --cmd "true" + +# Explicit root disk path. +node-vmm run \ + --image alpine:3.20 \ + --disk-path ./work.ext4 \ + --disk-size 1024 \ + --cmd "echo persisted > /root/marker" \ + --net none + +# Existing extra data disk plus a read-only seed disk. +node-vmm run \ + --rootfs ./root.ext4 \ + --attach-disk ./data.ext4 \ + --attach-disk-ro ./seed.ext4 \ + --cmd "lsblk && mount /dev/vdb /mnt" \ + --net none +``` + ## Kernel `node-vmm` can download a default guest kernel artifact for local development: @@ -273,8 +551,8 @@ npm install @misaelzapata/node-vmm npx @misaelzapata/node-vmm features ``` -On Linux x64 the package includes `prebuilds/linux-x64/node_vmm_native.node`, so -normal installs do not compile the KVM addon. Set +On Linux x64 and Windows x64 the package includes native prebuilds under +`prebuilds/-x64/`, so normal installs do not compile the addon. Set `NODE_VMM_FORCE_NATIVE_BUILD=1` before `npm rebuild @misaelzapata/node-vmm` if you want to rebuild it locally. @@ -294,11 +572,13 @@ sudo node-vmm run \ --image alpine:3.20 \ --cmd /bin/sh \ --interactive +``` In an interactive console, `exit` or `Ctrl-D` exits the guest shell normally. -`Ctrl-C` is reserved as a host escape: it stops the VM, restores the terminal, -and runs the usual network/overlay cleanup. +On macOS/HVF, `Ctrl-C` is delivered to the guest TTY so foreground commands are +interrupted without tearing down the VM. +```bash sudo node-vmm run \ --rootfs ./alpine.ext4 \ --cmd "echo hot path" \ @@ -430,6 +710,7 @@ Subpath exports are also available for advanced users: import { pullOciImage } from "@misaelzapata/node-vmm/oci"; import { buildRootfs } from "@misaelzapata/node-vmm/rootfs"; import { runKvmVm } from "@misaelzapata/node-vmm/kvm"; +import { materializePersistentDisk } from "@misaelzapata/node-vmm/disk"; ``` `node-vmm` is ESM-only. CommonJS projects can use dynamic import: @@ -448,24 +729,40 @@ For full API notes, Next.js usage, testing, and publishing, see: - [Performance](docs/performance.md) - [Snapshots and fast restore](docs/snapshots.md) - [Windows WHP backend](docs/windows.md) +- [macOS HVF backend](docs/macos.md) - [Publishing](docs/publishing.md) ## Current v1 Support - x86_64 KVM on Linux, 1-64 active vCPUs -- experimental WHP backend work for Windows -- ELF `vmlinux` kernels +- working x86_64 WHP backend on Windows for Linux guest VMs +- working arm64 HVF backend on Apple Silicon macOS for Linux guest VMs +- x86_64 ELF `vmlinux` kernels for KVM/WHP and ARM64 Linux `Image` kernels for + HVF - UART console with batch and interactive modes +- Windows getty/ttyS0 interactive console with VT input, arrow keys, `Ctrl-C` + delivered to the guest, and low idle CPU usage +- macOS PL011 `/dev/ttyAMA0` interactive console with boot loading progress, + host TTY sizing, and guest `Ctrl-C` delivery - virtio-mmio block root disk at `/dev/vda` +- CLI/SDK attached data disks after `/dev/vda`, starting at `/dev/vdb` +- virtio-mmio RNG for guest entropy - sparse copy-on-write `--sandbox` restore mode +- prebuilt rootfs downloads for common Alpine, Node, and Bun images before WSL2 + fallback - core `snapshot create` / `snapshot restore` bundle commands -- virtio-mmio network through TAP/NAT +- virtio-mmio network through TAP/NAT on Linux and Slirp on Windows/macOS +- Docker-style TCP port forwarding on Linux/KVM, Windows/WHP, and macOS/HVF +- WHP and HVF pause/resume/stop lifecycle controls +- WHP clocksource/timer path and HVF host UTC init path for Alpine guests +- OCI download progress and rootfs cache reuse - OCI manifest/index resolution, gzip layers, cache, and basic whiteouts - Dockerfile builds for common Node/JS app patterns without Docker Engine `--cpus` / `cpus` is wired through the CLI, SDK, native KVM runtime, ACPI/MP tables, and snapshot manifests. Values must be integers from `1` to `64`. -Next release work: full WHP boot parity, broader Dockerfile instruction -coverage, migration, jailer, bzImage boot, and the long-lived in-guest exec -agent. +Next release work: native no-WSL Windows rootfs creation for unsupported images, +broader Dockerfile instruction coverage, migration, jailer, bzImage boot, +stronger full Linux guest SMP parity on WHP, reusable attached-disk creation and +format helpers, and the long-lived in-guest exec agent. diff --git a/binding.gyp b/binding.gyp index 6ae5fac..87f8286 100644 --- a/binding.gyp +++ b/binding.gyp @@ -5,7 +5,7 @@ "sources": [], "conditions": [ ["OS=='linux'", { - "sources": ["native/kvm_backend.cc"], + "sources": ["native/kvm/backend.cc"], "cflags_cc!": ["-fno-exceptions"], "cflags_cc": [ "-std=c++17", @@ -18,11 +18,35 @@ "-fstack-protector-strong", "-D_FORTIFY_SOURCE=2" ], - "ldflags": ["-Wl,-z,relro", "-Wl,-z,now", "-Wl,-z,noexecstack"] + "ldflags": ["-Wl,-z,relro", "-Wl,-z,now", "-Wl,-z,noexecstack"], + "conditions": [ + ["\" exec -> pause` without rebooting the guest. | | Warm pool manager | Missing | Needs template cache, lease/recycle, health checks, and cleanup policy. | -| Multi-vCPU runtime | Available | `--cpus` / `cpus` creates Linux/KVM vCPU threads and exposes them through ACPI/MP tables. | -| Windows WHP runtime | In progress | Build/probe path exists; npm release should be treated as Linux/KVM today. | -| Native prebuilts | Partial | Linux x64 npm installs include `prebuilds/linux-x64/node_vmm_native.node`; other targets fall back to `node-gyp`. | +| Multi-vCPU runtime | Available | Linux/KVM and Windows/WHP support 1-64 vCPUs; WHP Alpine SMP is tested locally. | +| Windows WHP native probe | Available on WHP hosts | `probeWhp()` reports hypervisor, partition, and dirty-page capability. | +| Windows WHP HLT smoke | Available on WHP hosts | `whpSmokeHlt()` validates partition/vCPU/RAM/dirty bitmap against a tiny guest. | +| macOS HVF native probe | Available on Apple Silicon hosts | `probeHvf()` reports Hypervisor.framework availability; FDT/PL011/device smokes validate the supported ARM `virt` subset. | +| Windows OCI rootfs build | Partial | `--image` uses prebuilt/cache paths first; local OCI rootfs creation falls back to WSL2. Dockerfile and repo builds still require Linux. | +| macOS OCI rootfs build | Available for ARM64 images | `--image` pulls linux/arm64 layers and writes ext4 locally with `mkfs.ext4 -d`; Dockerfile RUN and repo builds still require Linux. | +| Native prebuilts | Available for release targets | npm release tarballs include Linux x64, Windows x64, and Darwin arm64 native prebuilds. Darwin arm64 prebuilds bundle their libslirp/glib dylibs. Unsupported targets fall back to `node-gyp`. | | Compose/multi-service apps | Missing | Single VM app/server workflow exists; stacks are not implemented yet. | -| ARM64 backend | Missing | Current native backend and default kernel path target x86_64. | +| ARM64 backend | Available on macOS/HVF | HVF targets ARM64 Linux `Image` kernels on Apple Silicon. KVM/WHP continue to use x86_64 kernels. | + +## Platform Matrix + +| Capability | Linux/KVM | Windows/WHP | macOS/HVF | +| --- | --- | --- | --- | +| Hosted CI | Build, unit, pack on Ubuntu | JS-only build, import, pack with `NODE_VMM_SKIP_NATIVE=1` | Build/pack shape; native e2e needs Apple Silicon | +| Native gate | Self-hosted Linux runner with `/dev/kvm` | Self-hosted Windows runner labelled `windows`, `x64`, `whp` | Apple Silicon runner with signed Node and `npm run test:macos-hvf` | +| Native probe | `probeKvm()` | `probeWhp()` | `probeHvf()` | +| Tiny/native smoke | `smokeHlt()` | `whpSmokeHlt()` | `hvfFdtSmoke()`, `hvfPl011Smoke()`, `hvfDeviceSmoke()` | +| Real rootfs boot | Available | Available locally through WSL2-built OCI rootfs images; full e2e gated by `NODE_VMM_WHP_FULL_E2E=1` | Available through local ARM64 OCI rootfs build; covered by `npm run test:macos-hvf` | +| Prebuilt rootfs boot | Available for x86_64 assets | Available for published x86_64 release assets; no WSL2 required when fetch/checksum succeeds or the cache already has the rootfs | Planned for future ARM64 assets; current x86_64 assets are intentionally skipped | +| Virtio block/rootfs | Available | Available with sparse overlay support | Available with sparse overlay support | +| Networking and port publishing | TAP/NAT by default, plus explicit libslirp user-mode networking and host forwarding | libslirp user-mode networking and host forwarding | libslirp user-mode networking and host forwarding by default; vmnet/socket_vmnet optional | +| Interactive console | UART/PTY helper | UART/PTY helper; idle CPU and guest Ctrl-C covered by WHP e2e | PL011 `/dev/ttyAMA0`; boot loading, TTY size, and guest Ctrl-C covered by HVF e2e | +| Guest entropy | `/dev/random`, `/dev/urandom`, host kernel RNG | plus virtio-rng `/dev/hwrng` on WHP | host kernel entropy path through the ARM64 guest | +| SMP | 1-64 vCPUs | 1-64 vCPUs | 1-64 vCPUs through PSCI | +| Sparse disk overlay | Available; protects base rootfs writes | Available; protects base rootfs writes | Available; protects base rootfs writes | +| Persistent rootfs writes | Available with explicit rootfs, `persist`, or `diskPath` and no sandbox | Available with explicit rootfs, `persist`, or `diskPath` and no sandbox | Available with explicit rootfs, `persist`, or `diskPath` and no sandbox | +| Existing disk grow | CLI/SDK `diskPath`/`persist` extend host files and request guest `resize2fs`; never shrink | CLI/SDK `diskPath`/`persist` extend host files and request guest `resize2fs`; never shrink | CLI/SDK `diskPath`/`persist` extend host files and request guest `resize2fs`; never shrink | +| Secondary attached disks | CLI/SDK/native support; read-write files persist, read-only files reject writes; fixture coverage depends on backend gate | CLI/SDK/native support; read-write files persist, read-only files reject writes; WHP has an env-gated e2e | CLI/SDK/native support; disks map after `/dev/vda`, net maps after disks | +| RAM/device restore | Native primitives only; real Linux restore still missing | Missing beyond dirty-page smoke | Missing beyond lifecycle controls | ## Practical Meaning -For real apps today, use a repo or local Dockerfile build, publish ports, and -keep the VM alive with `startVm()`: +For real apps today, Linux/KVM is still the most complete path: ```bash export NODE_VMM_KERNEL="$(node-vmm kernel fetch)" @@ -51,6 +86,63 @@ sudo node-vmm run \ --timeout-ms 0 ``` +On Windows, common `--image` runs try a prebuilt disk first and only need WSL2 +when the prebuilt is unavailable or the image is unsupported: + +```powershell +npm run build +node .\dist\src\main.js doctor +node .\dist\src\main.js run --image alpine:3.20 --sandbox --interactive --net auto --cpus 2 --mem 256 +``` + +To guarantee no WSL2 is involved, pass an existing rootfs: + +```powershell +node .\dist\src\main.js run --rootfs .\alpine.ext4 --sandbox --net none --cmd "echo no-wsl" +``` + +On macOS, use a signed Node binary for HVF and prefer a 512 MiB disk when +installing Node/npm inside Alpine: + +```bash +SIGNED_NODE="$HOME/.cache/node-vmm/node-hvf-signed" +KERNEL="$(node dist/src/main.js kernel fetch)" + +"$SIGNED_NODE" dist/src/main.js run \ + --image alpine:3.20 \ + --kernel "$KERNEL" \ + --interactive \ + --net auto \ + --disk 512 +``` + +For stateful SDK runs, use a named persistent root disk or an explicit disk +path; for extra data disks, attach existing files with `attachDisks`: + +```ts +await kvm.run({ + image: "node:22-alpine", + persist: "node-work", + diskSizeMiB: 2048, + attachDisks: [{ path: "./data.ext4" }], + cmd: "test -b /dev/vdb && echo attached", + net: "none", +}); +``` + +The same disk surface is available from the CLI: + +```bash +node-vmm run --image node:22-alpine --persist node-work --disk-size 2048 --cmd "node -v" +node-vmm run --rootfs ./root.ext4 --attach-disk ./data.ext4 --attach-disk-ro ./seed.ext4 --cmd "lsblk" +``` + +The practical write rule is: `--image` without `--persist`/`--disk-path` uses a +throwaway overlay, while `--rootfs`, `--persist`, and `--disk-path` without +`--sandbox` write to the root disk file. `--reset` recreates only stateful root +disks. Disk growth extends the host file and asks the guest to run +`resize2fs /dev/vda`; shrinking is rejected. + For code sandboxing today, `createSandbox().process.exec()` is useful and simple, but it is not the final sub-50ms design. The fast design is a live paused VM or native RAM snapshot plus a guest exec channel. diff --git a/docs/macos.md b/docs/macos.md new file mode 100644 index 0000000..acf9a1f --- /dev/null +++ b/docs/macos.md @@ -0,0 +1,303 @@ +# macOS HVF Backend + +macOS support is built around Apple Hypervisor.framework (HVF). The backend is +functional on Apple Silicon for ARM64 Linux guests using the QEMU `virt` machine +shape. It is still marked experimental until the same app fixture matrix as +Linux/KVM is promoted to a Darwin/HVF release gate. + +Current macOS surface: + +- The package can build, import, and pack on Apple Silicon macOS. +- The native Darwin addon uses Hypervisor.framework and can be shipped as + `prebuilds/darwin-arm64/node_vmm_native.node`. +- The Darwin prebuild bundles libslirp, glib, intl, and pcre2 dylibs when it is + present in the npm tarball. +- The tag release workflow builds and publishes a Darwin arm64 prebuild + alongside Linux x64 and Windows x64. +- `probeHvf()` reports HVF availability on arm64 hosts. +- `hvfFdtSmoke()`, `hvfPl011Smoke()`, and `hvfDeviceSmoke()` cover the local + FDT, PL011 UART, and emulated device shape. +- `node-vmm build --image ...` can create ARM64 ext4 rootfs images locally from + OCI layers using `mkfs.ext4 -d`; this path does not need Linux sudo. +- `node-vmm run --image alpine:3.20 --net auto --interactive` boots a real + Alpine ARM64 guest through HVF. +- macOS `--net auto` defaults to libslirp user-mode NAT with DNS and TCP host + forwarding. Optional vmnet/socket_vmnet paths remain gated behind env vars. +- The guest console is PL011 on `/dev/ttyAMA0`; interactive mode supports host + TTY sizing and sends `Ctrl-C` to the guest foreground process. +- The CLI prints boot progress while an interactive HVF guest is still silent. +- The init script applies the host UTC time early, which keeps Alpine `apk` TLS + certificate checks working even when the guest kernel does not expose a usable + RTC immediately. +- `--persist`, `--disk-path`, `--disk-size`, `--reset`, `--attach-disk`, and + `--attach-disk-ro` are wired through the CLI and SDK. Attached disks appear + after the root disk, starting at `/dev/vdb`. +- `npm run test:macos-hvf` covers the real ARM64 guest path end to end. + +Still missing for macOS release parity: + +- RAM/vCPU/device snapshot restore for sub-100ms warm starts. +- A live guest exec channel for `pause -> exec -> pause` workflows. +- Dockerfile `RUN` and repo rootfs builders on macOS. OCI image rootfs builds + work; Dockerfile and repo builds still require Linux. +- A promoted release gate for the full framework app matrix on macOS/HVF. +- x86 `microvm` parity. HVF intentionally targets the ARM64 QEMU `virt` subset, + while Linux/KVM and Windows/WHP keep the x86 `microvm` path. + +## Requirements + +Use an Apple Silicon macOS host with Hypervisor.framework available. + +Install: + +- Node.js 18.19 or newer. +- Xcode Command Line Tools. +- Homebrew packages for local builds: + +```bash +brew install e2fsprogs pkg-config libslirp glib +``` + +If `mkfs.ext4` is not in `PATH`, add Homebrew's e2fsprogs sbin directory: + +```bash +export PATH="$(brew --prefix e2fsprogs)/sbin:$PATH" +``` + +HVF requires the Node binary that loads the native addon to have the +`com.apple.security.hypervisor` entitlement. The macOS test runner creates an +ad-hoc signed copy at: + +```text +~/.cache/node-vmm/node-hvf-signed +``` + +Use that signed copy for manual HVF runs unless your normal Node binary is +already signed with the hypervisor entitlement. + +## Native HVF Gate + +Run the same shape locally on an Apple Silicon host: + +```bash +npm ci --ignore-scripts +npm run build +node dist/src/main.js doctor +npm run test:macos-hvf +``` + +`npm run test:macos-hvf` skips on non-Darwin or non-arm64 hosts. On Apple +Silicon it signs a private Node copy when needed, re-execs through that binary, +fetches or uses an ARM64 guest kernel, builds Alpine ARM64 rootfs images, and +boots real HVF guests. + +The gate validates: + +- `features()` and `doctor()` report the HVF backend. +- `probeHvf()` returns an available arm64 backend. +- ARM64 kernel resolution and checksum/magic handling. +- OCI rootfs build without sudo. +- batch command output through the PL011 console. +- interactive shell input, guest `Ctrl-C`, and host TTY size propagation. +- QEMU `virt` device-tree parity nodes. +- two-vCPU boot through PSCI. +- slirp IP/DNS/outbound networking. +- `apk update`, `apk add htop nodejs npm`, and Node execution in the guest. +- TCP port forwarding with pause/resume/stop through `startVm()`. +- optional vmnet networking when `NODE_VMM_HVF_TEST_VMNET=1`. + +## Manual Run + +Build once and let the test runner create the signed Node binary: + +```bash +npm run build +npm run test:macos-hvf +``` + +Then use the signed Node copy directly for manual runs: + +```bash +SIGNED_NODE="$HOME/.cache/node-vmm/node-hvf-signed" +KERNEL="$(node dist/src/main.js kernel fetch)" + +"$SIGNED_NODE" dist/src/main.js run \ + --image alpine:3.20 \ + --kernel "$KERNEL" \ + --interactive \ + --net auto \ + --disk 512 \ + --cpus 1 \ + --mem 256 +``` + +Inside the guest: + +```sh +date -u +apk update +apk add --no-cache nodejs npm +node -e "console.log(process.arch)" +wget -qO- http://example.com | head +``` + +For a custom ARM64 kernel, pass `--kernel /path/to/Image` or set +`NODE_VMM_KERNEL=/path/to/Image`. HVF expects an ARM64 Linux `Image`; Linux/KVM +and Windows/WHP expect x86_64 `vmlinux` ELF kernels. + +## Networking + +The default macOS path is: + +```bash +node-vmm run --image alpine:3.20 --net auto --interactive +``` + +On macOS/HVF, `auto` resolves to libslirp by default: + +| Guest field | Value | +| --- | --- | +| Address | `10.0.2.15/24` | +| Gateway | `10.0.2.2` | +| DNS | `10.0.2.3` | +| Interface | `eth0` | + +TCP publishing uses libslirp host forwarding: + +```bash +"$SIGNED_NODE" dist/src/main.js run \ + --image node:22-alpine \ + --kernel "$KERNEL" \ + --cmd "node -e \"require('node:http').createServer((_, r) => r.end('ok\\n')).listen(3000, '0.0.0.0')\"" \ + --net auto \ + -p 18080:3000 \ + --timeout-ms 0 + +curl http://127.0.0.1:18080 +``` + +Privileged macOS networking paths are still available for local experiments: + +```bash +NODE_VMM_HVF_NET_BACKEND=vmnet "$SIGNED_NODE" dist/src/main.js run ... + +NODE_VMM_HVF_NET_BACKEND=socket_vmnet \ +NODE_VMM_SOCKET_VMNET=/path/to/socket_vmnet \ +"$SIGNED_NODE" dist/src/main.js run ... +``` + +Use slirp for normal development and tests. vmnet/socket_vmnet setups depend on +host entitlements, root policy, or a separately managed socket_vmnet service. + +## Prebuilt rootfs and local build + +The release rootfs assets imported from the Windows/WHP work are Linux x86_64 +ext4 images. macOS/HVF runs ARM64 guests, so it intentionally does not consume +those x86_64 rootfs prebuilts. + +The current macOS rules are: + +1. `run --rootfs ./disk.ext4` boots the disk directly. There is no rootfs build + phase and no prebuilt download attempt. +2. `run --image alpine:3.20` checks the local prepared rootfs cache first. +3. On an ARM64 cache miss, HVF builds the rootfs locally from OCI layers with + `mkfs.ext4 -d`. +4. `prebuilt: "require"` / `--prebuilt require` fails on macOS/HVF until ARM64 + release assets exist for the selected image. +5. The rootfs cache key includes the target platform architecture, so x86_64 + KVM/WHP disks and ARM64 HVF disks do not collide. + +When Darwin arm64 rootfs assets are published later, the same prebuilt manifest +and checksum flow can be enabled for HVF without changing the runtime disk +contract. + +## Disk persistence, size, and reset + +The root disk is always a virtio-mmio block device exposed to the guest as +`/dev/vda`. The important question is which host file backs `/dev/vda`, and +whether writes go to that file or to a throwaway overlay. + +| Mode | Backing file | Guest writes | +| --- | --- | --- | +| `--rootfs PATH` | The exact ext4 file you pass | Persist in `PATH` unless `--sandbox`/`--restore` is enabled | +| `--image ...` | A cached base rootfs under `cacheDir/rootfs` | Go to a temporary sparse overlay by default, so the shared base stays clean | +| `--persist NAME` | `cacheDir/disks/NAME.ext4` | Persist in that named disk | +| `--disk-path PATH` | The exact ext4 file path you choose | Persist in `PATH` | + +Examples: + +```bash +# Named persistent root disk in the node-vmm cache. +"$SIGNED_NODE" dist/src/main.js run \ + --image alpine:3.20 \ + --kernel "$KERNEL" \ + --persist mac-work \ + --disk-size 1024 \ + --cmd "echo persisted > /root/marker" \ + --net none + +# Recreate the named disk from the current image. +"$SIGNED_NODE" dist/src/main.js run \ + --image alpine:3.20 \ + --kernel "$KERNEL" \ + --persist mac-work \ + --reset \ + --cmd "true" + +# Attach existing data disks after the root disk. +"$SIGNED_NODE" dist/src/main.js run \ + --rootfs ./root.ext4 \ + --kernel "$KERNEL" \ + --attach-disk ./data.ext4 \ + --attach-disk-ro ./seed.ext4 \ + --cmd "ls /sys/block && test -b /dev/vdb && test -b /dev/vdc" \ + --net none +``` + +HVF maps the root disk in virtio-mmio slot 0, extra disks in slots 1..N, and the +network device after the block devices. The native backend enforces the current +transport limit, so keep attached disk counts modest. + +## Backend Shape + +Implemented: + +- HVF VM lifecycle and guest RAM mapping. +- ARM64 Linux `Image` loading. +- QEMU `virt`-style device tree for the supported subset. +- GICv3 interrupt controller. +- PSCI CPU bring-up for SMP. +- PL011 UART for console and interactive PTY. +- PL031 RTC, PL061 GPIO, fw_cfg, gpio-keys, and an empty ECAM PCIe host node. +- virtio-mmio block devices with sparse overlay support. +- virtio-mmio net backed by libslirp, vmnet, or socket_vmnet depending on + configuration. +- host-stop, pause, resume, and stop controls through the native handle. + +Pending: + +- reusable RAM/vCPU/device restore state. +- live guest exec agent. +- broader device model parity beyond the Linux boot path. +- promoted framework app release gate on macOS/HVF. + +## macOS coverage matrix + +| Surface | Coverage | Notes | +| --- | --- | --- | +| Hosted package shape | `npm run build:ts`, `npm pack --dry-run --ignore-scripts` | Proves JS/package shape. | +| HVF probe | `probeHvf()` through `npm run test:macos-hvf` | Requires Apple Silicon and a signed Node binary. | +| Device/FDT smoke | `hvfFdtSmoke()`, `hvfPl011Smoke()`, `hvfDeviceSmoke()` in `test/native.test.ts` | Covers native device construction without a full guest boot. | +| Real Alpine HVF e2e | `npm run test:macos-hvf` | Builds ARM64 Alpine, boots HVF, validates console, SMP, slirp, `apk`, and lifecycle. | +| Slirp port forwarding | `npm run test:macos-hvf` | Starts a Node HTTP server in the guest and checks pause/resume over the forwarded port. | +| Optional vmnet | `NODE_VMM_HVF_TEST_VMNET=1 npm run test:macos-hvf` | For privileged/local vmnet setups only. | +| Pack shape | `npm run pack:check` | Validates Darwin dylibs when `prebuilds/darwin-arm64` is present. | + +## References + +- Apple Hypervisor.framework: + https://developer.apple.com/documentation/hypervisor +- QEMU ARM `virt` machine: + https://virtio-mem.gitlab.io/qemu/system/arm/virt.html +- QEMU `microvm` machine used by KVM/WHP: + https://qemu-project.gitlab.io/qemu/system/i386/microvm.html diff --git a/docs/performance.md b/docs/performance.md index 3c54a2c..2b46db8 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -236,6 +236,99 @@ Measured on 2026-04-28 with guest-written dirty pages: The full base RAM file is sparse. The per-request path should use the dirty delta, not a full RAM copy. +## Latest Windows WHP Local Result + +Measured on 2026-05-03 on the local Windows/WHP host with Node.js 24.15.0, the +fetched default guest kernel, one vCPU, `--net none` for command runs, and +temporary cache directories. Release prebuilt rootfs assets for `v0.1.3` were +not available during this run, so cold `--image` paths fell back to the WSL2 OCI +builder. Warm rows reuse the prepared rootfs cache. + +| Path | Image / command | Cache/build | Result | +| --- | --- | --- | ---: | +| Cold command | `alpine:3.20`, `tty`/`stty -F /dev/ttyS0` | miss, WSL2 OCI build | 12.280 s | +| Warm command | `alpine:3.20`, `true` | hit | 618-621 ms | +| Warm TTY sanity | `alpine:3.20`, `tty` + `/dev/ttyS0` check | hit | 779-819 ms | +| Direct lifecycle | `alpine:3.20`, `sleep 30` | already running | resume 1-2 ms | +| Plain Node HTTP | `node:22-alpine` app server | cold WSL2 OCI build | pause 8 ms, resume-to-HTTP 58 ms | + +The `tty` command is intentionally a batch-mode sanity check here. Batch runs +attach stdin to `/dev/null`, so `tty` prints `not a tty`; the same command then +checks that `/dev/ttyS0` exists and can be configured through `stty -F +/dev/ttyS0`. + +Raw WHP command/lifecycle sample: + +```json +{ + "date": "2026-05-03T15:29:20.371Z", + "platform": "win32/x64", + "image": "alpine:3.20", + "samples": [ + { + "label": "cold tty/stty", + "ms": 12280, + "exitReason": "guest-exit", + "runs": 1259, + "status": 0, + "output": ["not a tty", "ttyS0=yes", "TTY_OK"] + }, + { "label": "warm true 1", "ms": 618, "exitReason": "guest-exit", "runs": 1119, "status": 0 }, + { "label": "warm true 2", "ms": 618, "exitReason": "guest-exit", "runs": 1119, "status": 0 }, + { "label": "warm true 3", "ms": 621, "exitReason": "guest-exit", "runs": 1119, "status": 0 }, + { + "label": "warm tty/stty 1", + "ms": 815, + "exitReason": "guest-exit", + "runs": 1251, + "status": 0, + "output": ["not a tty", "ttyS0=yes", "TTY_OK"] + }, + { + "label": "warm tty/stty 2", + "ms": 819, + "exitReason": "guest-exit", + "runs": 1251, + "status": 0, + "output": ["not a tty", "ttyS0=yes", "TTY_OK"] + }, + { + "label": "warm tty/stty 3", + "ms": 779, + "exitReason": "guest-exit", + "runs": 1251, + "status": 0, + "output": ["not a tty", "ttyS0=yes", "TTY_OK"] + } + ], + "lifecycle": [ + { "i": 1, "pauseMs": 133, "resumeMs": 2, "state": "running" }, + { "i": 2, "pauseMs": 2, "resumeMs": 2, "state": "running" }, + { "i": 3, "pauseMs": 11, "resumeMs": 2, "state": "running" }, + { "i": 4, "pauseMs": 2, "resumeMs": 2, "state": "running" }, + { "i": 5, "pauseMs": 1, "resumeMs": 1, "state": "running" } + ], + "stopMs": 28, + "stopExitReason": "host-stop" +} +``` + +Raw WHP app smoke sample: + +```json +{ + "app": "plain-node", + "totalMs": 32993, + "bootToHttpMs": 3231, + "resumeToHttpMs": 58, + "pauseMs": 8, + "pausedHttpBlocked": true, + "stopExitReason": "host-stop", + "htmlMarker": "plain-node-ok", + "resumedMarker": "plain-node-ok" +} +``` + ## Latest Controlled Pause/Resume Result Measured on 2026-04-28 with `node:22-alpine`, a guest HTTP server on port 3000, diff --git a/docs/publishing.md b/docs/publishing.md index b2e50d1..9f0b54f 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,9 +1,16 @@ # Publishing The package publishes as `@misaelzapata/node-vmm` under MIT. It is ESM-only and -exposes the `node-vmm` CLI through `package.json#bin`. Linux x64 packages include -a native KVM prebuild; unsupported or forced native builds fall back to -`node-gyp`. +exposes the `node-vmm` CLI through `package.json#bin`. Release tarballs include +Node-API native prebuilds for Linux x64, Windows x64, and Darwin arm64, plus +the Linux in-guest console helper. Darwin arm64 prebuilds bundle the libslirp +and glib dylibs they need at runtime. Unsupported platforms or forced native +builds fall back to `node-gyp`. + +Rootfs prebuilts are different from native prebuilts. Rootfs disks are not stored +inside the npm package; they are compressed GitHub Release assets such as +`alpine-3.20.ext4.gz` plus a manifest, and the runtime downloads them into the +local rootfs cache on demand. ## Checklist @@ -19,19 +26,35 @@ npm run pack:check npm publish --dry-run --ignore-scripts --access public ``` -`npm run release:check` runs the full checklist and verifies that the `@misaelzapata/node-vmm` -package name is still available in the registry before publishing. Run it on a -self-hosted Linux/KVM release machine with `/dev/kvm`, passwordless `sudo -n`, -network access, Node 20.19 or newer, and `NODE_VMM_KERNEL` set. If you launch -it through `sudo`, preserve `PATH` so the same Node/npm toolchain is used. -When run as a normal user, `release:check` invokes only `test:real-apps` -through `sudo -n env PATH="$PATH" ...` because rootfs Dockerfile builds need -mount privileges. +`npm run release:check` runs the full Linux/KVM checklist and verifies that the +`@misaelzapata/node-vmm` package version is still available in the registry +before publishing. Run it on a self-hosted Linux/KVM release machine with +`/dev/kvm`, passwordless `sudo -n`, network access, Node 20.19 or newer, and +`NODE_VMM_KERNEL` set. If you launch it through `sudo`, preserve `PATH` so the +same Node/npm toolchain is used. When run as a normal user, `release:check` +invokes only `test:real-apps` through `sudo -n env PATH="$PATH" ...` because +rootfs Dockerfile builds need mount privileges. + +Tag publishing is handled by `.github/workflows/release-prebuilds.yml`: -Publish with: +1. Build `prebuilds/linux-x64/` on Ubuntu. +2. Build `prebuilds/win32-x64/` on Windows. +3. Build `prebuilds/darwin-arm64/` on an Apple Silicon macOS runner. +4. Attach all prebuild directories as GitHub Release tarballs. +5. Reassemble all directories in a clean Ubuntu publish job. +6. Run `NODE_VMM_PACK_REQUIRE_WIN32=1 NODE_VMM_PACK_REQUIRE_DARWIN=1 node scripts/pack-check.mjs`. +7. Run `npm publish --ignore-scripts --access public`. + +The publish job requires the `NPM_TOKEN` repository secret. Manual local +publishes are possible, but the local `prepack` only builds the current host's +prebuild. Before a manual publish, restore all release prebuild directories +under `prebuilds/` and run: ```bash -npm publish --access public +npm run build:ts +NODE_VMM_PACK_REQUIRE_WIN32=1 NODE_VMM_PACK_REQUIRE_DARWIN=1 node scripts/pack-check.mjs +npm publish --dry-run --ignore-scripts --access public +npm publish --ignore-scripts --access public ``` ## Package Contents @@ -40,8 +63,20 @@ npm publish --access public - `dist/src` - `prebuilds/linux-x64/node_vmm_native.node` -- `native/kvm_backend.cc` -- `native/whp_backend.cc` +- `prebuilds/linux-x64/node-vmm-console` +- `prebuilds/win32-x64/node_vmm_native.node` +- `prebuilds/win32-x64/libslirp-0.dll` +- `prebuilds/win32-x64/libglib-2.0-0.dll` +- the remaining Windows libslirp dependency DLLs staged by + `scripts/package-prebuild.mjs` +- `prebuilds/darwin-arm64/node_vmm_native.node` +- `prebuilds/darwin-arm64/libslirp.0.dylib` +- `prebuilds/darwin-arm64/libglib-2.0.0.dylib` +- `prebuilds/darwin-arm64/libintl.8.dylib` +- `prebuilds/darwin-arm64/libpcre2-8.0.dylib` +- `native/kvm/backend.cc` +- `native/whp/backend.cc` +- `native/hvf/backend.cc` - `guest/node-vmm-console.cc` - `scripts/build-native.mjs` - `scripts/package-prebuild.mjs` diff --git a/docs/sdk.md b/docs/sdk.md index 4822efd..15ed18c 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -3,9 +3,12 @@ `node-vmm` exposes an ESM SDK for TypeScript and JavaScript. The high-level API uses safe defaults and cleans temporary rootfs/cache artifacts it owns. -The current npm runtime is Linux/KVM. VM execution expects `/dev/kvm`, a Linux -guest kernel, and root privileges for rootfs mounting and network setup. In CI, -run release/e2e commands with passwordless `sudo -n`. +Linux/KVM and Windows/WHP are the supported x64 host backends. VM execution +expects a Linux guest kernel. Linux/KVM uses `/dev/kvm` and needs root +privileges for rootfs mounting and automatic TAP/NAT setup. Explicit +`net: "slirp"` uses user-mode networking when the Linux native addon was built +with libslirp. Windows/WHP runs the VM as the current Windows user and only uses +WSL2 when it must build a fresh rootfs locally from OCI layers. ## Running Without Sudo @@ -35,8 +38,9 @@ const result = await kvm.runCode({ console.log(result.guestOutput); ``` -Root is still required when the SDK builds/mounts rootfs images or creates -automatic TAP/NAT networking. +Root is still required on Linux when the SDK builds/mounts rootfs images or +creates automatic TAP/NAT networking. Use `net: "slirp"` with a prepared rootfs +to avoid TAP/iptables setup. Windows/WHP does not use Linux `sudo`. ## Simple API @@ -84,11 +88,13 @@ const kvm = createNodeVmmClient({ `cacheDir` stores downloaded OCI blobs and prepared ext4 rootfs images. Repeated `runImage({ image: ... })` calls reuse the prepared rootfs when the image, -disk size, platform, build args, env, workdir, and init mode match. Commands are -injected at boot, so different `cmd` values can reuse the same cached image. +disk size, platform architecture, build args, env, entrypoint, workdir, and +Dockerfile RUN timeout match. Commands are injected at boot, so different `cmd` +values can reuse the same cached image. Cached rootfs runs automatically use a temporary overlay so guest writes do not change the cached base disk. Custom `entrypoint` overrides currently use a -one-off rootfs. +one-off rootfs. On Windows, supported images can also populate this cache from a +prebuilt GitHub Release rootfs before falling back to the WSL2 OCI builder. ### Kernel Helpers @@ -109,6 +115,41 @@ const kernel = await fetchGocrackerKernel(); process.env.NODE_VMM_KERNEL = kernel.path; ``` +### Windows Prebuilt Rootfs + +On Windows, `run()` and `runCode()` use the same cache/prebuilt/fallback path as +the CLI. For `alpine:3.20`, `node:20-alpine`, `node:22-alpine`, and +`oven/bun:1-alpine`, a cold cache tries to download the package-versioned +release asset first: + +```ts +import kvm from "@misaelzapata/node-vmm"; + +const result = await kvm.run({ + image: "alpine:3.20", + cmd: "uname -a", + memory: 256, + net: "none", +}); + +console.log(result.guestOutput); +``` + +If the prebuilt asset is available and the checksum verifies, no WSL2 process is +spawned. If the image is unsupported, the asset is missing, or the fetch fails, +the SDK falls back to the WSL2 OCI rootfs builder when WSL2 is installed. + +To guarantee the no-WSL path, pass an existing ext4 disk: + +```ts +await kvm.run({ + rootfsPath: "C:\\vms\\alpine.ext4", + cmd: "echo no-wsl", + memory: 256, + net: "none", +}); +``` + ### `kvm.build(options)` Builds an ext4 rootfs from an OCI image or a Dockerfile. Dockerfile builds are @@ -219,6 +260,156 @@ console.log(result.restored); // true Use `overlayPath` plus `keepOverlay: true` only when debugging the native disk layer; normal sandbox runs create and delete their own overlay. +### Disk Persistence, Reset, And Size + +The stable SDK exposes one root disk, optional persistent root-disk +materialization, optional copy-on-write overlays, and attached data disks. + +Persistent rootfs writes: + +```ts +await kvm.run({ + rootfsPath: "./stateful.ext4", + cmd: "echo persisted > /var/lib/node-vmm-marker", + sandbox: false, + net: "none", +}); +``` + +Named persistent root disk under `cacheDir/disks`: + +```ts +await kvm.run({ + image: "node:22-alpine", + persist: "node-work", + diskSizeMiB: 2048, + cmd: "npm --version > /root/npm-version.txt", + net: "none", +}); +``` + +With `persist: "node-work"`, the SDK creates or reuses +`cacheDir/disks/node-work.ext4` and keeps `cacheDir/disks/node-work.json` +metadata next to it. The metadata records the source image/rootfs and relevant +build options. Reusing the same name with a different source fails until you +pass `reset: true`, which prevents old state from being attached to the wrong +guest. + +Explicit persistent root disk path: + +```ts +await kvm.run({ + image: "alpine:3.20", + diskPath: "./work.ext4", + diskSizeMiB: 1024, + cmd: "echo persisted > /root/marker", + net: "none", +}); +``` + +`diskPath` is useful when your application wants to own the disk file location +instead of letting node-vmm store it under `cacheDir/disks`. It behaves like +`persist`: node-vmm creates the disk from the selected source on first use, +boots it as writable `/dev/vda`, and reuses it on later runs. It cannot be +combined with `persist` or `rootfsPath`. + +Reset-on-exit writes: + +```ts +await kvm.run({ + rootfsPath: "./stateful.ext4", + cmd: "echo temporary > /var/lib/node-vmm-marker", + sandbox: true, + net: "none", +}); +``` + +Cached and prebuilt `image` runs also use an overlay automatically so the shared +base rootfs is not mutated unless you opt into `persist` or `diskPath`. Use +`disk`, `diskMiB`, or `diskSizeMiB` when building, first caching an image, or +materializing a persistent root disk: + +```ts +await kvm.build({ + image: "node:22-alpine", + output: "./node22.ext4", + diskMiB: 1024, +}); +``` + +Existing persistent root disks can grow but cannot shrink. When a persistent +root disk grows and the guest has `resize2fs`, node-vmm asks the guest to resize +`/dev/vda` on boot. `reset: true` recreates a `persist` or `diskPath` root disk +from the current source; it is separate from `sandbox: true`, which resets by +discarding an overlay after the VM exits. + +The write path is: + +- `rootfsPath` without `sandbox`, `persist`, and `diskPath` write directly to + the root disk backing file. +- `image` without `persist` or `diskPath` writes to a temporary sparse overlay, + protecting the cached/prebuilt base rootfs. +- `sandbox: true` or `restore: true` always uses a reset-on-exit overlay. +- `reset: true` only recreates stateful root disks from `persist` or `diskPath`; + it does not mean "discard this run's overlay". + +The resize path is: + +- The host file is extended first when the requested size is larger. +- Shrinking fails. +- A grown root disk boots with `node_vmm.resize_rootfs=1`; the guest init script + runs `resize2fs /dev/vda` when available. +- Attached disks are not resized by node-vmm. + +### Attach Disks + +Secondary data disks are available through `attachDisks`. Each disk must already +exist as a host file. node-vmm does not create, format, partition, or mount data +disks for the guest; it only maps them as virtio block devices after the root +disk. The root disk is `/dev/vda`, so attached disks start at `/dev/vdb`. +Read-write attached disks write directly to their host file. Read-only attached +disks reject guest writes with a block I/O error. + +```ts +const result = await kvm.run({ + rootfsPath: "./alpine.ext4", + attachDisks: [ + { path: "./data.ext4" }, + { path: "./reference.ext4", readonly: true }, + ], + cmd: "lsblk && test -b /dev/vdb && test -b /dev/vdc", + net: "none", +}); + +console.log(result.attachedDisks); +``` + +Up to 16 data disks can be attached. `result.attachedDisks` and live VM handles +report the resolved host path, read-only flag, and guest device name. + +CLI/SDK parity for the current disk surface is: + +| Concept | CLI | SDK | +| --- | --- | --- | +| Existing rootfs | `--rootfs ./disk.ext4` | `rootfsPath: "./disk.ext4"` | +| Named persistent root disk | `--persist work` | `persist: "work"` | +| Explicit persistent root disk | `--disk-path ./work.ext4` | `diskPath: "./work.ext4"` | +| Recreate persistent root disk | `--reset` | `reset: true` | +| Build/cold-cache/grow size | `--disk-size 1024` or legacy `--disk 1024` | `disk: 1024`, `diskMiB: 1024`, or `diskSizeMiB: 1024` | +| Reset overlay | `--sandbox` / `--restore` | `sandbox: true` / `restore: true` | +| Explicit overlay | `--overlay ./run.overlay` | `overlayPath: "./run.overlay"` | +| Keep overlay | `--keep-overlay` | `keepOverlay: true` | +| Attached data disks | `--attach-disk ./data.ext4` | `attachDisks: [{ path: "./data.ext4" }]` | +| Read-only data disks | `--attach-disk-ro ./seed.ext4` | `attachDisks: [{ path: "./seed.ext4", readonly: true }]` | + +CLI examples that match the SDK snippets above: + +```bash +node-vmm run --image node:22-alpine --persist node-work --disk-size 2048 --cmd "npm --version" +node-vmm run --image alpine:3.20 --disk-path ./work.ext4 --disk-size 1024 --cmd "echo persisted" +node-vmm run --rootfs ./alpine.ext4 --attach-disk ./data.ext4 --attach-disk-ro ./reference.ext4 --cmd "lsblk" +``` + For the lowest-latency JS workflow today, build once and run many: ```ts diff --git a/docs/testing.md b/docs/testing.md index 1f289fd..3b88ab3 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -13,6 +13,18 @@ npm run test:real-apps npm run bench:node-code ``` +`npm test` is the local all-in-one smoke: it rebuilds native + TypeScript and +runs every deterministic test file in `dist/test`. On Windows/WHP this includes +the native WHP probe, tiny guests, lifecycle, SMP, UART, virtio-blk, prebuilt +manifest tests, CLI/SDK disk parsing, and docs-backed attach-disk contract +tests. Environment-gated tests are listed in the output as skipped until their +kernel/rootfs fixtures are provided. + +The 100% badge refers to the strict `c8 --100` TypeScript coverage gate below. +Native C++ coverage is separate and backend-specific; Linux/KVM uses gcov, while +Windows/WHP and macOS/HVF currently rely on smoke/e2e gates plus self-hosted or +local hardware runner matrices. + ## TypeScript `npm run test:coverage:ts` runs `c8 --100` against deterministic TypeScript @@ -28,12 +40,16 @@ host/kernel race or e2e-only path it excludes. ## Native C++ `npm run test:coverage:cpp` rebuilds the addon with gcov flags, runs native KVM -smoke tests, and checks `native/kvm_backend.cc` with `scripts/check-gcov.mjs`. +smoke tests, and checks `native/kvm/backend.cc` with `scripts/check-gcov.mjs`. Uncovered native lines must be listed in `docs/cpp-coverage-exclusions.json` with a reason. This keeps kernel, errno, ioctl, and hardware-only defensive branches visible instead of silently ignoring them. +On macOS/HVF, deterministic native coverage is smoke-based: `test/native.test.ts` +validates `probeHvf()`, FDT generation, PL011 behavior, and the emulated device +shape. The real guest gate is `npm run test:macos-hvf`. + ## E2E `npm run test:e2e` requires: @@ -66,7 +82,9 @@ node dist/src/main.js run \ --net none ``` -Rootless runs must avoid rootfs building/mounting and `--net auto`. +Rootless runs must avoid rootfs building/mounting and `--net auto`. `--net +slirp` avoids TAP/iptables setup when the Linux native addon was built with +libslirp. ## Port Publishing Smoke @@ -85,6 +103,19 @@ sudo -n node dist/src/main.js run \ curl http://127.0.0.1:18080 ``` +Linux/KVM also supports explicit libslirp user-mode networking: + +```bash +sudo -n node dist/src/main.js run \ + --image node:22-alpine \ + --cmd "node -e \"require('node:http').createServer((_, r) => r.end('ok\\n')).listen(3000, '0.0.0.0')\"" \ + --net slirp \ + -p 18081:3000 \ + --timeout-ms 30000 + +curl http://127.0.0.1:18081 +``` + `-p 3000`, `-p 18080:3000`, and `-p 127.0.0.1:18080:3000/tcp` follow Docker's common TCP publish form. UDP and publish-all are not implemented in the current Linux/KVM release. @@ -167,6 +198,120 @@ The script uses only temporary npm and OCI caches under removes generated apps, rootfs disks, caches, and overlays in `finally`, with a `sudo -n rm -rf` fallback for root-owned build artifacts. +## Windows WHP Framework App Smoke + +`npm run test:whp-apps` is the Windows/WHP runtime counterpart to the Linux +real-app gate. It uses `node:22-alpine`, creates the same framework apps inside +the guest, publishes guest port `3000` through WHP/libslirp, waits for HTTP, +pauses the VM, verifies HTTP blocks while paused, resumes the VM, verifies HTTP +again, and stops the VM. + +```powershell +npm run test:whp-apps +``` + +It covers: + +- plain Node HTTP +- Express +- Fastify +- official Next.js hello-world via `create-next-app@16.2.4` +- Vite React via `create-vite@9.0.6 --template react` +- Vite Vue via `create-vite@9.0.6 --template vue` + +Use `NODE_VMM_WHP_APP_CASES=express,fastify` to run a subset while debugging. +This smoke proves the WHP VM runtime, port publishing, guest networking, and +pause/resume behavior for the same app families as Linux. It does not replace +the Linux Dockerfile builder gate; custom Dockerfile and repo rootfs builds on +Windows intentionally continue to use the Linux/WSL2 builder path. + +## macOS HVF E2E + +`npm run test:macos-hvf` is the Apple Silicon runtime gate. It runs only on +macOS arm64, signs a private Node copy with the +`com.apple.security.hypervisor` entitlement when needed, and re-execs through +that signed binary. + +```bash +brew install e2fsprogs pkg-config libslirp glib +npm run test:macos-hvf +``` + +Set `NODE_VMM_KERNEL=/path/to/arm64/Image` to use a local guest kernel instead +of the fetched default ARM64 kernel. + +It covers: + +- `features()`, `doctor()`, and `probeHvf()` on a real HVF host. +- ARM64 kernel resolution. +- ARM64 Alpine rootfs creation from OCI layers without sudo. +- batch command output through PL011 `/dev/ttyAMA0`. +- interactive PTY shell input, host TTY sizing, and guest `Ctrl-C` delivery. +- QEMU `virt` device-tree nodes used by the HVF subset. +- two-vCPU boot through PSCI. +- Slirp IP/DNS/outbound networking. +- `apk update`, `apk add --no-cache htop nodejs npm`, and Node execution. +- `node:22-alpine` `runCode()` on ARM64. +- Slirp TCP port forwarding with `startVm()`, pause, resume, and stop. + +Optional vmnet coverage is gated because it depends on host networking policy: + +```bash +NODE_VMM_HVF_TEST_VMNET=1 npm run test:macos-hvf +``` + +For manual package-install timing on this host, the current Alpine ARM64 path +with `--disk 312` completed `apk update` in about 8 seconds and +`apk add --no-cache nodejs npm` in about 12 seconds. Use `--disk 512` as the +normal development default so package caches and larger experiments have room. + +## macOS Coverage Matrix + +| Surface | Command or test | What it proves | +| --- | --- | --- | +| HVF probe and host capability | `npm run test:macos-hvf` | `features()`, `doctor()`, and `probeHvf()` report an available arm64 HVF backend. | +| Native device shape | `node --test dist/test/native.test.js` | `hvfFdtSmoke()`, `hvfPl011Smoke()`, and `hvfDeviceSmoke()` validate FDT, UART, and device construction. | +| Real Alpine HVF e2e | `npm run test:macos-hvf` | ARM64 rootfs build, boot, console, SMP, Slirp, `apk`, Node install, and lifecycle. | +| Slirp port forwarding | `npm run test:macos-hvf` | Guest HTTP published through libslirp remains correct across pause/resume. | +| Optional vmnet path | `NODE_VMM_HVF_TEST_VMNET=1 npm run test:macos-hvf` | Exercises the privileged vmnet/socket_vmnet alternative when the host is configured for it. | +| Darwin package shape | `npm run pack:check` | Validates Darwin dylib bundling when `prebuilds/darwin-arm64` is present. | + +## Windows Coverage Matrix + +| Surface | Command or test | What it proves | +| --- | --- | --- | +| Hosted Windows package shape | `NODE_VMM_SKIP_NATIVE=1 npm ci --ignore-scripts`, `npm run build:ts`, `npm pack --dry-run --ignore-scripts` | Windows can install/import/pack the JS package without native WHP execution. | +| WHP probe and dirty tracking | `node --test .\dist\test\native.test.js` | `probeWhp()` and `whpSmokeHlt()` can create a partition, run a tiny guest, and query dirty pages. | +| WHP lifecycle and SMP smoke | `node --test .\dist\test\native.test.js` | Generated ELF guests cover run, pause/resume/stop, and multi-vCPU control. | +| Real Alpine WHP e2e | `$env:NODE_VMM_WHP_FULL_E2E = "1"; node --test .\dist\test\native.test.js` | Alpine rootfs boot, virtio block overlay, Slirp DNS/networking, TCP-ready runtime pieces, RNG, clock, `apk`, console idle CPU, and guest Ctrl-C. | +| Framework runtime smoke | `npm run test:whp-apps` | Plain Node, Express, Fastify, Next.js, Vite React, and Vite Vue run inside WHP through `node:22-alpine` with HTTP plus pause/resume checks. | +| Prebuilt rootfs slug parity | `prebuiltSlugForImage maps the published images and rejects others` in `test/unit.test.ts` | Supported image refs stay aligned with release asset names. | +| Prebuilt fallback | `tryFetchPrebuiltRootfs returns fetched:false on missing release` in `test/unit.test.ts` | Missing release assets do not throw or leave partial disks; callers can fall back to WSL2. | +| No-WSL cache hit | `buildOrReuseRootfs cache hit returns the cached path without rebuilding (no WSL2 spawn)` in `test/unit.test.ts` | Prepared rootfs cache hits return before any builder path. | +| No-WSL explicit rootfs | `runImage with --rootfs never enters the build pipeline` in `test/unit.test.ts` | `rootfsPath`/`--rootfs` never invokes WSL2 or rootfs creation. | +| Prebuilt release asset production | `.github/workflows/prebuilt-rootfs.yml` | Builds `alpine:3.20`, `node:20-alpine`, `node:22-alpine`, and `oven/bun:1-alpine` ext4 assets and uploads `.ext4.gz` plus `.ext4.manifest.json`. | +| Attach/secondary disks | `NODE_VMM_WHP_ATTACH_DISKS_E2E=1 node --test .\dist\test\native.test.js` plus unit validation | WHP e2e maps a data disk after `/dev/vda` and verifies raw guest writes persist to the host file. | + +Recommended local Windows verification after touching WHP/disk code: + +```powershell +npm test +npm run test:coverage:ts +node --check .\scripts\build-prebuilt-rootfs.mjs +node --check .\scripts\package-prebuild.mjs +git diff --check +``` + +Run the fixture-gated WHP rootfs checks when you have a kernel and ext4 rootfs: + +```powershell +$env:NODE_VMM_WHP_E2E_KERNEL = "C:\path\to\vmlinux" +$env:NODE_VMM_WHP_E2E_ROOTFS = "C:\path\to\alpine.ext4" +$env:NODE_VMM_WHP_FULL_E2E = "1" +$env:NODE_VMM_WHP_ATTACH_DISKS_E2E = "1" +node --test .\dist\test\native.test.js +``` + ## Node Code Benchmark `npm run bench:node-code` builds `node:22-alpine`, runs JavaScript through diff --git a/docs/windows.md b/docs/windows.md index bf00d7b..80cc9b4 100644 --- a/docs/windows.md +++ b/docs/windows.md @@ -1,65 +1,168 @@ # Windows WHP Backend -`node-vmm` keeps the public JS API stable while Windows support is being built. -Linux/KVM is the current npm release runtime. Windows Hypervisor Platform support -is work in progress and lives behind `native/whp_backend.cc`. +Windows support is built around Windows Hypervisor Platform (WHP). The backend +is now functional for local Alpine rootfs guests, but it is still marked +experimental until the self-hosted Windows release gate covers the same app +fixtures as Linux/KVM and RAM/device snapshot restore lands. -```ts -import { probeWhp, whpSmokeHlt } from "@misaelzapata/node-vmm/kvm"; +Current Windows surface: -const probe = probeWhp(); -console.log(probe); +- The package can build, import, and pack on hosted Windows without native code. +- The native Windows addon loads `WinHvPlatform.dll` dynamically. +- `probeWhp()` reports WHP presence, partition setup, and dirty-page capability. +- `whpSmokeHlt()` creates a WHP partition, maps RAM with dirty tracking, runs a + tiny x86 guest to `hlt`, and checks the dirty bitmap. +- `node-vmm build --image ...` can create ext4 rootfs images through WSL2. +- `node-vmm run --image alpine:3.20` first tries a release prebuilt rootfs + (`alpine-3.20.ext4.gz` plus `alpine-3.20.ext4.manifest.json`) before falling + back to WSL2. +- `node-vmm run --image alpine:3.20 --net auto --interactive` boots a real + Alpine guest through WHP. +- virtio-mmio block, net, and rng are implemented. WHP networking uses libslirp + user-mode NAT and host forwarding. +- `startVm().pause()`, `resume()`, `stop()`, SMP, sparse overlays, `apk add`, + guest Ctrl-C, and idle interactive console CPU are covered by env-gated WHP + tests. +- `--persist`, `--disk-path`, `--disk-size`, `--reset`, `--attach-disk`, and + `--attach-disk-ro` are wired through the CLI and SDK for stateful root disks + and secondary data disks. +- `npm run test:whp-apps` covers the same app families as the Linux gate + (plain Node, Express, Fastify, Next.js, Vite React, and Vite Vue) by building + them inside a `node:22-alpine` guest and checking HTTP plus pause/resume. -if (probe.available) { - console.log(whpSmokeHlt()); -} -``` +Still missing for Windows release parity: -## Current State +- RAM/vCPU/device snapshot restore for sub-100ms warm starts. +- A live guest exec channel for `pause -> exec -> pause` workflows. +- Dockerfile and repo rootfs builders on Windows. OCI image rootfs builds use + prebuilt assets when available and otherwise fall back to WSL2. +- The full Linux/KVM app-server fixture matrix on self-hosted Windows CI. +- Self-hosted release enforcement for Windows native and rootfs prebuild assets. -- `binding.gyp` selects `native/kvm_backend.cc` on Linux and - `native/whp_backend.cc` on Windows. -- `scripts/build-native.mjs` builds native code on Linux and Windows, and skips - only unsupported host platforms. -- The Windows addon loads `WinHvPlatform.dll` dynamically so import can succeed - even when the Windows Hypervisor Platform feature is not enabled. -- `probeWhp()` is prepared to check hypervisor presence, dirty-page tracking, - `WHvQueryGpaRangeDirtyBitmap`, partition creation, and partition setup. -- `whpSmokeHlt()` is the first native WHP smoke target: create a partition, map - guest RAM with dirty tracking, run a tiny x86 guest until `hlt`, query the - dirty bitmap, and report timing. -- CI is configured to compile the Windows addon on `windows-latest` and run a - probe smoke once pushed to GitHub. -- A `workflow_dispatch` self-hosted Windows gate runs `node-vmm doctor` and - `whpSmokeHlt()` on a machine labelled `windows`, `x64`, `whp`. +## Requirements -## Windows Setup +Use an x64 Windows host with hardware virtualization enabled in firmware. If the +machine is itself a VM, the provider must expose nested virtualization. -Enable Windows Hypervisor Platform, then restart: +Enable Windows Hypervisor Platform, then reboot: ```powershell Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform ``` -Install Node.js 18.19+ and Visual Studio Build Tools with the Windows SDK, then: +Install: + +- Node.js 18.19 or newer. +- The npm release includes a Windows x64 native prebuild and its libslirp DLLs. +- Visual Studio Build Tools, a Windows SDK compatible with `WinHvPlatform.h`, + Python, and the usual `node-gyp` prerequisites are only needed when forcing or + falling back to a local native build. +- WSL2 with `truncate`, `mkfs.ext4`, `mount`, `umount`, and `python3` for + Windows OCI rootfs fallback builds when no cached or prebuilt rootfs is + available. + +## Hosted Windows CI + +The hosted Windows job is JS-only by design. GitHub hosted Windows runners are +not the WHP release gate, so native build and execution are skipped: + +```yaml +hosted-windows-js: + runs-on: windows-latest + env: + NODE_VMM_SKIP_NATIVE: "1" +``` + +Equivalent local commands: + +```powershell +$env:NODE_VMM_SKIP_NATIVE = "1" +npm ci --ignore-scripts +npm run build:ts +npm pack --dry-run --ignore-scripts +``` + +## Native WHP Gate + +Native WHP validation belongs on a self-hosted Windows runner labelled: + +```yaml +runs-on: [self-hosted, windows, x64, whp] +``` + +Run the same shape locally on a WHP-capable host: ```powershell -npm ci +npm ci --ignore-scripts npm run build -node -e "import('./dist/src/kvm.js').then((m) => console.log(m.probeWhp()))" +node .\dist\src\main.js doctor +node --test .\dist\test\native.test.js ``` -For release validation on a WHP-capable Windows host: +The default native test file covers WHP probe shape, HLT dirty-page smoke, +generated ELF run/lifecycle smoke, generated multi-vCPU smoke, and validation +that the WHP run contract is exposed. The larger real-rootfs tests are opt-in: + +```powershell +$env:NODE_VMM_WHP_FULL_E2E = "1" +node --test .\dist\test\native.test.js +Remove-Item Env:NODE_VMM_WHP_FULL_E2E +``` + +Those tests build Alpine through WSL2 and validate: + +- clock progress through `sleep` +- virtio-rng through `/dev/hwrng` +- libslirp DNS/networking with `ping` +- `apk add --no-cache htop` +- `startVm()` pause/resume/stop +- interactive console idle CPU +- Ctrl-C delivered to the guest instead of stopping the host process +- no early `__common_interrupt ... No irq handler` warning + +## Local Performance Snapshot + +Measured on 2026-05-03 on the local Windows/WHP host with Node.js 24.15.0 and +the fetched default guest kernel: + +| Scenario | Result | +| --- | ---: | +| `alpine:3.20` cold `tty`/`stty -F /dev/ttyS0` command, WSL2 OCI fallback | 12.280 s | +| `alpine:3.20` warm cached `true` command | 618-621 ms | +| `alpine:3.20` warm cached TTY sanity command | 779-819 ms | +| Direct `resume()` on an already-running WHP VM | 1-2 ms | +| `node:22-alpine` plain Node HTTP app `pause()` / resume-to-HTTP | 8 ms / 58 ms | + +The TTY sanity command runs in batch mode, so `tty` correctly prints `not a +tty`; the useful assertion is that `/dev/ttyS0` exists and accepts +`stty -F /dev/ttyS0`. Interactive console behavior is covered separately by the +env-gated WHP interactive test. + +## Manual Run ```powershell npm run build -node -e "import('./dist/src/index.js').then(async (m) => { const r = await m.doctor(); console.log(r); process.exit(r.ok ? 0 : 1); })" -node -e "import('./dist/src/kvm.js').then((m) => console.log(m.whpSmokeHlt()))" +node .\dist\src\main.js run ` + --image alpine:3.20 ` + --sandbox ` + --interactive ` + --net auto ` + --cpus 2 ` + --mem 256 +``` + +Inside the guest: + +```sh +apk add --no-cache htop +cat /sys/devices/virtual/misc/hw_random/rng_available +dd if=/dev/hwrng bs=16 count=1 | wc -c +ping google.com ``` ## Backend Shape -WHP maps onto the same native boundary as KVM: +Implemented: - partition lifecycle: `WHvCreatePartition`, `WHvSetupPartition`, `WHvDeletePartition` @@ -68,34 +171,200 @@ WHP maps onto the same native boundary as KVM: - vCPU loop: `WHvCreateVirtualProcessor`, `WHvRunVirtualProcessor` - register state: `WHvGetVirtualProcessorRegisters`, `WHvSetVirtualProcessorRegisters` -- exits: memory access, I/O port access, halt, MSR, CPUID, exceptions +- ELF kernel loading plus Linux boot parameters for x86_64 `vmlinux` +- ACPI/MP tables, PIT, HPET, PM timer, PIC, IOAPIC, and WHP LAPIC delegation +- CPUID/MSR exits needed for the current Linux boot path +- APIC EOI exit handling for IOAPIC level-triggered flow +- COM1 UART, node-vmm guest-exit port, and interactive PTY helper +- virtio-mmio block read/write/flush/get-id with optional sparse overlay +- virtio-mmio net backed by libslirp +- virtio-mmio rng backed by Windows CNG -The fast sandbox design stays the same on both platforms: boot a prepared parent, -pause at the guest command agent, snapshot RAM/vCPU/device state, restore with -copy-on-write memory and a fresh writable disk overlay, execute code, return -stdout/stderr/status to JS. +Pending: -## CI +- `WhvSavePartitionState` / `WhvRestorePartitionState` or equivalent reusable + RAM/vCPU/device restore path +- full serial modem/error/loopback behavior +- live guest exec agent +- app/server e2e fixtures equivalent to the Linux/KVM gate -The default GitHub Windows job is intentionally able to run on hosted runners: +## Milestones -```yaml -windows: - runs-on: windows-latest - env: - NODE_VMM_SKIP_NATIVE: "1" -``` +M1 - Build, load, and probe: compile the Windows addon, load WHP dynamically, +surface `probeWhp()`, and keep hosted Windows CI JS-only. Done. -It validates the JS package can build, import, and pack on Windows without -claiming WHP release readiness. Native WHP compilation and execution belong in -the manual self-hosted gate because hosted runners may not expose nested -virtualization: +M2 - Native HLT smoke: create a partition/vCPU, map RAM, execute a tiny guest to +`hlt`, and verify dirty-page reporting. Done. -```yaml -windows-whp-gate: - runs-on: [self-hosted, windows, x64, whp] +M3 - Minimal kernel loop: load an x86_64 ELF kernel, set boot parameters, run +with one vCPU and `--net none`, capture serial output, and enforce timeouts. +Done. + +M4 - Real rootfs boot: WSL2 OCI rootfs builder, virtio block, timer/irqchip, and +command result plumbing. Done for local Alpine WHP e2e. + +M5 - SMP: `--cpus 2` and generated multi-vCPU smoke coverage. Done. + +M6 - Networking: virtio-net plus libslirp NAT/DNS/TCP forwarding. Done for local +Alpine WHP e2e. + +M7 - Console hardening: guest Ctrl-C, UTF-8 Windows console output, no early IRQ +warning, and idle PTY helper CPU. Done for local Alpine WHP e2e. + +M8 - Restore parity: RAM, vCPU, irq/device, and disk state restore. Pending. + +M9 - Runtime parity gate: app server fixtures and prebuild/release policy. +Covered locally through `npm run test:whp-apps`; release CI should run the same +suite on a self-hosted Windows machine. Custom Dockerfile/repo rootfs builds +continue to use the Linux/WSL2 builder path. + +## Prebuilt rootfs and WSL2 fallback + +`node-vmm` runs VMs natively on Windows + WHP. WSL2 is not part of the runtime; +it is only a helper builder for rootfs images that must be created locally from +OCI layers. + +The no-WSL paths are: + +1. `node-vmm run --rootfs .\disk.ext4` or SDK `rootfsPath` boots an existing + ext4 disk directly. There is no build phase, so WSL2 is never invoked. + +2. `node-vmm run --image alpine:3.20`, `node:20-alpine`, `node:22-alpine`, or + `oven/bun:1-alpine` checks the prepared rootfs cache first. On a cache miss, + it tries to download the package-versioned GitHub Release assets: + + - `alpine-3.20.ext4.gz` / `alpine-3.20.ext4.manifest.json` + - `node-20-alpine.ext4.gz` / `node-20-alpine.ext4.manifest.json` + - `node-22-alpine.ext4.gz` / `node-22-alpine.ext4.manifest.json` + - `oven-bun-1-alpine.ext4.gz` / `oven-bun-1-alpine.ext4.manifest.json` + + A verified prebuilt is gunzipped into the rootfs cache and booted by WHP + without spawning WSL2. + +3. Rootfs cache hits never rebuild. The cache lookup in `buildOrReuseRootfs` + happens before any builder process, and the cache key includes image, disk + size, build args, env, entrypoint, workdir, platform arch, and Dockerfile RUN + timeout. Re-running the same image/options is a cache hit. + +Fallback behavior is intentionally forgiving: if the image has no prebuilt +mapping, the release asset is missing, the network fails, or the checksum does +not verify, `node-vmm` removes any partial file and falls through to the WSL2 OCI +builder. Without WSL2 installed, that fallback reports the usual missing +rootfs-builder error; `--rootfs` and successful prebuilt downloads still work. + +Dockerfile and repo builds on Windows still require a Linux host path today. +They do not have a WSL2 builder yet. + +## Disk persistence, size, and reset + +The root disk is always a virtio-mmio block device exposed to the guest as +`/dev/vda`. The important question is which host file backs `/dev/vda`, and +whether writes go to that file or to a throwaway overlay. + +| Mode | Backing file | Guest writes | +| --- | --- | --- | +| `--rootfs PATH` | The exact ext4 file you pass | Persist in `PATH` unless `--sandbox`/`--restore` is enabled | +| `--image ...` | A cached or prebuilt base rootfs under `cacheDir/rootfs` | Go to a temporary sparse overlay by default, so the shared base stays clean | +| `--persist NAME` | `cacheDir/disks/NAME.ext4` | Persist in that named disk | +| `--disk-path PATH` | The exact ext4 file path you choose | Persist in `PATH` | + +`--persist NAME` / SDK `persist: "name"` is the safest stateful path for normal +Windows use. On first run, node-vmm builds, downloads, or reuses the selected +base rootfs, copies it into `cacheDir/disks/NAME.ext4`, then boots that copy as +`/dev/vda`. It also writes `cacheDir/disks/NAME.json` metadata with: + +- `kind` and `version`, so node-vmm can tell it owns the disk. +- `name`, the persistent disk name. +- `sourceKey`, a hash of the source image/rootfs and relevant build options. +- `sizeMiB`, `createdAt`, and `updatedAt`. + +Persistent names must start with a letter or number and may contain letters, +numbers, `.`, `_`, and `-` up to 128 characters. This keeps the cache layout +predictable and avoids path traversal. + +Reusing a named disk with the same source simply boots the existing writable +disk. Reusing it with a different source image/rootfs fails with a clear error +unless you pass `--reset`; this protects you from accidentally booting old state +for the wrong guest. `--reset` / SDK `reset: true` deletes and recreates the +stateful root disk from the currently selected source. + +`--disk-path PATH` / SDK `diskPath` is the manual version of the same idea. It +creates or reuses a writable root disk at the path you choose. It cannot be +combined with `persist` or `rootfsPath`, because each of those names a different +root disk owner. + +`--sandbox`, `--restore`, or SDK `sandbox: true` puts a sparse overlay in front +of the base rootfs. The guest still sees a writable `/dev/vda`, but writes are +redirected into the overlay. Normal runs delete the overlay on exit; `--overlay` +and `--keep-overlay` are debugging tools for inspecting the native disk layer. + +Resize is host-first, guest-finish: + +1. `--disk-size MIB`, legacy numeric `--disk MIB`, SDK `disk`, SDK `diskMiB`, + and SDK `diskSizeMiB` choose the desired root disk size. +2. New rootfs images are created at that size. Existing `persist` and + `diskPath` root disks can grow; node-vmm extends the host file with + `truncate`. +3. Shrinking is rejected. A smaller requested size would silently destroy data, + so node-vmm fails instead. +4. When the host file grows, node-vmm adds `node_vmm.resize_rootfs=1` to the + kernel command line. The guest init script runs `resize2fs /dev/vda` when + the image provides `resize2fs`. The published prebuilt rootfs images include + resize support. +5. Attached data disks are not resized automatically. Grow, partition, format, + and mount them explicitly if your workload needs that. + +## Attach disks and SDK parity + +Secondary data disks are public through CLI flags and SDK `attachDisks`. Each +attached disk must be an existing host file; node-vmm maps it as a virtio block +device after the root disk. The first attached disk is `/dev/vdb`, the second is +`/dev/vdc`, and so on up to 16 attached disks. + +Read-write attached disks write directly to their host file and are meant for +database/data state that should survive root disk resets. Read-only attached +disks are useful for seed/reference data; guest writes to them are rejected by +virtio-blk with an I/O error. node-vmm does not create, partition, format, mount, +grow, or shrink attached disks in v1. + +```powershell +node .\dist\src\main.js run ` + --rootfs .\root.ext4 ` + --attach-disk .\data.ext4 ` + --attach-disk-ro .\seed.ext4 ` + --cmd "lsblk && test -b /dev/vdb && test -b /dev/vdc" ` + --net none ``` +The current CLI/SDK disk parity is: + +| Concept | CLI | SDK | +| --- | --- | --- | +| Existing root disk | `--rootfs .\disk.ext4` | `rootfsPath: ".\\disk.ext4"` | +| Stateful named root disk | `--persist work` | `persist: "work"` | +| Explicit stateful root disk | `--disk-path .\work.ext4` | `diskPath: ".\\work.ext4"` | +| Reset stateful root disk | `--reset` | `reset: true` | +| Rootfs build/grow size | `--disk-size 1024` or legacy `--disk 1024` | `disk: 1024`, `diskMiB: 1024`, or `diskSizeMiB: 1024` | +| Reset-on-exit overlay | `--sandbox` / `--restore` | `sandbox: true` / `restore: true` | +| Explicit overlay path | `--overlay .\run.overlay` | `overlayPath: ".\\run.overlay"` | +| Keep overlay for debugging | `--keep-overlay` | `keepOverlay: true` | +| Attached data disk | `--attach-disk .\data.ext4` | `attachDisks: [{ path: ".\\data.ext4" }]` | +| Read-only data disk | `--attach-disk-ro .\data.ext4` | `attachDisks: [{ path: ".\\data.ext4", readonly: true }]` | + +## Windows coverage matrix + +| Surface | Coverage | Notes | +| --- | --- | --- | +| Hosted Windows package shape | `hosted-windows-js` / local `NODE_VMM_SKIP_NATIVE=1` build, import, pack | Proves JS/package shape without WHP. | +| WHP probe and dirty-page smoke | `node --test .\dist\test\native.test.js` | Covers `probeWhp()` and `whpSmokeHlt()`. | +| WHP native lifecycle | `native.test.js` generated ELF tests | Covers run, pause/resume/stop, and generated multi-vCPU smoke. | +| Real Alpine WHP e2e | `NODE_VMM_WHP_FULL_E2E=1 node --test .\dist\test\native.test.js` | Builds or reuses Alpine, validates block, Slirp, RNG, clock, `apk`, console, and Ctrl-C. | +| Framework app smoke | `npm run test:whp-apps` | Creates plain Node, Express, Fastify, Next.js, Vite React, and Vite Vue inside `node:22-alpine`. | +| Prebuilt rootfs mapping and fallback | `test/unit.test.ts` prebuilt-rootfs tests | Ensures image slugs match release assets and missing releases return `fetched:false`. | +| No-WSL cache/rootfs contract | `test/unit.test.ts` cache-hit and `--rootfs` tests | Ensures cache hits and explicit rootfs runs do not enter the builder path. | +| Release asset production | `.github/workflows/prebuilt-rootfs.yml` | Builds `alpine:3.20`, `node:20-alpine`, `node:22-alpine`, and `oven/bun:1-alpine` ext4 assets on Linux and attaches `.ext4.gz` plus manifest assets to releases. | +| Attached data disks | `NODE_VMM_WHP_ATTACH_DISKS_E2E=1 node --test .\dist\test\native.test.js` | Fixture-gated WHP e2e maps a data disk at `/dev/vdb` and verifies raw guest writes persist to the host file. | + ## References - Microsoft Windows Hypervisor Platform API: diff --git a/guest/node-vmm-console.cc b/guest/node-vmm-console.cc index 49b6afe..6bc1d02 100644 --- a/guest/node-vmm-console.cc +++ b/guest/node-vmm-console.cc @@ -23,6 +23,10 @@ constexpr uint16_t kFcr = 2; constexpr uint16_t kLcr = 3; constexpr uint16_t kMcr = 4; constexpr uint16_t kLsr = 5; +constexpr int kIdlePollMs = 20; +constexpr int kExitDrainPollMs = 10; +constexpr int kExitDrainPolls = 5; +uint8_t g_last_serial_byte = 0; void die(const char* message) { dprintf(STDERR_FILENO, "node-vmm-console: %s: %s\n", message, strerror(errno)); @@ -55,10 +59,11 @@ void serial_write_byte(uint8_t value) { void serial_write(const uint8_t* data, ssize_t len) { for (ssize_t i = 0; i < len; i++) { - if (data[i] == '\n') { + if (data[i] == '\n' && g_last_serial_byte != '\r') { serial_write_byte('\r'); } serial_write_byte(data[i]); + g_last_serial_byte = data[i]; } } @@ -69,6 +74,52 @@ void set_nonblock(int fd) { } } +unsigned short env_ushort(const char* name, unsigned short fallback) { + const char* raw = getenv(name); + if (raw == nullptr || *raw == '\0') { + return fallback; + } + char* end = nullptr; + long parsed = strtol(raw, &end, 10); + if (end == raw || *end != '\0' || parsed <= 0 || parsed > 1000) { + return fallback; + } + return static_cast(parsed); +} + +struct winsize env_winsize() { + struct winsize size {}; + size.ws_col = env_ushort("NODE_VMM_TTY_COLS", 80); + size.ws_row = env_ushort("NODE_VMM_TTY_ROWS", 24); + return size; +} + +void apply_winsize(int fd) { + struct winsize size = env_winsize(); + ioctl(fd, TIOCSWINSZ, &size); +} + +bool write_master_byte(int master, uint8_t ch) { + for (int attempts = 0; attempts < 1000; attempts++) { + ssize_t n = write(master, &ch, 1); + if (n == 1) { + return true; + } + if (n < 0 && errno == EINTR) { + continue; + } + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + struct pollfd pfd {}; + pfd.fd = master; + pfd.events = POLLOUT; + poll(&pfd, 1, 10); + continue; + } + return false; + } + return false; +} + int child_status_code(int status) { if (WIFEXITED(status)) { return WEXITSTATUS(status); @@ -79,9 +130,58 @@ int child_status_code(int status) { return 1; } +int run_tty(int argc, char** argv) { + if (argc < 4) { + dprintf(STDERR_FILENO, "node-vmm-console: --tty requires TTY and command\n"); + return 127; + } + const char* tty_path = argv[2]; + if (setsid() < 0) { + die("setsid"); + } + int tty = open(tty_path, O_RDWR); + if (tty < 0) { + die("open tty"); + } + if (ioctl(tty, TIOCSCTTY, 0) != 0) { + die("TIOCSCTTY"); + } + tcsetpgrp(tty, getpgrp()); + + struct termios term {}; + if (tcgetattr(tty, &term) == 0) { + term.c_iflag |= ICRNL | IXON | IXOFF; + term.c_oflag |= OPOST | ONLCR; + term.c_lflag |= ICANON | ECHO | ECHOE | ISIG; + term.c_cflag |= CREAD | CLOCAL; + term.c_cflag &= ~HUPCL; + term.c_cc[VINTR] = 3; + term.c_cc[VEOF] = 4; + term.c_cc[VERASE] = 0x7f; + term.c_cc[VKILL] = 0x15; + tcsetattr(tty, TCSANOW, &term); + } + apply_winsize(tty); + + dup2(tty, STDIN_FILENO); + dup2(tty, STDOUT_FILENO); + dup2(tty, STDERR_FILENO); + if (tty > STDERR_FILENO) { + close(tty); + } + setenv("TERM", "xterm-256color", 0); + execvp(argv[3], &argv[3]); + die("exec tty command"); + return 127; +} + } // namespace int main(int argc, char** argv) { + if (argc > 1 && strcmp(argv[1], "--tty") == 0) { + return run_tty(argc, argv); + } + serial_init(); int master = -1; @@ -92,8 +192,13 @@ int main(int argc, char** argv) { term.c_lflag |= ICANON | ECHO | ECHOE | ISIG; term.c_oflag |= OPOST | ONLCR; term.c_cflag |= CREAD | CLOCAL; + term.c_cc[VINTR] = 3; + term.c_cc[VEOF] = 4; + term.c_cc[VERASE] = 0x7f; + term.c_cc[VKILL] = 0x15; - if (openpty(&master, &slave, nullptr, &term, nullptr) != 0) { + struct winsize size = env_winsize(); + if (openpty(&master, &slave, nullptr, &term, &size) != 0) { die("openpty"); } @@ -106,13 +211,14 @@ int main(int argc, char** argv) { close(master); setsid(); ioctl(slave, TIOCSCTTY, 0); + tcsetpgrp(slave, getpgrp()); dup2(slave, STDIN_FILENO); dup2(slave, STDOUT_FILENO); dup2(slave, STDERR_FILENO); if (slave > STDERR_FILENO) { close(slave); } - setenv("TERM", "linux", 0); + setenv("TERM", "xterm-256color", 0); if (argc > 1) { execvp(argv[1], &argv[1]); } else { @@ -132,16 +238,21 @@ int main(int argc, char** argv) { uint8_t buf[512]; for (;;) { + bool did_work = false; struct pollfd pfd {}; pfd.fd = master; pfd.events = POLLIN; - poll(&pfd, 1, 10); + int prc = poll(&pfd, 1, child_done ? kExitDrainPollMs : kIdlePollMs); + if (prc < 0 && errno != EINTR) { + break; + } for (;;) { ssize_t n = read(master, buf, sizeof(buf)); if (n > 0) { serial_write(buf, n); drain_ticks = 0; + did_work = true; continue; } if (n < 0 && (errno == EINTR || errno == EAGAIN)) { @@ -155,8 +266,8 @@ int main(int argc, char** argv) { break; } uint8_t ch = inb(kCom1 + kRbr); - ssize_t ignored = write(master, &ch, 1); - (void)ignored; + write_master_byte(master, ch); + did_work = true; } if (!child_done) { @@ -164,7 +275,7 @@ int main(int argc, char** argv) { if (rc == child) { child_done = true; } - } else if (++drain_ticks > 20) { + } else if (!did_work && ++drain_ticks >= kExitDrainPolls) { break; } } diff --git a/native/common/bytes.h b/native/common/bytes.h new file mode 100644 index 0000000..752f67c --- /dev/null +++ b/native/common/bytes.h @@ -0,0 +1,141 @@ +#pragma once + +// Tiny shared helpers used by every WHP module: throw-on-false `Check`, +// little-endian byte readers/writers, range/overflow guards, and +// `WindowsErrorMessage` for HRESULT/Win32 error formatting. Each module +// previously carried a TU-local copy; consolidating them here means +// one source of truth and one less thing to read past when scanning a +// module's source. +// +// All functions are `inline` so the header is the implementation. No +// `.cc` file. Include from any C++ TU under native/whp/ or native/kvm/. + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#ifdef _WIN32 +#include +#endif + +#include +#include +#include + +namespace node_vmm::common { + +inline void Check(bool ok, const std::string& message) { + if (!ok) { + throw std::runtime_error(message); + } +} + +inline uint64_t CheckedAdd(uint64_t a, uint64_t b, const std::string& what) { + Check(b == 0 || a <= UINT64_MAX - b, what + " overflow"); + return a + b; +} + +inline uint64_t CheckedMul(uint64_t a, uint64_t b, const std::string& what) { + if (a == 0 || b == 0) { + return 0; + } + Check(a <= UINT64_MAX / b, what + " overflow"); + return a * b; +} + +inline void CheckRange(uint64_t total, uint64_t offset, uint64_t len, const std::string& what) { + Check(offset <= total, what + " offset is out of range"); + Check(len <= total - offset, what + " length is out of range"); +} + +inline void WriteU16(uint8_t* p, uint16_t v) { + p[0] = static_cast(v & 0xff); + p[1] = static_cast((v >> 8) & 0xff); +} + +inline void WriteU32(uint8_t* p, uint32_t v) { + for (int i = 0; i < 4; i++) { + p[i] = static_cast((v >> (8 * i)) & 0xff); + } +} + +inline void WriteU64(uint8_t* p, uint64_t v) { + for (int i = 0; i < 8; i++) { + p[i] = static_cast((v >> (8 * i)) & 0xff); + } +} + +inline uint16_t ReadU16(const uint8_t* p) { + return static_cast(p[0]) | (static_cast(p[1]) << 8); +} + +inline uint32_t ReadU32(const uint8_t* p) { + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + +inline uint64_t ReadU64(const uint8_t* p) { + uint64_t v = 0; + for (int i = 0; i < 8; i++) { + v |= static_cast(p[i]) << (8 * i); + } + return v; +} + +// Generic little-endian load/store for partial-width MMIO accesses (HPET +// 32-bit halves of 64-bit registers, etc). `len` is clamped to 8. +inline uint64_t ReadLe(const uint8_t* p, uint32_t len) { + uint64_t value = 0; + for (uint32_t i = 0; i < len && i < 8; i++) { + value |= static_cast(p[i]) << (i * 8); + } + return value; +} + +inline void WriteLe(uint8_t* p, uint32_t len, uint64_t value) { + for (uint32_t i = 0; i < len && i < 8; i++) { + p[i] = static_cast(value >> (i * 8)); + } +} + +// Replaces `bits` consecutive bits of `old_value` starting at `shift` with +// the low `bits` bits of `value`. Used by HPET register writes that span +// only a 32-bit half of a 64-bit register. +inline uint64_t DepositBits(uint64_t old_value, uint32_t shift, uint32_t bits, uint64_t value) { + if (bits == 0 || shift >= 64) { + return old_value; + } + uint32_t effective = bits > 64 - shift ? 64 - shift : bits; + uint64_t mask = effective == 64 ? UINT64_MAX : ((uint64_t(1) << effective) - 1); + return (old_value & ~(mask << shift)) | ((value & mask) << shift); +} + +#ifdef _WIN32 +// Windows-only: format a Win32 DWORD error code as a human-readable string. +inline std::string WindowsErrorMessage(DWORD code) { + char* buffer = nullptr; + DWORD size = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + code, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&buffer), + 0, + nullptr); + std::string message = size && buffer ? std::string(buffer, size) : "unknown Windows error"; + if (buffer) { + LocalFree(buffer); + } + while (!message.empty() && (message.back() == '\n' || message.back() == '\r' || message.back() == ' ')) { + message.pop_back(); + } + return message; +} +#endif // _WIN32 + +} // namespace node_vmm::common diff --git a/native/hvf/backend.cc b/native/hvf/backend.cc new file mode 100644 index 0000000..9c66b4b --- /dev/null +++ b/native/hvf/backend.cc @@ -0,0 +1,3910 @@ +// Apple Hypervisor.framework backend for node-vmm (ARM64 macOS 15.0+) +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if __has_include() +#include +#define NODE_VMM_HAVE_SLIRP 1 +#elif __has_include() +#include +#define NODE_VMM_HAVE_SLIRP 1 +#else +#define NODE_VMM_HAVE_SLIRP 0 +#endif + +namespace { + +// ─── QEMU ARM virt low MMIO layout ─────────────────────────────────────────── +// Keep this table aligned with QEMU's hw/arm/virt.c for probe-sensitive devices. +constexpr uint64_t kGicDistBase = 0x08000000ULL; +constexpr uint64_t kGicRedistBase = 0x080A0000ULL; +constexpr uint64_t kUartBase = 0x09000000ULL; +constexpr uint64_t kPl031RtcBase = 0x09010000ULL; +constexpr uint64_t kFwCfgBase = 0x09020000ULL; +constexpr uint64_t kPl061GpioBase = 0x09030000ULL; +constexpr uint64_t kVirtioMmioBase = 0x0A000000ULL; +constexpr uint64_t kVirtioMmioSize = 0x200ULL; +constexpr uint64_t kVirtioMmioStride = 0x200ULL; +constexpr uint32_t kVirtioMmioCount = 32; +constexpr uint32_t kVirtioBlkSlot = 0; +constexpr uint32_t kVirtioNetSlot = 1; +constexpr uint64_t kVirtioBlkBase = kVirtioMmioBase + kVirtioBlkSlot * kVirtioMmioStride; +constexpr uint64_t kVirtioNetBase = kVirtioMmioBase + kVirtioNetSlot * kVirtioMmioStride; +constexpr uint64_t kExitDevBase = 0x0B000000ULL; +constexpr uint64_t kPcieMmioBase = 0x10000000ULL; +constexpr uint64_t kPcieMmioSize = 0x2EFF0000ULL; +constexpr uint64_t kPciePioBase = 0x3EFF0000ULL; +constexpr uint64_t kPciePioSize = 0x00010000ULL; +constexpr uint64_t kPcieEcamBase = 0x3F000000ULL; +constexpr uint64_t kPcieEcamSize = 0x01000000ULL; +constexpr uint64_t kRamBase = 0x40000000ULL; +constexpr uint64_t kDtbOffset = 0x00000000ULL; // DTB at kRamBase + 0 +constexpr uint64_t kKernelOffset = 0x00400000ULL; // Kernel at kRamBase + 4MiB +constexpr uint32_t kGicRedistributorBytesPerCpu = 0x20000; + +// GIC SPI interrupt IDs (INTID = 32 + SPI_N) +constexpr uint32_t kUartSpi = 1; +constexpr uint32_t kRtcSpi = 2; +constexpr uint32_t kPcieFirstSpi = 3; +constexpr uint32_t kPcieIntxCount = 4; +constexpr uint32_t kGpioSpi = 7; +constexpr uint32_t kVirtioFirstSpi = 16; +constexpr uint32_t kUartIntid = 32 + kUartSpi; +constexpr uint32_t kRtcIntid = 32 + kRtcSpi; +constexpr uint32_t kGpioIntid = 32 + kGpioSpi; +constexpr uint32_t kVirtioBlkIntid = 32 + kVirtioFirstSpi + kVirtioBlkSlot; +constexpr uint32_t kVirtioNetIntid = 32 + kVirtioFirstSpi + kVirtioNetSlot; +constexpr uint64_t kVtimerIntidBit = 1ULL << 27; + +constexpr uint64_t VirtioMmioBaseForSlot(uint32_t slot) { + return kVirtioMmioBase + static_cast(slot) * kVirtioMmioStride; +} + +constexpr uint32_t VirtioMmioIntidForSlot(uint32_t slot) { + return 32 + kVirtioFirstSpi + slot; +} + +constexpr uint32_t kPciRangeIoPort = 0x01000000; +constexpr uint32_t kPciRangeMmio32 = 0x02000000; + +constexpr uint32_t kMaxQueueSize = 256; +constexpr uint32_t kMaxFrameSize = 1518; +constexpr uint32_t kVirtioStatusDeviceNeedsReset = 0x40; +constexpr int32_t kControlRun = 0; +constexpr int32_t kControlPause = 1; +constexpr int32_t kControlStop = 2; +constexpr int32_t kControlStateStarting = 0; +constexpr int32_t kControlStateRunning = 1; +constexpr int32_t kControlStatePaused = 2; +constexpr int32_t kControlStateStopping = 3; +constexpr int32_t kControlStateExited = 4; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +std::string ErrnoMessage(const std::string& what) { + return what + ": " + strerror(errno); +} + +void Check(bool ok, const std::string& message) { + if (!ok) throw std::runtime_error(message); +} + +void CheckErr(int rc, const std::string& what) { + if (rc < 0) throw std::runtime_error(ErrnoMessage(what)); +} + +void CheckHv(hv_return_t rc, const std::string& what) { + if (rc != HV_SUCCESS) { + throw std::runtime_error(what + " failed: hv_return=" + std::to_string(rc)); + } +} + +bool HvfDebugEnabled() { + const char* value = getenv("NODE_VMM_HVF_DEBUG"); + return value && value[0] != '\0' && strcmp(value, "0") != 0; +} + +bool HvfDebugUartEnabled() { + const char* value = getenv("NODE_VMM_HVF_DEBUG_UART"); + return value && value[0] != '\0' && strcmp(value, "0") != 0; +} + +bool IsQemuVirtioMmioAddress(uint64_t ipa) { + return ipa >= kVirtioMmioBase && + ipa < kVirtioMmioBase + uint64_t(kVirtioMmioCount) * kVirtioMmioStride; +} + +uint64_t VirtioMmioOffset(uint64_t ipa) { + return (ipa - kVirtioMmioBase) % kVirtioMmioStride; +} + +uint32_t PcieBusCount() { + return static_cast(kPcieEcamSize / 0x100000ULL); +} + +uint32_t GicRedistributorBytes(uint32_t cpus) { + return kGicRedistributorBytesPerCpu * cpus; +} + +void FillRandom(uint8_t* data, size_t len) { + arc4random_buf(data, len); +} + +uint64_t RandomU64() { + uint8_t data[8]{}; + FillRandom(data, sizeof(data)); + uint64_t out = 0; + for (size_t i = 0; i < sizeof(data); i++) { + out |= uint64_t(data[i]) << (8 * i); + } + return out; +} + +std::string Hex64(uint64_t value) { + char buf[20]; + snprintf(buf, sizeof(buf), "0x%llx", static_cast(value)); + return buf; +} + +std::string UnsupportedExceptionReason(const char* kind, uint32_t ec, uint64_t syndrome, + uint64_t ipa, uint64_t pc) { + return std::string("unsupported-") + kind + + " ec=" + Hex64(ec) + + " syndrome=" + Hex64(syndrome) + + " ipa=" + Hex64(ipa) + + " pc=" + Hex64(pc); +} + +uint64_t MonotonicMicros() { + struct timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return uint64_t(ts.tv_sec) * 1000000ULL + uint64_t(ts.tv_nsec) / 1000ULL; +} + +// ─── RAII helpers ───────────────────────────────────────────────────────────── +struct Fd { + int fd{-1}; + Fd() = default; + explicit Fd(int v) : fd(v) {} + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd(Fd&& o) noexcept : fd(o.fd) { o.fd = -1; } + Fd& operator=(Fd&& o) noexcept { + if (this != &o) { reset(); fd = o.fd; o.fd = -1; } + return *this; + } + ~Fd() { reset(); } + void reset(int next = -1) { if (fd >= 0) close(fd); fd = next; } + int get() const { return fd; } +}; + +struct Mapping { + void* addr{MAP_FAILED}; + size_t len{0}; + Mapping() = default; + Mapping(void* a, size_t s) : addr(a), len(s) {} + Mapping(const Mapping&) = delete; + Mapping& operator=(const Mapping&) = delete; + Mapping(Mapping&& o) noexcept : addr(o.addr), len(o.len) { o.addr = MAP_FAILED; o.len = 0; } + Mapping& operator=(Mapping&& o) noexcept { + if (this != &o) { reset(); addr = o.addr; len = o.len; o.addr = MAP_FAILED; o.len = 0; } + return *this; + } + ~Mapping() { reset(); } + void reset() { + if (addr != MAP_FAILED && len > 0) munmap(addr, len); + addr = MAP_FAILED; len = 0; + } + uint8_t* bytes() const { return reinterpret_cast(addr); } +}; + +// ─── Integer I/O helpers ────────────────────────────────────────────────────── +static inline uint16_t ReadU16(const uint8_t* p) { + return uint16_t(p[0]) | (uint16_t(p[1]) << 8); +} +static inline uint32_t ReadU32(const uint8_t* p) { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} +static inline uint64_t ReadU64(const uint8_t* p) { + return uint64_t(ReadU32(p)) | (uint64_t(ReadU32(p + 4)) << 32); +} +static inline void WriteU16(uint8_t* p, uint16_t v) { p[0]=v; p[1]=v>>8; } +static inline void WriteU32(uint8_t* p, uint32_t v) { p[0]=v; p[1]=v>>8; p[2]=v>>16; p[3]=v>>24; } +static inline void WriteU64(uint8_t* p, uint64_t v) { WriteU32(p,uint32_t(v)); WriteU32(p+4,uint32_t(v>>32)); } + +// Big-endian (for FDT) +static inline void WriteU32BE(uint8_t* p, uint32_t v) { p[0]=v>>24; p[1]=v>>16; p[2]=v>>8; p[3]=v; } +static inline void WriteU64BE(uint8_t* p, uint64_t v) { WriteU32BE(p,uint32_t(v>>32)); WriteU32BE(p+4,uint32_t(v)); } + +// ─── File helpers ───────────────────────────────────────────────────────────── +std::vector ReadFile(const std::string& path) { + Fd fd(open(path.c_str(), O_RDONLY | O_CLOEXEC)); + CheckErr(fd.get(), "open " + path); + struct stat st {}; + CheckErr(fstat(fd.get(), &st), "stat " + path); + Check(st.st_size >= 0, "negative file size: " + path); + std::vector data(static_cast(st.st_size)); + size_t done = 0; + while (done < data.size()) { + ssize_t n = read(fd.get(), data.data() + done, data.size() - done); + if (n < 0 && errno == EINTR) continue; + Check(n > 0, ErrnoMessage("read " + path)); + done += static_cast(n); + } + return data; +} + +void PreadAll(int fd, uint8_t* dst, size_t len, off_t off) { + size_t done = 0; + while (done < len) { + ssize_t n = pread(fd, dst + done, len - done, off + static_cast(done)); + if (n < 0 && errno == EINTR) continue; + Check(n > 0, ErrnoMessage("pread disk")); + done += static_cast(n); + } +} + +void PwriteAll(int fd, const uint8_t* src, size_t len, off_t off) { + size_t done = 0; + while (done < len) { + ssize_t n = pwrite(fd, src + done, len - done, off + static_cast(done)); + if (n < 0 && errno == EINTR) continue; + Check(n > 0, ErrnoMessage("pwrite disk")); + done += static_cast(n); + } +} + +// ─── GuestMemory ───────────────────────────────────────────────────────────── +class GuestMemory { + public: + GuestMemory() = default; + GuestMemory(uint8_t* data, size_t size) : data_(data), size_(size) {} + uint8_t* ptr(uint64_t gpa, uint64_t len) { + uint64_t off = offset(gpa, len); + return data_ + off; + } + const uint8_t* ptr(uint64_t gpa, uint64_t len) const { + uint64_t off = offset(gpa, len); + return data_ + off; + } + uint8_t* guest_ptr(uint64_t gpa, uint64_t len) { + uint64_t off = guest_offset(gpa, len); + return data_ + off; + } + const uint8_t* guest_ptr(uint64_t gpa, uint64_t len) const { + uint64_t off = guest_offset(gpa, len); + return data_ + off; + } + bool contains_guest_range(uint64_t gpa, uint64_t len) const { + if (gpa < kRamBase) return false; + uint64_t off = gpa - kRamBase; + return off <= size_ && len <= size_ - off; + } + size_t size() const { return size_; } + uint8_t* data() const { return data_; } + private: + uint64_t offset(uint64_t gpa, uint64_t len) const { + uint64_t off = gpa; + if (gpa >= kRamBase) { + off = gpa - kRamBase; + } + Check(off <= size_ && len <= size_ - off, + "guest memory access out of bounds @" + std::to_string(gpa)); + return off; + } + + uint64_t guest_offset(uint64_t gpa, uint64_t len) const { + Check(contains_guest_range(gpa, len), + "guest physical memory access out of bounds @" + std::to_string(gpa)); + return gpa - kRamBase; + } + + uint8_t* data_{nullptr}; + size_t size_{0}; +}; + +// ─── FDT Builder ───────────────────────────────────────────────────────────── +// Produces a minimal FDT blob for ARM64 Linux. +class FdtBuilder { + public: + std::vector Build( + uint64_t ram_base, uint64_t ram_size, + uint32_t cpus, const std::string& cmdline, + bool has_net) + { + (void)has_net; + buf_.clear(); + str_buf_.clear(); + + constexpr uint32_t kHeaderSize = 40; + constexpr uint32_t kReserveMapSize = 16; + constexpr uint32_t kStructOffset = kHeaderSize + kReserveMapSize; + + buf_.resize(kStructOffset, 0); + + // FDT_BEGIN_NODE for root + AppendU32(FDT_BEGIN_NODE); + AppendStr(""); // root name = "" + Align4(); + + // root properties + AppendPropU32("#address-cells", 2); + AppendPropU32("#size-cells", 2); + AppendPropStr("compatible", "linux,dummy-virt"); + AppendPropStr("model", "node-vmm-hvf"); + AppendPropU32("interrupt-parent", 1); + AppendPropFlag("dma-coherent"); + + // /aliases + AppendU32(FDT_BEGIN_NODE); + AppendStr("aliases"); + Align4(); + AppendPropStr("serial0", "/pl011@9000000"); + AppendU32(FDT_END_NODE); + + // /chosen + AppendU32(FDT_BEGIN_NODE); + AppendStr("chosen"); + Align4(); + AppendPropStr("bootargs", cmdline); + AppendPropStr("stdout-path", "/pl011@9000000"); + AppendPropU64("kaslr-seed", RandomU64()); + { + uint8_t seed[32]; + FillRandom(seed, sizeof(seed)); + AppendPropRaw("rng-seed", seed, sizeof(seed)); + } + AppendU32(FDT_END_NODE); + + // /cpus + AppendU32(FDT_BEGIN_NODE); + AppendStr("cpus"); + Align4(); + AppendPropU32("#address-cells", 1); + AppendPropU32("#size-cells", 0); + for (uint32_t i = 0; i < cpus; i++) { + std::string cpu_name = "cpu@" + ToHex(i); + AppendU32(FDT_BEGIN_NODE); + AppendStr(cpu_name); + Align4(); + AppendPropStr("device_type", "cpu"); + AppendPropStr("compatible", "arm,arm-v8"); + AppendPropU32("reg", i); + if (cpus > 1) { + AppendPropStr("enable-method", "psci"); + } + AppendU32(FDT_END_NODE); + } + AppendU32(FDT_END_NODE); // /cpus + + // /psci + AppendU32(FDT_BEGIN_NODE); + AppendStr("psci"); + Align4(); + AppendPropStringList("compatible", {"arm,psci-1.0", "arm,psci-0.2", "arm,psci"}); + AppendPropStr("method", "hvc"); + AppendU32(FDT_END_NODE); + + // /memory + AppendU32(FDT_BEGIN_NODE); + AppendStr("memory@" + ToHex64(ram_base)); + Align4(); + AppendPropStr("device_type", "memory"); + // reg = + AppendPropReg("reg", ram_base, ram_size); + AppendU32(FDT_END_NODE); + + // /intc (GICv3) + AppendU32(FDT_BEGIN_NODE); + AppendStr("intc@8000000"); + Align4(); + AppendPropU32("phandle", 1); + AppendPropStr("compatible", "arm,gic-v3"); + AppendPropU32("#address-cells", 2); + AppendPropU32("#size-cells", 2); + AppendPropU32("#interrupt-cells", 3); + AppendPropFlag("interrupt-controller"); + AppendPropFlag("ranges"); + // reg = distributor + redistributor + { + uint8_t regbuf[32]; + WriteU32BE(regbuf+0, uint32_t(kGicDistBase >> 32)); + WriteU32BE(regbuf+4, uint32_t(kGicDistBase)); + WriteU32BE(regbuf+8, 0); + WriteU32BE(regbuf+12, 0x10000); + WriteU32BE(regbuf+16, uint32_t(kGicRedistBase >> 32)); + WriteU32BE(regbuf+20, uint32_t(kGicRedistBase)); + WriteU32BE(regbuf+24, 0); + WriteU32BE(regbuf+28, GicRedistributorBytes(cpus)); + AppendPropRaw("reg", regbuf, sizeof(regbuf)); + } + AppendU32(FDT_END_NODE); + + // ARM architected timer. HVF provides the virtual timer; Linux needs this + // node to program clock events instead of falling back to busy waits. + AppendU32(FDT_BEGIN_NODE); + AppendStr("timer"); + Align4(); + AppendPropStringList("compatible", {"arm,armv8-timer", "arm,armv7-timer"}); + { + uint8_t irq[48]; + // . GICv3 does not use the GICv2 PPI CPU mask. + const uint32_t ppis[] = {13, 14, 11, 10}; + for (size_t i = 0; i < 4; i++) { + WriteU32BE(irq + i * 12 + 0, 1); + WriteU32BE(irq + i * 12 + 4, ppis[i]); + WriteU32BE(irq + i * 12 + 8, 4); + } + AppendPropRaw("interrupts", irq, sizeof(irq)); + } + AppendPropFlag("always-on"); + AppendU32(FDT_END_NODE); + + // /pl011 UART + AppendU32(FDT_BEGIN_NODE); + AppendStr("pl011@9000000"); + Align4(); + AppendPropStringList("compatible", {"arm,pl011", "arm,primecell"}); + AppendPropReg("reg", kUartBase, 0x1000); + // interrupts = + { + uint8_t irq[12]; + WriteU32BE(irq+0, 0); // GIC_SPI + WriteU32BE(irq+4, kUartSpi); + WriteU32BE(irq+8, 4); // level high + AppendPropRaw("interrupts", irq, sizeof(irq)); + } + AppendPropU32("interrupt-parent", 1); + AppendPropStringList("clock-names", {"uartclk", "apb_pclk"}); + { + uint8_t clks[8]; + WriteU32BE(clks+0, 2); + WriteU32BE(clks+4, 3); + AppendPropRaw("clocks", clks, sizeof(clks)); + } + AppendU32(FDT_END_NODE); + + // clock nodes for PL011 + AppendU32(FDT_BEGIN_NODE); + AppendStr("uartclk"); + Align4(); + AppendPropU32("phandle", 2); + AppendPropStr("compatible", "fixed-clock"); + AppendPropU32("#clock-cells", 0); + AppendPropU32("clock-frequency", 24000000); + AppendU32(FDT_END_NODE); + + AppendU32(FDT_BEGIN_NODE); + AppendStr("apb-pclk"); + Align4(); + AppendPropU32("phandle", 3); + AppendPropStr("compatible", "fixed-clock"); + AppendPropU32("#clock-cells", 0); + AppendPropU32("clock-frequency", 24000000); + AppendU32(FDT_END_NODE); + + // PL031 RTC + AppendU32(FDT_BEGIN_NODE); + AppendStr("pl031@9010000"); + Align4(); + AppendPropStringList("compatible", {"arm,pl031", "arm,primecell"}); + AppendPropReg("reg", kPl031RtcBase, 0x1000); + { + uint8_t irq[12]; + WriteU32BE(irq+0, 0); // GIC_SPI + WriteU32BE(irq+4, kRtcSpi); + WriteU32BE(irq+8, 4); // level high + AppendPropRaw("interrupts", irq, sizeof(irq)); + } + AppendPropU32("interrupt-parent", 1); + AppendPropU32("clocks", 3); + AppendPropStr("clock-names", "apb_pclk"); + AppendU32(FDT_END_NODE); + + // PL061 GPIO and the QEMU virt power key child node. + AppendU32(FDT_BEGIN_NODE); + AppendStr("pl061@9030000"); + Align4(); + AppendPropStringList("compatible", {"arm,pl061", "arm,primecell"}); + AppendPropReg("reg", kPl061GpioBase, 0x1000); + AppendPropU32("#gpio-cells", 2); + AppendPropFlag("gpio-controller"); + { + uint8_t irq[12]; + WriteU32BE(irq+0, 0); // GIC_SPI + WriteU32BE(irq+4, kGpioSpi); + WriteU32BE(irq+8, 4); // level high + AppendPropRaw("interrupts", irq, sizeof(irq)); + } + AppendPropU32("interrupt-parent", 1); + AppendPropU32("clocks", 3); + AppendPropStr("clock-names", "apb_pclk"); + AppendPropU32("phandle", 4); + AppendU32(FDT_END_NODE); + + AppendU32(FDT_BEGIN_NODE); + AppendStr("gpio-keys"); + Align4(); + AppendPropStr("compatible", "gpio-keys"); + AppendU32(FDT_BEGIN_NODE); + AppendStr("poweroff"); + Align4(); + AppendPropStr("label", "GPIO Key Poweroff"); + AppendPropU32("linux,code", 116); // KEY_POWER + { + uint8_t gpios[12]; + WriteU32BE(gpios+0, 4); + WriteU32BE(gpios+4, 3); + WriteU32BE(gpios+8, 0); + AppendPropRaw("gpios", gpios, sizeof(gpios)); + } + AppendU32(FDT_END_NODE); + AppendU32(FDT_END_NODE); + + // fw_cfg-mmio + AppendU32(FDT_BEGIN_NODE); + AppendStr("fw-cfg@9020000"); + Align4(); + AppendPropStr("compatible", "qemu,fw-cfg-mmio"); + AppendPropReg("reg", kFwCfgBase, 0x18); + AppendPropFlag("dma-coherent"); + AppendU32(FDT_END_NODE); + + // QEMU creates probe-safe virtio-mmio transports up front. Empty + // transports report device ID 0; Linux probes them without binding. + for (uint32_t slot = 0; slot < kVirtioMmioCount; slot++) { + AppendVirtioNode(kVirtioMmioBase + slot * kVirtioMmioStride, kVirtioFirstSpi + slot); + } + + // Empty generic PCIe ECAM host bridge. This exposes QEMU's virt bus layout + // while keeping PCI enumeration probe-safe until virtio-pci/MSI exist. + AppendU32(FDT_BEGIN_NODE); + AppendStr("pcie@10000000"); + Align4(); + AppendPropStr("compatible", "pci-host-ecam-generic"); + AppendPropStr("device_type", "pci"); + AppendPropU32("#address-cells", 3); + AppendPropU32("#size-cells", 2); + AppendPropU32("linux,pci-domain", 0); + { + uint8_t bus_range[8]; + WriteU32BE(bus_range+0, 0); + WriteU32BE(bus_range+4, PcieBusCount() - 1); + AppendPropRaw("bus-range", bus_range, sizeof(bus_range)); + } + AppendPropFlag("dma-coherent"); + AppendPropReg("reg", kPcieEcamBase, kPcieEcamSize); + { + uint8_t ranges[56]; + WriteU32BE(ranges+0, kPciRangeIoPort); + WriteU32BE(ranges+4, 0); + WriteU32BE(ranges+8, 0); + WriteU32BE(ranges+12, uint32_t(kPciePioBase >> 32)); + WriteU32BE(ranges+16, uint32_t(kPciePioBase)); + WriteU32BE(ranges+20, uint32_t(kPciePioSize >> 32)); + WriteU32BE(ranges+24, uint32_t(kPciePioSize)); + WriteU32BE(ranges+28, kPciRangeMmio32); + WriteU32BE(ranges+32, uint32_t(kPcieMmioBase >> 32)); + WriteU32BE(ranges+36, uint32_t(kPcieMmioBase)); + WriteU32BE(ranges+40, uint32_t(kPcieMmioBase >> 32)); + WriteU32BE(ranges+44, uint32_t(kPcieMmioBase)); + WriteU32BE(ranges+48, uint32_t(kPcieMmioSize >> 32)); + WriteU32BE(ranges+52, uint32_t(kPcieMmioSize)); + AppendPropRaw("ranges", ranges, sizeof(ranges)); + } + AppendPropU32("#interrupt-cells", 1); + { + uint8_t map[4 * kPcieIntxCount * 10 * sizeof(uint32_t)]{}; + size_t off = 0; + for (uint32_t slot = 0; slot < 4; slot++) { + for (uint32_t pin = 0; pin < kPcieIntxCount; pin++) { + const uint32_t spi = kPcieFirstSpi + ((pin + slot) % kPcieIntxCount); + const uint32_t cells[] = { + slot << 11, 0, 0, pin + 1, + 1, 0, 0, 0, spi, 4, + }; + for (uint32_t cell : cells) { + WriteU32BE(map + off, cell); + off += sizeof(uint32_t); + } + } + } + AppendPropRaw("interrupt-map", map, sizeof(map)); + } + { + uint8_t mask[16]; + WriteU32BE(mask+0, 0x1800); // devfn mask for slots 0..3 + WriteU32BE(mask+4, 0); + WriteU32BE(mask+8, 0); + WriteU32BE(mask+12, 0x7); // PCI INTx pin mask + AppendPropRaw("interrupt-map-mask", mask, sizeof(mask)); + } + AppendU32(FDT_END_NODE); + + // /exit-device (custom MMIO to signal guest exit) + AppendU32(FDT_BEGIN_NODE); + AppendStr("exit@b000000"); + Align4(); + AppendPropStr("compatible", "node-vmm,exit"); + AppendPropReg("reg", kExitDevBase, 0x1000); + AppendU32(FDT_END_NODE); + + AppendU32(FDT_END_NODE); // root + AppendU32(FDT_END); + + // Build final blob + uint32_t struct_size = static_cast(buf_.size() - kStructOffset); + uint32_t strings_off = static_cast(buf_.size()); + buf_.insert(buf_.end(), str_buf_.begin(), str_buf_.end()); + Align4buf(); + uint32_t total = static_cast(buf_.size()); + + // Fill header + uint8_t* h = buf_.data(); + WriteU32BE(h+0, 0xD00DFEED); // magic + WriteU32BE(h+4, total); + WriteU32BE(h+8, kStructOffset); + WriteU32BE(h+12, strings_off); + WriteU32BE(h+16, kHeaderSize); + WriteU32BE(h+20, 17); // version + WriteU32BE(h+24, 16); // last_comp_version + WriteU32BE(h+28, 0); // boot_cpuid_phys + WriteU32BE(h+32, static_cast(str_buf_.size())); // size_dt_strings + WriteU32BE(h+36, struct_size); // size_dt_struct + + return buf_; + } + + private: + static constexpr uint32_t FDT_BEGIN_NODE = 0x00000001; + static constexpr uint32_t FDT_END_NODE = 0x00000002; + static constexpr uint32_t FDT_PROP = 0x00000003; + static constexpr uint32_t FDT_END = 0x00000009; + + std::vector buf_; + std::vector str_buf_; + + void AppendU32(uint32_t v) { + uint8_t b[4]; WriteU32BE(b, v); + buf_.insert(buf_.end(), b, b+4); + } + + void AppendStr(const std::string& s) { + buf_.insert(buf_.end(), s.begin(), s.end()); + buf_.push_back(0); + } + + void Align4() { + while (buf_.size() % 4) buf_.push_back(0); + } + + void Align4buf() { + while (buf_.size() % 4) buf_.push_back(0); + } + + uint32_t AddString(const char* s) { + uint32_t off = static_cast(str_buf_.size()); + size_t len = strlen(s) + 1; + str_buf_.insert(str_buf_.end(), s, s + len); + return off; + } + + void AppendPropRaw(const char* name, const uint8_t* data, uint32_t len) { + AppendU32(FDT_PROP); + AppendU32(len); + AppendU32(AddString(name)); + if (len > 0) { + Check(data != nullptr, "FDT property data is null"); + buf_.insert(buf_.end(), data, data + len); + } + Align4(); + } + + void AppendPropU32(const char* name, uint32_t value) { + uint8_t b[4]; WriteU32BE(b, value); + AppendPropRaw(name, b, 4); + } + + void AppendPropU64(const char* name, uint64_t value) { + uint8_t b[8]; WriteU64BE(b, value); + AppendPropRaw(name, b, 8); + } + + void AppendPropStr(const char* name, const char* value) { + size_t len = strlen(value) + 1; + AppendPropRaw(name, reinterpret_cast(value), static_cast(len)); + } + + // For compatible with two strings (null-separated) + void AppendPropStr(const char* name, const std::string& value) { + AppendPropRaw(name, reinterpret_cast(value.c_str()), static_cast(value.size() + 1)); + } + + void AppendPropStringList(const char* name, std::initializer_list values) { + std::vector data; + for (const char* value : values) { + size_t len = strlen(value) + 1; + data.insert(data.end(), reinterpret_cast(value), reinterpret_cast(value) + len); + } + AppendPropRaw(name, data.data(), static_cast(data.size())); + } + + void AppendPropFlag(const char* name) { + AppendPropRaw(name, nullptr, 0); + } + + void AppendPropReg(const char* name, uint64_t base, uint64_t size) { + uint8_t b[16]; + WriteU32BE(b+0, uint32_t(base >> 32)); + WriteU32BE(b+4, uint32_t(base)); + WriteU32BE(b+8, uint32_t(size >> 32)); + WriteU32BE(b+12, uint32_t(size)); + AppendPropRaw(name, b, 16); + } + + void AppendVirtioNode(uint64_t base, uint32_t spi) { + std::string name = "virtio_mmio@" + ToHex64(base); + AppendU32(FDT_BEGIN_NODE); + AppendStr(name); + Align4(); + AppendPropStr("compatible", "virtio,mmio"); + AppendPropReg("reg", base, kVirtioMmioSize); + AppendPropU32("interrupt-parent", 1); + uint8_t irq[12]; + WriteU32BE(irq+0, 0); // GIC_SPI + WriteU32BE(irq+4, spi); + WriteU32BE(irq+8, 1); // edge rising, matching QEMU virtio-mmio + AppendPropRaw("interrupts", irq, sizeof(irq)); + AppendPropFlag("dma-coherent"); + AppendU32(FDT_END_NODE); + } + + static std::string ToHex(uint32_t v) { + char buf[16]; + snprintf(buf, sizeof(buf), "%x", v); + return buf; + } + + static std::string ToHex64(uint64_t v) { + char buf[20]; + snprintf(buf, sizeof(buf), "%llx", (unsigned long long)v); + return buf; + } +}; + +// ─── ARM64 Image loader ─────────────────────────────────────────────────────── +// ARM64 Image header (little-endian): +// offset 0: code0 (branch instruction) +// offset 8: text_offset (uint64_t) +// offset 16: image_size (uint64_t) +// offset 56: magic = 0x644D5241 ("ARM\x64") +struct Arm64ImageInfo { + uint64_t load_offset; // text_offset from header (or 0x80000 default) + uint64_t image_size; + std::vector data; +}; + +Arm64ImageInfo LoadArm64Image(const std::string& path, GuestMemory& mem) { + std::vector data = ReadFile(path); + Check(data.size() >= 64, "ARM64 Image too small"); + constexpr uint32_t kArm64Magic = 0x644D5241; + uint32_t magic = ReadU32(data.data() + 56); + Check(magic == kArm64Magic, "not an ARM64 Image (bad magic at offset 56)"); + + uint64_t text_offset = ReadU64(data.data() + 8); + uint64_t image_size = ReadU64(data.data() + 16); + if (image_size == 0) image_size = static_cast(data.size()); + if (text_offset == 0) text_offset = 0x80000; // default for 2MiB-aligned images + + // Load at kRamBase + kKernelOffset (we always use 4MiB offset) + uint64_t load_gpa = kKernelOffset; + Check(load_gpa + data.size() <= mem.size(), "ARM64 Image too large for guest RAM"); + memcpy(mem.ptr(load_gpa, data.size()), data.data(), data.size()); + + Arm64ImageInfo info; + info.load_offset = load_gpa; + info.image_size = image_size; + info.data = std::move(data); + return info; +} + +// ─── ESR decode helpers ─────────────────────────────────────────────────────── +static inline uint32_t EsrEC(uint64_t esr) { return uint32_t((esr >> 26) & 0x3F); } +static inline bool EsrWnR(uint64_t esr) { return (esr >> 6) & 1; } +static inline uint32_t EsrSAS(uint64_t esr) { return uint32_t((esr >> 22) & 3); } +static inline uint32_t EsrSRT(uint64_t esr) { return uint32_t((esr >> 16) & 0x1F); } +static inline bool EsrISV(uint64_t esr) { return (esr >> 24) & 1; } +static inline bool EsrSSE(uint64_t esr) { return (esr >> 21) & 1; } + +constexpr uint32_t kSysRegOp0Shift = 20; +constexpr uint32_t kSysRegOp1Shift = 14; +constexpr uint32_t kSysRegCrnShift = 10; +constexpr uint32_t kSysRegCrmShift = 1; +constexpr uint32_t kSysRegOp2Shift = 17; +constexpr uint32_t SysReg(uint32_t op0, uint32_t op1, uint32_t crn, uint32_t crm, uint32_t op2) { + return (op0 << kSysRegOp0Shift) | + (op1 << kSysRegOp1Shift) | + (crn << kSysRegCrnShift) | + (crm << kSysRegCrmShift) | + (op2 << kSysRegOp2Shift); +} + +constexpr uint32_t kSysRegMask = SysReg(0x3, 0x7, 0xF, 0xF, 0x7); +constexpr uint32_t kSysRegOslArEl1 = SysReg(2, 0, 1, 0, 4); +constexpr uint32_t kSysRegOslSrEl1 = SysReg(2, 0, 1, 1, 4); +constexpr uint32_t kSysRegOsdLrEl1 = SysReg(2, 0, 1, 3, 4); +constexpr uint32_t kSysRegLorcEl1 = SysReg(3, 0, 10, 4, 3); +constexpr uint32_t kSysRegCntPctEl0 = SysReg(3, 3, 14, 0, 1); +constexpr uint32_t kSysRegCntpCtlEl0 = SysReg(3, 3, 14, 2, 1); + +static bool HandleSystemRegisterTrap(hv_vcpu_t vcpu, uint64_t syndrome) { + bool is_read = (syndrome & 1) != 0; + uint32_t rt = static_cast((syndrome >> 5) & 0x1F); + uint32_t reg = static_cast(syndrome) & kSysRegMask; + uint64_t value = 0; + bool handled = false; + + if (is_read) { + switch (reg) { + case kSysRegOslSrEl1: + case kSysRegOsdLrEl1: + case kSysRegLorcEl1: + value = 0; + handled = true; + break; + case kSysRegCntPctEl0: + value = MonotonicMicros() * 1000ULL; + handled = true; + break; + default: + break; + } + if (handled && rt < 31) { + CheckHv(hv_vcpu_set_reg(vcpu, static_cast(rt), value), + "hv_vcpu_set_reg sysreg read"); + } + return handled; + } + + switch (reg) { + case kSysRegOslArEl1: + case kSysRegOsdLrEl1: + case kSysRegLorcEl1: + case kSysRegCntpCtlEl0: + return true; + default: + return false; + } +} + +constexpr uint64_t kPsciVersion = 0x84000000ULL; +constexpr uint64_t kPsciCpuSuspend32 = 0x84000001ULL; +constexpr uint64_t kPsciCpuOff = 0x84000002ULL; +constexpr uint64_t kPsciCpuOn32 = 0x84000003ULL; +constexpr uint64_t kPsciAffinityInfo32 = 0x84000004ULL; +constexpr uint64_t kPsciMigrateInfoType = 0x84000006ULL; +constexpr uint64_t kPsciSystemOff = 0x84000008ULL; +constexpr uint64_t kPsciSystemReset = 0x84000009ULL; +constexpr uint64_t kPsciFeatures = 0x8400000AULL; +constexpr uint64_t kPsciCpuSuspend64 = 0xC4000001ULL; +constexpr uint64_t kPsciCpuOn64 = 0xC4000003ULL; +constexpr uint64_t kPsciAffinityInfo64 = 0xC4000004ULL; + +constexpr int64_t kPsciSuccess = 0; +constexpr int64_t kPsciNotSupported = -1; +constexpr int64_t kPsciInvalidParams = -2; +constexpr int64_t kPsciDenied = -3; +constexpr int64_t kPsciAlreadyOn = -4; + +static bool PsciFeatureSupported(uint64_t function_id) { + switch (function_id) { + case kPsciVersion: + case kPsciCpuOff: + case kPsciCpuOn32: + case kPsciCpuOn64: + case kPsciAffinityInfo32: + case kPsciAffinityInfo64: + case kPsciMigrateInfoType: + case kPsciSystemOff: + case kPsciSystemReset: + case kPsciFeatures: + return true; + default: + return false; + } +} + +// ─── PL011 UART ─────────────────────────────────────────────────────────────── +class Pl011Uart { + public: + Pl011Uart() = default; + + void read_mmio(uint64_t offset, uint8_t* data, uint32_t len) { + memset(data, 0, len); + if (len == 0 || len > 4) return; + uint32_t val = 0; + std::lock_guard lk(mu_); + switch (offset) { + case 0x000: // DR + if (!rx_.empty()) { + val = rx_.front(); rx_.pop_front(); + if (debug_uart_) { + fprintf(stderr, "[node-vmm hvf] uart rx read 0x%02x remaining=%zu\n", val, rx_.size()); + } + if (rx_.empty()) ris_ &= ~kRxInterrupts; + update_irq_locked(); + } + break; + case 0x018: // FR + val = 0x90; // TXFE | RXFE (TX empty, RX empty by default) + if (!rx_.empty()) val &= ~0x10; // clear RXFE + val &= ~0x20; // clear TXFF (always room to write) + break; + case 0x024: // IBRD + val = ibrd_; + break; + case 0x028: // FBRD + val = fbrd_; + break; + case 0x02C: // LCR_H + val = lcr_h_; + break; + case 0x030: // CR + val = cr_; + break; + case 0x034: // IFLS + val = ifls_; + break; + case 0x038: // IMSC + val = imsc_; + break; + case 0x03C: // RIS + val = ris_; + break; + case 0x040: // MIS + val = ris_ & imsc_; + break; + case 0x048: // DMACR + val = dmacr_; + break; + case 0xFE0: val = 0x11; break; // Peripheral ID0 + case 0xFE4: val = 0x10; break; // Peripheral ID1 + case 0xFE8: val = 0x14; break; // Peripheral ID2 + case 0xFEC: val = 0x00; break; // Peripheral ID3 + case 0xFF0: val = 0x0D; break; // PrimeCell ID0 + case 0xFF4: val = 0xF0; break; // PrimeCell ID1 + case 0xFF8: val = 0x05; break; // PrimeCell ID2 + case 0xFFC: val = 0xB1; break; // PrimeCell ID3 + default: + break; + } + WriteU32(data, val); + } + + void write_mmio(uint64_t offset, const uint8_t* data, uint32_t len) { + if (len == 0 || len > 4) return; + std::lock_guard lk(mu_); + switch (offset) { + case 0x000: { // DR - transmit + if (len < 1) return; + char c = static_cast(data[0]); + handle_tx_locked(c); + ris_ |= 0x20; // TXRIS + update_irq_locked(); + break; + } + default: break; + } + uint32_t val = ReadU32(data); + switch (offset) { + case 0x024: ibrd_ = val & 0xFFFF; break; + case 0x028: fbrd_ = val & 0x3F; break; + case 0x02C: lcr_h_ = val; break; + case 0x030: cr_ = val; break; + case 0x034: ifls_ = val & 0x3F; update_irq_locked(); break; + case 0x038: imsc_ = val; update_irq_locked(); break; + case 0x044: ris_ &= ~val; update_irq_locked(); break; // ICR + case 0x048: dmacr_ = val; break; // DMACR is accepted but not implemented. + default: break; + } + } + + void set_console_limit(size_t limit) { console_limit_ = limit; } + void set_echo_stdout(bool echo) { echo_stdout_ = echo; } + void set_debug(bool debug) { debug_uart_ = debug; } + void set_console_output_notify(std::function fn) { console_output_notify_ = std::move(fn); } + + std::string console() const { + std::lock_guard lk(mu_); + return console_; + } + + void inject_char(char c) { + std::lock_guard lk(mu_); + if (rx_.size() < 4096) rx_.push_back(static_cast(c)); + ris_ |= kRxInterrupts; + update_irq_locked(); + } + + void inject_bytes(const uint8_t* data, size_t len) { + std::lock_guard lk(mu_); + if (debug_uart_) { + fprintf(stderr, "[node-vmm hvf] uart inject len=%zu\n", len); + } + for (size_t i = 0; i < len && rx_.size() < 4096; i++) { + rx_.push_back(data[i]); + } + if (len > 0) ris_ |= kRxInterrupts; + update_irq_locked(); + } + + void refresh_irq() { + std::lock_guard lk(mu_); + update_irq_locked(); + } + + // Called without lock held — returns desired IRQ level + bool irq_pending() const { + std::lock_guard lk(mu_); + return (ris_ & imsc_) != 0; + } + + // Called from MMIO handler — fire GIC SPI after state update + // (gic_inject is called after releasing the lock) + bool consume_irq_level() { + std::lock_guard lk(mu_); + bool level = (ris_ & imsc_) != 0; + return level; + } + + void set_gic_inject(std::function fn) { gic_inject_ = std::move(fn); } + + private: + void emit_tx_locked(char c) { + if (console_.size() < console_limit_) { + console_.push_back(c); + } + if (echo_stdout_) { + if (!console_output_seen_) { + console_output_seen_ = true; + if (console_output_notify_) { + console_output_notify_(); + } + } + write(STDOUT_FILENO, &c, 1); + } + } + + void handle_tx_locked(char c) { + if (echo_stdout_) { + emit_tx_locked(c); + return; + } + static const std::string query = "\x1b[6n"; + if (!terminal_query_.empty() || c == 0x1B) { + terminal_query_.push_back(c); + if (query.rfind(terminal_query_, 0) == 0) { + if (terminal_query_ == query) { + static const char response[] = "\x1b[1;1R"; + for (size_t i = sizeof(response) - 1; i > 0 && rx_.size() < 4096; i--) { + rx_.push_front(static_cast(response[i - 1])); + } + ris_ |= kRxInterrupts; + terminal_query_.clear(); + } + return; + } + for (char pending : terminal_query_) { + emit_tx_locked(pending); + } + terminal_query_.clear(); + return; + } + emit_tx_locked(c); + } + + void update_irq_locked() { + if (!rx_.empty()) { + ris_ |= kRxInterrupts; + } else { + ris_ &= ~kRxInterrupts; + } + bool level = (ris_ & imsc_) != 0; + if (gic_inject_) gic_inject_(kUartIntid, level); + } + + mutable std::mutex mu_; + std::deque rx_; + std::string console_; + std::string terminal_query_; + size_t console_limit_{0}; + bool echo_stdout_{false}; + bool console_output_seen_{false}; + std::function console_output_notify_; + bool debug_uart_{false}; + uint32_t cr_{0x301}; // UARTEN|TXE|RXE + uint32_t ibrd_{13}; // 24MHz / (16 * 115200) ~= 13.02 + uint32_t fbrd_{1}; + uint32_t lcr_h_{0x70}; // 8-bit words, FIFO enabled + uint32_t ifls_{0x12}; // QEMU reset value: RX/TX interrupt at half FIFO + uint32_t dmacr_{0}; + uint32_t imsc_{0}; + uint32_t ris_{0}; + std::function gic_inject_; + + static constexpr uint32_t kRxInterrupts = 0x10 | 0x40; // RXRIS | RTRIS +}; + +// ─── PL031 RTC ──────────────────────────────────────────────────────────────── +class Pl031Rtc { + public: + Pl031Rtc() = default; + + void read_mmio(uint64_t offset, uint8_t* data, uint32_t len) { + memset(data, 0, len); + if (len == 0 || len > 4) return; + uint32_t val = 0; + std::lock_guard lk(mu_); + switch (offset) { + case 0x000: // DR + val = current_seconds_locked(); + break; + case 0x004: // MR + val = match_; + break; + case 0x008: // LR + val = current_seconds_locked(); + break; + case 0x00C: // CR + val = cr_; + break; + case 0x010: // IMSC + val = imsc_; + break; + case 0x014: // RIS + val = ris_; + break; + case 0x018: // MIS + val = ris_ & imsc_; + break; + case 0xFE0: val = 0x31; break; // Peripheral ID0 + case 0xFE4: val = 0x10; break; // Peripheral ID1 + case 0xFE8: val = 0x14; break; // Peripheral ID2 + case 0xFEC: val = 0x00; break; // Peripheral ID3 + case 0xFF0: val = 0x0D; break; // PrimeCell ID0 + case 0xFF4: val = 0xF0; break; // PrimeCell ID1 + case 0xFF8: val = 0x05; break; // PrimeCell ID2 + case 0xFFC: val = 0xB1; break; // PrimeCell ID3 + default: + break; + } + WriteU32(data, val); + } + + void write_mmio(uint64_t offset, const uint8_t* data, uint32_t len) { + if (len == 0 || len > 4) return; + uint32_t val = ReadU32(data); + std::lock_guard lk(mu_); + switch (offset) { + case 0x004: // MR + match_ = val; + ris_ = (match_ != 0 && current_seconds_locked() >= match_) ? 1 : 0; + break; + case 0x008: { // LR + time_t now = time(nullptr); + offset_seconds_ = int64_t(val) - int64_t(now < 0 ? 0 : now); + ris_ = (match_ != 0 && val >= match_) ? 1 : 0; + break; + } + case 0x00C: // CR + cr_ = val & 1; + break; + case 0x010: // IMSC + imsc_ = val & 1; + break; + case 0x01C: // ICR + ris_ &= ~val; + break; + default: + break; + } + } + + private: + uint32_t current_seconds_locked() const { + time_t now = time(nullptr); + int64_t seconds = int64_t(now < 0 ? 0 : now) + offset_seconds_; + if (seconds < 0) return 0; + if (seconds > UINT32_MAX) return UINT32_MAX; + return static_cast(seconds); + } + + mutable std::mutex mu_; + int64_t offset_seconds_{0}; + uint32_t match_{0}; + uint32_t cr_{1}; + uint32_t imsc_{0}; + uint32_t ris_{0}; +}; + +// ─── fw_cfg MMIO ────────────────────────────────────────────────────────────── +class FwCfgMmio { + public: + explicit FwCfgMmio(uint16_t cpus = 1) : cpus_(cpus) {} + + void read_mmio(uint64_t offset, uint8_t* data, uint32_t len) { + memset(data, 0, len); + if (len == 0 || len > 8) return; + std::lock_guard lk(mu_); + if (offset < 8) { + std::vector item = item_data_locked(); + for (uint32_t i = 0; i < len; i++) { + if (data_offset_ < item.size()) { + data[i] = item[data_offset_]; + } + data_offset_++; + } + return; + } + if (offset == 8 && len >= 2) { + WriteU16(data, selector_); + return; + } + // DMA is intentionally not implemented yet; FW_CFG_ID reports no DMA bit. + } + + void write_mmio(uint64_t offset, const uint8_t* data, uint32_t len) { + if (len == 0 || len > 8) return; + std::lock_guard lk(mu_); + if (offset == 8) { + uint16_t raw = len >= 2 ? ReadU16(data) : data[0]; + selector_ = normalize_selector(raw); + data_offset_ = 0; + } + } + + private: + static uint16_t normalize_selector(uint16_t raw) { + if ((raw & 0x00FF) == 0 && raw != 0) return raw >> 8; + return raw; + } + + std::vector item_data_locked() const { + switch (selector_) { + case 0x0000: // FW_CFG_SIGNATURE + return {'Q', 'E', 'M', 'U'}; + case 0x0001: // FW_CFG_ID: traditional interface, no DMA interface. + return {1, 0, 0, 0}; + case 0x0005: // FW_CFG_NB_CPUS + return {static_cast(cpus_ & 0xFF), static_cast(cpus_ >> 8)}; + case 0x0019: // FW_CFG_FILE_DIR with zero file entries. + return {0, 0, 0, 0}; + default: + return {}; + } + } + + std::mutex mu_; + uint16_t cpus_{1}; + uint16_t selector_{0}; + size_t data_offset_{0}; +}; + +// ─── PL061 GPIO ─────────────────────────────────────────────────────────────── +class Pl061Gpio { + public: + void read_mmio(uint64_t offset, uint8_t* data, uint32_t len) { + memset(data, 0, len); + if (len == 0 || len > 4) return; + uint32_t val = 0; + std::lock_guard lk(mu_); + if (offset <= 0x3FC) { + val = data_ & DataMask(offset); + } else { + switch (offset) { + case 0x400: val = dir_; break; + case 0x404: val = is_; break; + case 0x408: val = ibe_; break; + case 0x40C: val = iev_; break; + case 0x410: val = ie_; break; + case 0x414: val = ris_; break; + case 0x418: val = ris_ & ie_; break; + case 0x420: val = afsel_; break; + case 0xFE0: val = 0x61; break; // Peripheral ID0 + case 0xFE4: val = 0x10; break; // Peripheral ID1 + case 0xFE8: val = 0x14; break; // Peripheral ID2 + case 0xFEC: val = 0x00; break; // Peripheral ID3 + case 0xFF0: val = 0x0D; break; // PrimeCell ID0 + case 0xFF4: val = 0xF0; break; // PrimeCell ID1 + case 0xFF8: val = 0x05; break; // PrimeCell ID2 + case 0xFFC: val = 0xB1; break; // PrimeCell ID3 + default: break; + } + } + WriteU32(data, val); + } + + void write_mmio(uint64_t offset, const uint8_t* data, uint32_t len) { + if (len == 0 || len > 4) return; + uint32_t val = ReadU32(data) & 0xFF; + std::lock_guard lk(mu_); + if (offset <= 0x3FC) { + uint32_t mask = DataMask(offset) & dir_; + data_ = (data_ & ~mask) | (val & mask); + return; + } + switch (offset) { + case 0x400: dir_ = val; break; + case 0x404: is_ = val; break; + case 0x408: ibe_ = val; break; + case 0x40C: iev_ = val; break; + case 0x410: ie_ = val; break; + case 0x41C: ris_ &= ~val; break; + case 0x420: afsel_ = val; break; + default: break; + } + } + + private: + static uint32_t DataMask(uint64_t offset) { + return static_cast((offset >> 2) & 0xFF); + } + + std::mutex mu_; + uint32_t data_{0}; + uint32_t dir_{0}; + uint32_t is_{0}; + uint32_t ibe_{0}; + uint32_t iev_{0}; + uint32_t ie_{0}; + uint32_t ris_{0}; + uint32_t afsel_{0}; +}; + +// ─── Empty PCIe ECAM Host ──────────────────────────────────────────────────── +class EmptyPcieHost { + public: + void read_mmio(uint64_t /*offset*/, uint8_t* data, uint32_t len) { + memset(data, 0xFF, len); + } + + void write_mmio(uint64_t /*offset*/, const uint8_t* /*data*/, uint32_t /*len*/) {} +}; + +// ─── Empty virtio-mmio transport ────────────────────────────────────────────── +class EmptyVirtioMmio { + public: + void read_mmio(uint64_t offset, uint8_t* data, uint32_t len) { + memset(data, 0, len); + if (len != 4) return; + uint32_t val = 0; + switch (offset) { + case 0x000: val = 0x74726976; break; // "virt" + case 0x004: val = 2; break; + case 0x008: val = 0; break; // no backend attached + case 0x00C: val = 0x554D4551; break; // QEMU vendor ID + case 0x034: val = kMaxQueueSize; break; + case 0x070: val = 0; break; + case 0x0FC: val = 0; break; + default: break; + } + WriteU32(data, val); + } + + void write_mmio(uint64_t /*offset*/, const uint8_t* /*data*/, uint32_t /*len*/) {} +}; + +// ─── Virtio descriptor helpers ──────────────────────────────────────────────── +struct Desc { + uint64_t addr; + uint32_t len; + uint16_t flags; + uint16_t next; +}; + +struct DescChain { + std::array items {}; + size_t size{0}; + void push(Desc d) { + Check(size < items.size(), "virtio descriptor chain too long"); + items[size++] = d; + } + Desc& operator[](size_t i) { return items[i]; } + const Desc& operator[](size_t i) const { return items[i]; } + bool empty() const { return size == 0; } +}; + +struct VirtQueue { + uint32_t size{kMaxQueueSize}; + bool ready{false}; + uint16_t last_avail{0}; + uint64_t desc_addr{0}; + uint64_t driver_addr{0}; + uint64_t device_addr{0}; +}; + +// Virtio queue memory helpers. Queue addresses and descriptors are guest +// physical addresses, so they must be inside the mapped RAM IPA range. +static bool IsPowerOfTwo(uint32_t value) { + return value != 0 && (value & (value - 1)) == 0; +} + +static bool ValidVirtQueue(const GuestMemory& mem, const VirtQueue& q) { + if (!IsPowerOfTwo(q.size) || q.size > kMaxQueueSize) return false; + if ((q.desc_addr % 16) != 0 || (q.driver_addr % 2) != 0 || (q.device_addr % 4) != 0) return false; + if (!mem.contains_guest_range(q.desc_addr, uint64_t(q.size) * 16)) return false; + if (!mem.contains_guest_range(q.driver_addr, 6 + uint64_t(q.size) * 2)) return false; + if (!mem.contains_guest_range(q.device_addr, 6 + uint64_t(q.size) * 8)) return false; + return true; +} + +static Desc ReadDesc(GuestMemory& mem, const VirtQueue& q, uint16_t idx) { + Check(idx < q.size, "virtio descriptor index out of bounds"); + uint8_t* p = mem.guest_ptr(q.desc_addr + uint64_t(idx) * 16, 16); + return Desc{ReadU64(p), ReadU32(p+8), ReadU16(p+12), ReadU16(p+14)}; +} + +static DescChain WalkChain(GuestMemory& mem, const VirtQueue& q, uint16_t head) { + DescChain chain; + std::array seen{}; + uint16_t idx = head; + for (;;) { + Check(idx < q.size, "descriptor next out of bounds"); + Check(seen[idx] == 0, "descriptor cycle"); + seen[idx] = 1; + Desc d = ReadDesc(mem, q, idx); + Check((d.flags & 4) == 0, "indirect descriptors not supported"); + chain.push(d); + if ((d.flags & 1) == 0) break; + idx = d.next; + } + return chain; +} + +static void PushUsed(GuestMemory& mem, VirtQueue& q, uint32_t id, uint32_t written) { + uint8_t* idxp = mem.guest_ptr(q.device_addr + 2, 2); + uint16_t used = ReadU16(idxp); + uint64_t entry = q.device_addr + 4 + uint64_t(used % q.size) * 8; + WriteU32(mem.guest_ptr(entry, 8), id); + WriteU32(mem.guest_ptr(entry+4, 4), written); + WriteU16(idxp, used + 1); +} + +// ─── VirtioBlk ──────────────────────────────────────────────────────────────── +class VirtioBlk { + public: + VirtioBlk(GuestMemory mem, uint32_t slot, const std::string& path, + const std::string& overlay_path, bool read_only, + std::function gic_inject) + : mem_(mem), + base_(VirtioMmioBaseForSlot(slot)), + intid_(VirtioMmioIntidForSlot(slot)), + read_only_(read_only), + gic_inject_(std::move(gic_inject)) { + bool overlay = !overlay_path.empty(); + Check(!(read_only_ && overlay), "read-only disk cannot use an overlay"); + int flags = (overlay || read_only_) ? (O_RDONLY | O_CLOEXEC) : (O_RDWR | O_CLOEXEC); + base_fd_.reset(open(path.c_str(), flags)); + CheckErr(base_fd_.get(), "open rootfs " + path); + struct stat st{}; + CheckErr(fstat(base_fd_.get(), &st), "stat rootfs " + path); + Check(st.st_size >= 0, "negative rootfs size"); + disk_bytes_ = static_cast(st.st_size); + sectors_ = disk_bytes_ / 512; + if (overlay) { + Check(overlay_path != path, "overlayPath must not equal rootfsPath"); + overlay_fd_.reset(open(overlay_path.c_str(), O_RDWR | O_CREAT | O_TRUNC | O_CLOEXEC, 0600)); + CheckErr(overlay_fd_.get(), "open overlay " + overlay_path); + CheckErr(ftruncate(overlay_fd_.get(), st.st_size), "truncate overlay"); + dirty_sectors_.assign(static_cast((sectors_ + 7) / 8), 0); + } + queues_[0].size = kMaxQueueSize; + } + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + memset(data, 0, len); + uint32_t off = static_cast(addr - base_); + if (off < 0x100) { + if (len != 4) return; + WriteU32(data, read_reg(off)); + return; + } + uint8_t cfg[16]{}; + WriteU64(cfg, sectors_); + WriteU32(cfg+8, 0); + WriteU32(cfg+12, 128); + uint32_t cfg_off = off - 0x100; + if (cfg_off < sizeof(cfg)) + memcpy(data, cfg+cfg_off, std::min(len, sizeof(cfg)-cfg_off)); + } + + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + uint32_t off = static_cast(addr - base_); + if (off >= 0x100 || len != 4) return; + write_reg(off, ReadU32(data)); + } + + bool contains(uint64_t addr) const { + return addr >= base_ && addr < base_ + kVirtioMmioSize; + } + + private: + uint32_t read_reg(uint32_t off) { + switch (off) { + case 0x000: return 0x74726976; + case 0x004: return 2; + case 0x008: return 2; + case 0x00C: return 0x554D4551; + case 0x010: { + uint64_t f = (1ULL<<32)|(1ULL<<9); + return dev_features_sel_ == 1 ? uint32_t(f>>32) : uint32_t(f); + } + case 0x034: return kMaxQueueSize; + case 0x044: return queues_[queue_sel_].ready ? 1 : 0; + case 0x060: return interrupt_status_; + case 0x070: return status_; + case 0x0FC: return 0; + default: return 0; + } + } + + void write_reg(uint32_t off, uint32_t value) { + VirtQueue& q = queues_[queue_sel_]; + switch (off) { + case 0x014: dev_features_sel_ = value; break; + case 0x024: drv_features_sel_ = value; break; + case 0x020: + if (drv_features_sel_ == 0) drv_features_ = (drv_features_ & ~0xFFFFFFFFULL) | value; + else drv_features_ = (drv_features_ & 0xFFFFFFFFULL) | (uint64_t(value)<<32); + break; + case 0x030: if (value < 8) queue_sel_ = value; break; + case 0x038: if (value >= 1 && value <= kMaxQueueSize) q.size = value; break; + case 0x044: + if (value == 0) { + q.ready = false; + } else if (ValidVirtQueue(mem_, q)) { + q.ready = true; + } else { + q.ready = false; + status_ |= kVirtioStatusDeviceNeedsReset; + } + break; + case 0x050: + if (value < 8 && queues_[value].ready) { + handle_queue(queues_[value]); + interrupt_status_ |= 1; + gic_inject_(intid_, true); + } + break; + case 0x064: + interrupt_status_ &= ~value; + if (interrupt_status_ == 0) gic_inject_(intid_, false); + break; + case 0x070: + if (value == 0) reset(); + else status_ = value; + break; + case 0x080: q.desc_addr = (q.desc_addr & ~0xFFFFFFFFULL) | value; break; + case 0x084: q.desc_addr = (q.desc_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + case 0x090: q.driver_addr = (q.driver_addr & ~0xFFFFFFFFULL) | value; break; + case 0x094: q.driver_addr = (q.driver_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + case 0x0A0: q.device_addr = (q.device_addr & ~0xFFFFFFFFULL) | value; break; + case 0x0A4: q.device_addr = (q.device_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + default: break; + } + } + + void reset() { + status_ = 0; drv_features_ = 0; + dev_features_sel_ = 0; drv_features_sel_ = 0; + queue_sel_ = 0; interrupt_status_ = 0; + for (auto& q : queues_) q = VirtQueue{}; + gic_inject_(intid_, false); + } + + void handle_queue(VirtQueue& q) { + uint16_t avail = ReadU16(mem_.guest_ptr(q.driver_addr+2, 2)); + while (q.last_avail != avail) { + uint16_t head = ReadU16(mem_.guest_ptr(q.driver_addr+4+uint64_t(q.last_avail%q.size)*2, 2)); + q.last_avail++; + process_request(q, head); + } + } + + void process_request(VirtQueue& q, uint16_t head) { + uint8_t status = 0; + uint32_t written = 0; + try { + DescChain chain = WalkChain(mem_, q, head); + Check(chain.size >= 2, "virtio-blk chain too short"); + Desc hdr = chain[0]; + Desc stat_d = chain[chain.size-1]; + Check(hdr.len >= 16, "virtio-blk header too short"); + Check((stat_d.flags & 2) != 0 && stat_d.len >= 1, "virtio-blk status desc invalid"); + uint8_t h[16]; memcpy(h, mem_.guest_ptr(hdr.addr, 16), 16); + uint32_t type = ReadU32(h); + uint64_t sector = ReadU64(h+8); + if (type == 0) { + for (size_t i = 1; i+1 < chain.size; i++) { + Desc d = chain[i]; + Check((d.flags & 2) != 0, "virtio-blk read desc must be writable"); + Check((d.len % 512) == 0, "virtio-blk read length not sector-aligned"); + ReadDisk(sector, mem_.guest_ptr(d.addr, d.len), d.len); + Check(written <= std::numeric_limits::max() - d.len, + "virtio-blk read byte count overflow"); + written += d.len; sector += d.len / 512; + } + } else if (type == 1) { + for (size_t i = 1; i+1 < chain.size; i++) { + Desc d = chain[i]; + Check((d.flags & 2) == 0, "virtio-blk write desc must be read-only"); + Check((d.len % 512) == 0, "virtio-blk write length not sector-aligned"); + WriteDisk(sector, mem_.guest_ptr(d.addr, d.len), d.len); + sector += d.len / 512; + } + } else if (type == 4) { + CheckErr(fsync(write_fd()), "virtio-blk flush"); + } else if (type == 8) { + const char id[] = "node-vmm"; + for (size_t i = 1; i+1 < chain.size; i++) { + Desc d = chain[i]; + uint32_t n = std::min(d.len, sizeof(id)); + memcpy(mem_.guest_ptr(d.addr, n), id, n); + written += n; break; + } + } else { + status = 2; + } + memcpy(mem_.guest_ptr(stat_d.addr, 1), &status, 1); + PushUsed(mem_, q, head, written+1); + } catch (...) { + status = 1; + try { + DescChain chain = WalkChain(mem_, q, head); + if (!chain.empty()) { + Desc sd = chain[chain.size-1]; + memcpy(mem_.guest_ptr(sd.addr, 1), &status, 1); + } + } catch (...) {} + PushUsed(mem_, q, head, written); + } + } + + bool has_overlay() const { return overlay_fd_.get() >= 0; } + int write_fd() const { return has_overlay() ? overlay_fd_.get() : base_fd_.get(); } + + bool sector_dirty(uint64_t s) const { + if (!has_overlay() || s >= sectors_) return false; + return (dirty_sectors_[static_cast(s/8)] & (uint8_t(1)<<(s%8))) != 0; + } + void mark_dirty(uint64_t s) { + if (!has_overlay() || s >= sectors_) return; + dirty_sectors_[static_cast(s/8)] |= uint8_t(1)<<(s%8); + } + void mark_dirty_range(uint64_t off, size_t len) { + if (!has_overlay() || len == 0) return; + uint64_t last = off + static_cast(len) - 1; + for (uint64_t s = off/512; s <= last/512; s++) mark_dirty(s); + } + void prepare_partial_overlay(uint64_t off, size_t len) { + if (!has_overlay() || len == 0) return; + uint64_t end = off + static_cast(len); + std::array buf{}; + for (uint64_t s = off/512; s <= (end-1)/512; s++) { + uint64_t ss = s*512; + bool full = off<=ss && end>=ss+512; + if (full || sector_dirty(s)) continue; + PreadAll(base_fd_.get(), buf.data(), 512, static_cast(ss)); + PwriteAll(overlay_fd_.get(), buf.data(), 512, static_cast(ss)); + } + } + uint64_t DiskOffset(uint64_t sector, size_t len, const char* op) const { + Check(sector <= std::numeric_limits::max() / 512ULL, + std::string("virtio-blk ") + op + " sector offset overflow"); + uint64_t off = sector * 512ULL; + Check(off <= disk_bytes_ && uint64_t(len) <= disk_bytes_ - off, + std::string("virtio-blk ") + op + " out of range"); + uint64_t max_off = static_cast(std::numeric_limits::max()); + Check(off <= max_off, + std::string("virtio-blk ") + op + " offset exceeds host off_t"); + Check(len == 0 || uint64_t(len - 1) <= max_off - off, + std::string("virtio-blk ") + op + " range exceeds host off_t"); + return off; + } + void ReadDisk(uint64_t sector, uint8_t* dst, size_t len) { + uint64_t off = DiskOffset(sector, len, "read"); + if (!has_overlay()) { PreadAll(base_fd_.get(), dst, len, static_cast(off)); return; } + size_t done = 0; + while (done < len) { + uint64_t cur = off + done; + uint64_t s = cur / 512; + size_t in = static_cast(cur % 512); + size_t chunk = std::min(len-done, size_t(512)-in); + int fd = sector_dirty(s) ? overlay_fd_.get() : base_fd_.get(); + PreadAll(fd, dst+done, chunk, static_cast(cur)); + done += chunk; + } + } + void WriteDisk(uint64_t sector, const uint8_t* src, size_t len) { + Check(!read_only_, "virtio-blk write on read-only disk"); + uint64_t off = DiskOffset(sector, len, "write"); + prepare_partial_overlay(off, len); + PwriteAll(write_fd(), src, len, static_cast(off)); + mark_dirty_range(off, len); + } + + GuestMemory mem_; + uint64_t base_{kVirtioBlkBase}; + uint32_t intid_{kVirtioBlkIntid}; + bool read_only_{false}; + Fd base_fd_; + Fd overlay_fd_; + std::vector dirty_sectors_; + uint64_t disk_bytes_{0}; + uint64_t sectors_{0}; + uint32_t status_{0}; + uint64_t drv_features_{0}; + uint32_t dev_features_sel_{0}; + uint32_t drv_features_sel_{0}; + uint32_t queue_sel_{0}; + uint32_t interrupt_status_{0}; + VirtQueue queues_[8]; + std::function gic_inject_; +}; + +// ─── VirtioNet (vmnet.framework) ────────────────────────────────────────────── +std::array ParseMac(const std::string& mac) { + std::array out{}; + unsigned int p[6]{}; + Check(sscanf(mac.c_str(), "%x:%x:%x:%x:%x:%x", &p[0],&p[1],&p[2],&p[3],&p[4],&p[5]) == 6, + "invalid MAC: " + mac); + for (int i = 0; i < 6; i++) out[i] = static_cast(p[i]); + return out; +} + +struct NetPortForward { + std::string host; + uint16_t host_port{0}; + uint16_t guest_port{0}; +}; + +struct AttachedDiskConfig { + std::string path; + bool read_only{false}; +}; + +const char* VmnetStatusName(vmnet_return_t status) { + switch (status) { + case VMNET_SUCCESS: return "VMNET_SUCCESS"; + case VMNET_FAILURE: return "VMNET_FAILURE"; + case VMNET_MEM_FAILURE: return "VMNET_MEM_FAILURE"; + case VMNET_INVALID_ARGUMENT: return "VMNET_INVALID_ARGUMENT"; + case VMNET_SETUP_INCOMPLETE: return "VMNET_SETUP_INCOMPLETE"; + case VMNET_INVALID_ACCESS: return "VMNET_INVALID_ACCESS"; + case VMNET_PACKET_TOO_BIG: return "VMNET_PACKET_TOO_BIG"; + case VMNET_BUFFER_EXHAUSTED: return "VMNET_BUFFER_EXHAUSTED"; + case VMNET_TOO_MANY_PACKETS: return "VMNET_TOO_MANY_PACKETS"; + case VMNET_SHARING_SERVICE_BUSY: return "VMNET_SHARING_SERVICE_BUSY"; + case VMNET_NOT_AUTHORIZED: return "VMNET_NOT_AUTHORIZED"; + default: return "VMNET_UNKNOWN"; + } +} + +std::string VmnetStartError(vmnet_return_t status) { + std::string message = "vmnet_start_interface failed: "; + message += VmnetStatusName(status); + message += " (" + std::to_string(status) + ")"; + if (status == VMNET_FAILURE || status == VMNET_INVALID_ACCESS || status == VMNET_NOT_AUTHORIZED) { + message += "; macOS vmnet requires root or a valid com.apple.vm.networking entitlement"; + } + return message; +} + +class VirtioNet { + public: + VirtioNet(GuestMemory mem, uint32_t slot, const std::string& mac, + std::function gic_inject) + : mem_(mem), + base_(VirtioMmioBaseForSlot(slot)), + intid_(VirtioMmioIntidForSlot(slot)), + mac_(ParseMac(mac)), + gic_inject_(std::move(gic_inject)) { + queues_[0].size = kMaxQueueSize; + queues_[1].size = kMaxQueueSize; + // Create notification pipe + int pipefd[2]; + CheckErr(pipe(pipefd), "pipe for vmnet notification"); + notify_read_.reset(pipefd[0]); + notify_write_.reset(pipefd[1]); + int read_flags = fcntl(notify_read_.get(), F_GETFL); + CheckErr(read_flags, "fcntl notify read flags"); + CheckErr(fcntl(notify_read_.get(), F_SETFL, read_flags | O_NONBLOCK), + "fcntl notify read nonblock"); + int write_flags = fcntl(notify_write_.get(), F_GETFL); + CheckErr(write_flags, "fcntl notify write flags"); + CheckErr(fcntl(notify_write_.get(), F_SETFL, write_flags | O_NONBLOCK), + "fcntl notify write nonblock"); + } + + ~VirtioNet() { stop_vmnet(); } + + // Start vmnet in shared (NAT) mode + void start(const std::string& hint_tapname, + const std::string& host_ip, + const std::string& guest_ip, + const std::string& netmask, + const std::string& dns_ip, + const std::vector& port_forwards) { + if (hint_tapname == "slirp" || hint_tapname.rfind("slirp:", 0) == 0) { + start_slirp(host_ip, guest_ip, netmask, dns_ip, port_forwards); + return; + } + if (hint_tapname.rfind("socket_vmnet:", 0) == 0) { + start_socket_vmnet(hint_tapname.substr(strlen("socket_vmnet:"))); + return; + } + + xpc_object_t opts = xpc_dictionary_create(nullptr, nullptr, 0); + xpc_dictionary_set_uint64(opts, vmnet_operation_mode_key, VMNET_SHARED_MODE); + if (!host_ip.empty() && !guest_ip.empty() && !netmask.empty()) { + xpc_dictionary_set_string(opts, vmnet_start_address_key, host_ip.c_str()); + xpc_dictionary_set_string(opts, vmnet_end_address_key, guest_ip.c_str()); + xpc_dictionary_set_string(opts, vmnet_subnet_mask_key, netmask.c_str()); + } + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block vmnet_return_t status_out = VMNET_SUCCESS; + __block interface_ref iface_out = nullptr; + + iface_ = vmnet_start_interface(opts, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), + ^(vmnet_return_t status, xpc_object_t params) { + status_out = status; + if (status == VMNET_SUCCESS) { + iface_out = iface_; + max_packet_size_ = static_cast( + xpc_dictionary_get_uint64(params, vmnet_max_packet_size_key)); + if (max_packet_size_ == 0) max_packet_size_ = kMaxFrameSize; + } + dispatch_semaphore_signal(sem); + }); + + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + dispatch_release(sem); + xpc_release(opts); + + Check(status_out == VMNET_SUCCESS, VmnetStartError(status_out)); + iface_ = iface_out; + + // Register packet arrival callback + VirtioNet* self = this; + vmnet_interface_set_event_callback(iface_, VMNET_INTERFACE_PACKETS_AVAILABLE, + dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), + ^(interface_event_t /*event*/, xpc_object_t /*params*/) { + self->notify_host(); + }); + } + + void stop_vmnet() { + stop_slirp(); + stop_socket_vmnet(); + if (!iface_) return; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + vmnet_stop_interface(iface_, dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), + ^(vmnet_return_t /*status*/) { dispatch_semaphore_signal(sem); }); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + dispatch_release(sem); + iface_ = nullptr; + } + + bool notify_readable() const { + struct pollfd pfd{}; + pfd.fd = notify_read_.get(); + pfd.events = POLLIN; + return poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN); + } + + void drain_notify() { + std::lock_guard lk(notify_mu_); + uint8_t buf[64]; + for (;;) { + ssize_t n = read(notify_read_.get(), buf, sizeof(buf)); + if (n > 0) continue; + if (n < 0 && errno == EINTR) continue; + break; + } + notify_pending_ = false; + } + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + memset(data, 0, len); + uint32_t off = static_cast(addr - base_); + if (off < 0x100) { + if (len != 4) return; + WriteU32(data, read_reg(off)); + return; + } + uint8_t cfg[12]{}; + memcpy(cfg, mac_.data(), 6); + WriteU16(cfg+6, 1); + WriteU16(cfg+8, 1); + WriteU16(cfg+10, 1500); + uint32_t cfg_off = off - 0x100; + if (cfg_off < sizeof(cfg)) + memcpy(data, cfg+cfg_off, std::min(len, sizeof(cfg)-cfg_off)); + } + + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + uint32_t off = static_cast(addr - base_); + if (off >= 0x100 || len != 4) return; + write_reg(off, ReadU32(data)); + } + + bool contains(uint64_t addr) const { + return addr >= base_ && addr < base_ + kVirtioMmioSize; + } + + void poll_rx() { + if (!enabled() || !queues_[0].ready) return; + drain_notify(); + poll_slirp(); + if (socket_fd_.get() >= 0 || slirp_enabled()) { + for (int iter = 0; iter < 32; iter++) { + if (!has_rx_buffer()) break; + std::vector frame; + { + std::lock_guard lk(rx_frames_mu_); + if (rx_frames_.empty()) break; + frame = std::move(rx_frames_.front()); + rx_frames_.pop_front(); + } + if (!frame.empty()) inject_rx_frame(frame.data(), frame.size()); + } + return; + } + if (!iface_) return; + for (int iter = 0; iter < 32; iter++) { + if (!has_rx_buffer()) break; + // Read packet from vmnet + struct vmpktdesc pktdesc{}; + struct iovec iov{}; + std::array frame_buf{}; + iov.iov_base = frame_buf.data(); + iov.iov_len = frame_buf.size(); + pktdesc.vm_pkt_iov = &iov; + pktdesc.vm_pkt_iovcnt = 1; + pktdesc.vm_flags = 0; + int count = 1; + vmnet_return_t ret = vmnet_read(iface_, &pktdesc, &count); + if (ret != VMNET_SUCCESS || count == 0) break; + inject_rx_frame(frame_buf.data(), pktdesc.vm_pkt_size); + } + } + + bool enabled() const { return iface_ != nullptr || socket_fd_.get() >= 0 || slirp_enabled(); } + bool needs_poll() const { return slirp_enabled(); } + void set_wake_callback(std::function wake_vcpus) { + std::lock_guard lk(wake_mu_); + wake_vcpus_ = std::move(wake_vcpus); + } + + private: + void notify_host() { + { + std::lock_guard lk(notify_mu_); + if (!notify_pending_) { + uint8_t byte = 1; + for (;;) { + ssize_t n = write(notify_write_.get(), &byte, 1); + if (n == 1 || (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK))) { + notify_pending_ = true; + break; + } + if (n < 0 && errno == EINTR) continue; + break; + } + } + } + std::function wake; + { + std::lock_guard lk(wake_mu_); + wake = wake_vcpus_; + } + if (wake) wake(); + } + + static bool read_exact(int fd, uint8_t* data, size_t len) { + size_t off = 0; + while (off < len) { + ssize_t n = read(fd, data + off, len - off); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + if (n == 0) return false; + off += static_cast(n); + } + return true; + } + + static bool write_exact(int fd, const uint8_t* data, size_t len) { + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, data + off, len - off); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + off += static_cast(n); + } + return true; + } + + void start_socket_vmnet(const std::string& path) { + Check(!path.empty(), "socket_vmnet path is empty"); + Check(path.size() < sizeof(sockaddr_un::sun_path), "socket_vmnet path is too long"); + + Fd fd(socket(AF_UNIX, SOCK_STREAM, 0)); + CheckErr(fd.get(), "socket_vmnet socket"); + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path.c_str(), sizeof(addr.sun_path) - 1); + CheckErr(connect(fd.get(), reinterpret_cast(&addr), sizeof(addr)), "connect socket_vmnet"); + + socket_fd_ = std::move(fd); + socket_done_.store(false, std::memory_order_relaxed); + int read_fd = socket_fd_.get(); + socket_thread_ = std::thread([this, read_fd]() { + for (;;) { + if (socket_done_.load(std::memory_order_relaxed)) break; + uint32_t len_be = 0; + if (!read_exact(read_fd, reinterpret_cast(&len_be), sizeof(len_be))) break; + uint32_t len = ntohl(len_be); + if (len == 0 || len > 65536) break; + std::vector frame(len); + if (!read_exact(read_fd, frame.data(), frame.size())) break; + { + std::lock_guard lk(rx_frames_mu_); + rx_frames_.push_back(std::move(frame)); + } + notify_host(); + } + }); + } + + void stop_socket_vmnet() { + socket_done_.store(true, std::memory_order_relaxed); + if (socket_fd_.get() >= 0) { + shutdown(socket_fd_.get(), SHUT_RDWR); + socket_fd_.reset(); + } + if (socket_thread_.joinable()) socket_thread_.join(); + } + + bool slirp_enabled() const { +#if NODE_VMM_HAVE_SLIRP + return slirp_ != nullptr; +#else + return false; +#endif + } + + void start_slirp(const std::string& host_ip, + const std::string& guest_ip, + const std::string& netmask, + const std::string& dns_ip, + const std::vector& port_forwards) { +#if NODE_VMM_HAVE_SLIRP + in_addr host_addr{}; + in_addr guest_addr{}; + in_addr mask_addr{}; + in_addr dns_addr{}; + Check(inet_pton(AF_INET, host_ip.c_str(), &host_addr) == 1, "invalid slirp host IP: " + host_ip); + Check(inet_pton(AF_INET, guest_ip.c_str(), &guest_addr) == 1, "invalid slirp guest IP: " + guest_ip); + Check(inet_pton(AF_INET, netmask.c_str(), &mask_addr) == 1, "invalid slirp netmask: " + netmask); + const std::string effective_dns = dns_ip.empty() ? host_ip : dns_ip; + Check(inet_pton(AF_INET, effective_dns.c_str(), &dns_addr) == 1, "invalid slirp DNS IP: " + effective_dns); + + in_addr network_addr{}; + network_addr.s_addr = htonl(ntohl(host_addr.s_addr) & ntohl(mask_addr.s_addr)); + + SlirpConfig cfg{}; + cfg.version = SLIRP_CONFIG_VERSION_MAX; + cfg.restricted = 0; + cfg.in_enabled = true; + cfg.vnetwork = network_addr; + cfg.vnetmask = mask_addr; + cfg.vhost = host_addr; + cfg.in6_enabled = false; + cfg.vhostname = "node-vmm"; + cfg.vdhcp_start = guest_addr; + cfg.vnameserver = dns_addr; + cfg.if_mtu = 1500; + cfg.if_mru = 1500; + cfg.disable_host_loopback = false; + + memset(&slirp_cb_, 0, sizeof(slirp_cb_)); + slirp_cb_.send_packet = &VirtioNet::slirp_send_packet_cb; + slirp_cb_.guest_error = &VirtioNet::slirp_guest_error_cb; + slirp_cb_.clock_get_ns = &VirtioNet::slirp_clock_get_ns_cb; + slirp_cb_.notify = &VirtioNet::slirp_notify_cb; + slirp_cb_.register_poll_socket = &VirtioNet::slirp_register_poll_socket_cb; + slirp_cb_.unregister_poll_socket = &VirtioNet::slirp_unregister_poll_socket_cb; + + slirp_ = slirp_new(&cfg, &slirp_cb_, this); + Check(slirp_ != nullptr, "slirp_new failed"); + + for (const NetPortForward& forward : port_forwards) { + in_addr host_bind{}; + const std::string bind_host = forward.host.empty() ? "127.0.0.1" : forward.host; + Check(inet_pton(AF_INET, bind_host.c_str(), &host_bind) == 1, + "invalid slirp host forward bind IP: " + bind_host); + int rc = slirp_add_hostfwd(slirp_, 0, host_bind, forward.host_port, guest_addr, forward.guest_port); + Check(rc == 0, "slirp host forward failed: " + bind_host + ":" + + std::to_string(forward.host_port) + " -> " + guest_ip + ":" + + std::to_string(forward.guest_port)); + } +#else + (void)host_ip; + (void)guest_ip; + (void)netmask; + (void)dns_ip; + (void)port_forwards; + throw std::runtime_error("HVF slirp networking is not available in this build; install libslirp or use vmnet/socket_vmnet"); +#endif + } + + void stop_slirp() { +#if NODE_VMM_HAVE_SLIRP + if (!slirp_) return; + slirp_cleanup(slirp_); + slirp_ = nullptr; +#endif + } + +#if NODE_VMM_HAVE_SLIRP + struct SlirpPollSet { + std::vector fds; + }; +#endif + + void poll_slirp() { +#if NODE_VMM_HAVE_SLIRP + if (!slirp_) return; + uint32_t timeout = 0; + SlirpPollSet poll_set; + slirp_pollfds_fill_socket(slirp_, &timeout, &VirtioNet::slirp_add_poll_cb, &poll_set); + int rc = 0; + if (!poll_set.fds.empty()) { + rc = poll(poll_set.fds.data(), static_cast(poll_set.fds.size()), 0); + if (rc < 0 && errno == EINTR) rc = 0; + } + slirp_pollfds_poll(slirp_, rc < 0 ? 1 : 0, &VirtioNet::slirp_get_revents_cb, &poll_set); +#endif + } + +#if NODE_VMM_HAVE_SLIRP + static short poll_events_from_slirp(int events) { + short out = 0; + if (events & SLIRP_POLL_IN) out |= POLLIN; + if (events & SLIRP_POLL_OUT) out |= POLLOUT; + if (events & SLIRP_POLL_PRI) out |= POLLPRI; + if (events & SLIRP_POLL_ERR) out |= POLLERR; + if (events & SLIRP_POLL_HUP) out |= POLLHUP; + return out; + } + + static int slirp_events_from_poll(short events) { + int out = 0; + if (events & POLLIN) out |= SLIRP_POLL_IN; + if (events & POLLOUT) out |= SLIRP_POLL_OUT; + if (events & POLLPRI) out |= SLIRP_POLL_PRI; + if (events & POLLERR) out |= SLIRP_POLL_ERR; + if (events & POLLHUP) out |= SLIRP_POLL_HUP; + return out; + } + + static int slirp_add_poll_cb(slirp_os_socket fd, int events, void* opaque) { + auto* set = static_cast(opaque); + pollfd pfd{}; + pfd.fd = fd; + pfd.events = poll_events_from_slirp(events); + set->fds.push_back(pfd); + return static_cast(set->fds.size() - 1); + } + + static int slirp_get_revents_cb(int idx, void* opaque) { + auto* set = static_cast(opaque); + if (idx < 0 || static_cast(idx) >= set->fds.size()) return 0; + return slirp_events_from_poll(set->fds[static_cast(idx)].revents); + } + + static slirp_ssize_t slirp_send_packet_cb(const void* buf, size_t len, void* opaque) { + return static_cast(opaque)->slirp_send_packet(buf, len); + } + + slirp_ssize_t slirp_send_packet(const void* buf, size_t len) { + if (!buf || len == 0) return 0; + const auto* bytes = static_cast(buf); + { + std::lock_guard lk(rx_frames_mu_); + rx_frames_.emplace_back(bytes, bytes + len); + } + notify_host(); + return static_cast(len); + } + + static void slirp_guest_error_cb(const char* msg, void* /*opaque*/) { + if (HvfDebugEnabled() && msg) { + fprintf(stderr, "[node-vmm hvf] slirp guest error: %s\n", msg); + } + } + + static int64_t slirp_clock_get_ns_cb(void* /*opaque*/) { + struct timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return int64_t(ts.tv_sec) * 1000000000LL + int64_t(ts.tv_nsec); + } + + static void slirp_notify_cb(void* opaque) { + auto* self = static_cast(opaque); + self->notify_host(); + } + + static void slirp_register_poll_socket_cb(slirp_os_socket /*socket*/, void* opaque) { + slirp_notify_cb(opaque); + } + + static void slirp_unregister_poll_socket_cb(slirp_os_socket /*socket*/, void* opaque) { + slirp_notify_cb(opaque); + } +#endif + + uint32_t read_reg(uint32_t off) { + switch (off) { + case 0x000: return 0x74726976; + case 0x004: return 2; + case 0x008: return 1; + case 0x00C: return 0x554D4551; + case 0x010: { + uint64_t f = (1ULL<<32)|(1ULL<<5)|(1ULL<<16); + return dev_features_sel_ == 1 ? uint32_t(f>>32) : uint32_t(f); + } + case 0x034: return queue_sel_ < 2 ? kMaxQueueSize : 0; + case 0x044: return queue_sel_ < 2 && queues_[queue_sel_].ready ? 1 : 0; + case 0x060: return interrupt_status_; + case 0x070: return status_; + case 0x0FC: return 0; + default: return 0; + } + } + + void write_reg(uint32_t off, uint32_t value) { + VirtQueue& q = queues_[queue_sel_ < 2 ? queue_sel_ : 0]; + switch (off) { + case 0x014: dev_features_sel_ = value; break; + case 0x024: drv_features_sel_ = value; break; + case 0x020: + if (drv_features_sel_ == 0) drv_features_ = (drv_features_ & ~0xFFFFFFFFULL) | value; + else drv_features_ = (drv_features_ & 0xFFFFFFFFULL) | (uint64_t(value)<<32); + break; + case 0x030: if (value < 2) queue_sel_ = value; break; + case 0x038: if (value >= 1 && value <= kMaxQueueSize) q.size = value; break; + case 0x044: + if (value == 0) { + q.ready = false; + } else if (ValidVirtQueue(mem_, q)) { + q.ready = true; + } else { + q.ready = false; + status_ |= kVirtioStatusDeviceNeedsReset; + } + break; + case 0x050: + if (value == 0) poll_rx(); + else if (value == 1 && queues_[1].ready) handle_tx_queue(); + break; + case 0x064: + interrupt_status_ &= ~value; + if (interrupt_status_ == 0) gic_inject_(intid_, false); + break; + case 0x070: + if (value == 0) reset_dev(); + else status_ = value; + break; + case 0x080: q.desc_addr = (q.desc_addr & ~0xFFFFFFFFULL) | value; break; + case 0x084: q.desc_addr = (q.desc_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + case 0x090: q.driver_addr = (q.driver_addr & ~0xFFFFFFFFULL) | value; break; + case 0x094: q.driver_addr = (q.driver_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + case 0x0A0: q.device_addr = (q.device_addr & ~0xFFFFFFFFULL) | value; break; + case 0x0A4: q.device_addr = (q.device_addr & 0xFFFFFFFFULL) | (uint64_t(value)<<32); break; + default: break; + } + } + + void reset_dev() { + status_ = 0; drv_features_ = 0; + dev_features_sel_ = 0; drv_features_sel_ = 0; + queue_sel_ = 0; interrupt_status_ = 0; + for (auto& q : queues_) q = VirtQueue{}; + gic_inject_(intid_, false); + } + + bool has_rx_buffer() const { + const VirtQueue& q = queues_[0]; + return q.ready && q.last_avail != ReadU16(mem_.guest_ptr(q.driver_addr+2, 2)); + } + + void signal_net() { + interrupt_status_ |= 1; + gic_inject_(intid_, true); + } + + void inject_rx_frame(const uint8_t* frame, size_t len) { + VirtQueue& q = queues_[0]; + Check(len <= kMaxFrameSize, "virtio-net rx frame too large"); + uint16_t head = ReadU16(mem_.guest_ptr(q.driver_addr+4+uint64_t(q.last_avail%q.size)*2, 2)); + q.last_avail++; + DescChain chain = WalkChain(mem_, q, head); + size_t needed = 12 + len; + size_t offset = 0; + uint8_t header[12]{}; + for (size_t i = 0; i < chain.size; i++) { + const Desc& d = chain[i]; + Check((d.flags & 2) != 0, "virtio-net rx desc must be writable"); + uint8_t* dst = mem_.guest_ptr(d.addr, d.len); + size_t desc_off = 0; + if (offset < sizeof(header) && desc_off < d.len) { + size_t n = std::min(d.len-desc_off, sizeof(header)-offset); + memcpy(dst+desc_off, header+offset, n); + desc_off += n; offset += n; + } + if (offset >= sizeof(header) && desc_off < d.len && offset < needed) { + size_t frame_off = offset - sizeof(header); + size_t n = std::min(d.len-desc_off, len-frame_off); + memcpy(dst+desc_off, frame+frame_off, n); + offset += n; + } + if (offset >= needed) break; + } + if (offset < needed) return; + PushUsed(mem_, q, head, static_cast(needed)); + signal_net(); + } + + void handle_tx_queue() { + VirtQueue& q = queues_[1]; + uint16_t avail = ReadU16(mem_.guest_ptr(q.driver_addr+2, 2)); + while (q.last_avail != avail) { + uint16_t head = ReadU16(mem_.guest_ptr(q.driver_addr+4+uint64_t(q.last_avail%q.size)*2, 2)); + q.last_avail++; + process_tx(head); + } + poll_rx(); + } + + void process_tx(uint16_t head) { + VirtQueue& q = queues_[1]; + DescChain chain = WalkChain(mem_, q, head); + std::vector frame; + frame.reserve(kMaxFrameSize); + size_t skip = 12; + for (size_t i = 0; i < chain.size; i++) { + const Desc& d = chain[i]; + Check((d.flags & 2) == 0, "virtio-net tx desc must be read-only"); + const uint8_t* src = mem_.guest_ptr(d.addr, d.len); + if (skip >= d.len) { skip -= d.len; continue; } + size_t pos = skip; skip = 0; + Check(frame.size() <= kMaxFrameSize && + static_cast(d.len - pos) <= kMaxFrameSize - frame.size(), + "virtio-net tx frame too large"); + frame.insert(frame.end(), src+pos, src+d.len); + } + if (slirp_enabled() && !frame.empty()) { +#if NODE_VMM_HAVE_SLIRP + slirp_input(slirp_, frame.data(), static_cast(frame.size())); +#endif + } else if (socket_fd_.get() >= 0 && !frame.empty()) { + uint32_t len_be = htonl(static_cast(frame.size())); + write_exact(socket_fd_.get(), reinterpret_cast(&len_be), sizeof(len_be)); + write_exact(socket_fd_.get(), frame.data(), frame.size()); + } else if (iface_ && !frame.empty()) { + struct vmpktdesc pktdesc{}; + struct iovec iov{}; + iov.iov_base = frame.data(); + iov.iov_len = frame.size(); + pktdesc.vm_pkt_iov = &iov; + pktdesc.vm_pkt_iovcnt = 1; + pktdesc.vm_pkt_size = frame.size(); + pktdesc.vm_flags = 0; + int count = 1; + vmnet_write(iface_, &pktdesc, &count); + } + PushUsed(mem_, q, head, 0); + signal_net(); + } + + GuestMemory mem_; + uint64_t base_{kVirtioNetBase}; + uint32_t intid_{kVirtioNetIntid}; + std::array mac_; + std::function gic_inject_; + interface_ref iface_{nullptr}; + Fd socket_fd_; + std::atomic socket_done_{false}; + std::thread socket_thread_; + std::mutex rx_frames_mu_; + std::deque> rx_frames_; +#if NODE_VMM_HAVE_SLIRP + Slirp* slirp_{nullptr}; + SlirpCb slirp_cb_{}; +#endif + uint32_t max_packet_size_{kMaxFrameSize}; + Fd notify_read_; + Fd notify_write_; + std::mutex notify_mu_; + bool notify_pending_{false}; + std::mutex wake_mu_; + std::function wake_vcpus_; + uint32_t status_{0}; + uint64_t drv_features_{0}; + uint32_t dev_features_sel_{0}; + uint32_t drv_features_sel_{0}; + uint32_t queue_sel_{0}; + uint32_t interrupt_status_{0}; + VirtQueue queues_[2]; +}; + +// ─── NAPI helpers ───────────────────────────────────────────────────────────── +static napi_value MakeString(napi_env env, const std::string& s) { + napi_value v; + napi_create_string_utf8(env, s.c_str(), s.size(), &v); + return v; +} +static napi_value MakeU32(napi_env env, uint32_t n) { + napi_value v; napi_create_uint32(env, n, &v); return v; +} +static napi_value MakeBool(napi_env env, bool b) { + napi_value v; napi_get_boolean(env, b, &v); return v; +} +static napi_value MakeObject(napi_env env) { + napi_value v; napi_create_object(env, &v); return v; +} +static void SetProp(napi_env env, napi_value obj, const char* key, napi_value val) { + napi_set_named_property(env, obj, key, val); +} +static bool HasNamed(napi_env env, napi_value obj, const char* key) { + bool has = false; + napi_has_named_property(env, obj, key, &has); + return has; +} +static bool IsNullish(napi_env env, napi_value value) { + napi_valuetype type = napi_undefined; + napi_typeof(env, value, &type); + return type == napi_undefined || type == napi_null; +} +static std::string GetString(napi_env env, napi_value obj, const char* key) { + if (!HasNamed(env, obj, key)) return ""; + napi_value v; napi_get_named_property(env, obj, key, &v); + napi_valuetype t; napi_typeof(env, v, &t); + if (t != napi_string) return ""; + size_t len; napi_get_value_string_utf8(env, v, nullptr, 0, &len); + std::vector buf(len + 1, '\0'); + napi_get_value_string_utf8(env, v, buf.data(), buf.size(), nullptr); + return std::string(buf.data(), len); +} +static uint32_t GetU32(napi_env env, napi_value obj, const char* key, uint32_t def = 0) { + if (!HasNamed(env, obj, key)) return def; + napi_value v; napi_get_named_property(env, obj, key, &v); + napi_valuetype t; napi_typeof(env, v, &t); + if (t != napi_number) return def; + uint32_t n; napi_get_value_uint32(env, v, &n); return n; +} +static bool GetBool(napi_env env, napi_value obj, const char* key, bool def = false) { + if (!HasNamed(env, obj, key)) return def; + napi_value v; napi_get_named_property(env, obj, key, &v); + napi_valuetype t; napi_typeof(env, v, &t); + if (t != napi_boolean) return def; + bool b; napi_get_value_bool(env, v, &b); return b; +} + +static std::vector GetPortForwards(napi_env env, napi_value obj, const char* key) { + std::vector out; + if (!HasNamed(env, obj, key)) return out; + napi_value arr; napi_get_named_property(env, obj, key, &arr); + bool is_array = false; + napi_is_array(env, arr, &is_array); + if (!is_array) return out; + uint32_t len = 0; + napi_get_array_length(env, arr, &len); + for (uint32_t i = 0; i < len; i++) { + napi_value item; + napi_get_element(env, arr, i, &item); + NetPortForward forward; + forward.host = GetString(env, item, "host"); + uint32_t host_port = GetU32(env, item, "hostPort", 0); + uint32_t guest_port = GetU32(env, item, "guestPort", 0); + Check(host_port > 0 && host_port <= 65535, "invalid net hostPort"); + Check(guest_port > 0 && guest_port <= 65535, "invalid net guestPort"); + forward.host_port = static_cast(host_port); + forward.guest_port = static_cast(guest_port); + out.push_back(std::move(forward)); + } + return out; +} + +static bool HasNonNullishNamed(napi_env env, napi_value obj, const char* key) { + if (!HasNamed(env, obj, key)) return false; + napi_value value; + napi_get_named_property(env, obj, key, &value); + return !IsNullish(env, value); +} + +static std::vector GetAttachedDisks(napi_env env, napi_value obj) { + bool has_disks = HasNonNullishNamed(env, obj, "disks"); + bool has_attached_disks = HasNonNullishNamed(env, obj, "attachedDisks"); + if (!has_disks && !has_attached_disks) return {}; + Check(!(has_disks && has_attached_disks), "use either disks or attachedDisks, not both"); + + const char* name = has_disks ? "disks" : "attachedDisks"; + napi_value value; + napi_get_named_property(env, obj, name, &value); + bool is_array = false; + napi_is_array(env, value, &is_array); + Check(is_array, std::string(name) + " must be an array"); + + uint32_t length = 0; + napi_get_array_length(env, value, &length); + std::vector out; + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, value, i, &entry); + napi_valuetype type = napi_undefined; + napi_typeof(env, entry, &type); + Check(type == napi_object, std::string(name) + " entries must be objects"); + AttachedDiskConfig disk; + disk.path = GetString(env, entry, "path"); + Check(!disk.path.empty(), std::string(name) + " entries require path"); + disk.read_only = GetBool(env, entry, "readOnly", GetBool(env, entry, "readonly", false)); + out.push_back(std::move(disk)); + } + return out; +} + +struct RunControl { + int32_t* words{nullptr}; + size_t length{0}; + + bool enabled() const { return words != nullptr && length >= 2; } + + int32_t command() const { + if (!enabled()) return kControlRun; + return __atomic_load_n(&words[0], __ATOMIC_SEQ_CST); + } + + void set_state(int32_t state) const { + if (enabled()) { + __atomic_store_n(&words[1], state, __ATOMIC_SEQ_CST); + } + } + + void mark_console_output() const { + if (words != nullptr && length >= 3) { + __atomic_store_n(&words[2], 1, __ATOMIC_SEQ_CST); + } + } +}; + +static RunControl GetRunControl(napi_env env, napi_value obj) { + if (!HasNamed(env, obj, "control")) return {}; + napi_value value; napi_get_named_property(env, obj, "control", &value); + if (IsNullish(env, value)) return {}; + + bool is_typedarray = false; + Check(napi_is_typedarray(env, value, &is_typedarray) == napi_ok, "napi_is_typedarray control failed"); + Check(is_typedarray, "control must be an Int32Array"); + + napi_typedarray_type type; + size_t length = 0; + void* data = nullptr; + napi_value arraybuffer; + size_t byte_offset = 0; + Check(napi_get_typedarray_info(env, value, &type, &length, &data, &arraybuffer, &byte_offset) == napi_ok, + "napi_get_typedarray_info control failed"); + Check(type == napi_int32_array, "control must be an Int32Array"); + Check(length >= 2, "control Int32Array must have at least 2 entries"); + return {reinterpret_cast(data), length}; +} + +struct ControlExitGuard { + RunControl control; + ~ControlExitGuard() { control.set_state(kControlStateExited); } +}; + +struct HvMapGuard { + uint64_t ipa{0}; + uint64_t size{0}; + bool active{false}; + + ~HvMapGuard() { unmap(); } + + void unmap() { + if (!active) return; + hv_vm_unmap(ipa, size); + active = false; + } +}; + +struct VcpuGuard { + hv_vcpu_t vcpu{}; + bool active{false}; + + ~VcpuGuard() { destroy(); } + + void destroy() { + if (!active) return; + hv_vcpu_destroy(vcpu); + active = false; + } +}; + +struct MonitorThreadGuard { + std::atomic& done; + std::thread& thread; + + ~MonitorThreadGuard() { stop(); } + + void stop() { + done.store(true, std::memory_order_relaxed); + if (thread.joinable()) thread.join(); + } +}; + +struct InputThreadGuard { + std::atomic& done; + std::thread& thread; + + ~InputThreadGuard() { stop(); } + + void stop() { + done.store(true, std::memory_order_relaxed); + if (thread.joinable()) thread.join(); + } +}; + +class TerminalRawMode { + public: + explicit TerminalRawMode(bool enable) { + if (!enable || !isatty(STDIN_FILENO)) return; + if (tcgetattr(STDIN_FILENO, &old_) != 0) return; + struct termios raw = old_; + raw.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | + INLCR | IGNCR | ICRNL | IXON); + raw.c_oflag |= OPOST; + raw.c_lflag &= ~(ECHO | ECHONL | ICANON | IEXTEN | ISIG); + raw.c_cflag &= ~(CSIZE | PARENB); + raw.c_cflag |= CS8; + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + raw_ = raw; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) { + active_ = true; + struct sigaction action {}; + action.sa_handler = &TerminalRawMode::OnSigcont; + sigemptyset(&action.sa_mask); + if (sigaction(SIGCONT, &action, &old_sigcont_) == 0) { + sigcont_active_ = true; + } + } + } + + TerminalRawMode(const TerminalRawMode&) = delete; + TerminalRawMode& operator=(const TerminalRawMode&) = delete; + + ~TerminalRawMode() { + if (active_) { + tcsetattr(STDIN_FILENO, TCSANOW, &old_); + } + if (sigcont_active_) { + sigaction(SIGCONT, &old_sigcont_, nullptr); + } + } + + void restore_after_sigcont() const { + if (!active_) return; + if (sigcont_pending_.exchange(false, std::memory_order_relaxed)) { + tcsetattr(STDIN_FILENO, TCSANOW, &raw_); + } + } + + private: + static void OnSigcont(int) { + sigcont_pending_.store(true, std::memory_order_relaxed); + } + + bool active_{false}; + bool sigcont_active_{false}; + struct termios old_ {}; + struct termios raw_ {}; + struct sigaction old_sigcont_ {}; + inline static std::atomic sigcont_pending_{false}; +}; + +class SignalIgnoreGuard { + public: + SignalIgnoreGuard(int signum, bool enable) : signum_(signum) { + if (!enable) return; + struct sigaction action {}; + action.sa_handler = SIG_IGN; + sigemptyset(&action.sa_mask); + if (sigaction(signum_, &action, &old_) == 0) { + active_ = true; + } + } + + SignalIgnoreGuard(const SignalIgnoreGuard&) = delete; + SignalIgnoreGuard& operator=(const SignalIgnoreGuard&) = delete; + + ~SignalIgnoreGuard() { + if (active_) { + sigaction(signum_, &old_, nullptr); + } + } + + private: + int signum_{0}; + bool active_{false}; + struct sigaction old_ {}; +}; + +// Throw a JS error from a C++ exception +static napi_value ThrowError(napi_env env, const std::string& msg) { + napi_throw_error(env, nullptr, msg.c_str()); + return nullptr; +} + +// ─── ProbeHvf ───────────────────────────────────────────────────────────────── +static napi_value ProbeHvf(napi_env env, napi_callback_info /*info*/) { + napi_value result = MakeObject(env); + try { + // Test hv_vm_create + hv_return_t rc = hv_vm_create(nullptr); + bool vm_ok = (rc == HV_SUCCESS); + if (vm_ok) hv_vm_destroy(); + + SetProp(env, result, "backend", MakeString(env, "hvf")); + SetProp(env, result, "available", MakeBool(env, vm_ok)); + SetProp(env, result, "arch", MakeString(env, "arm64")); + if (!vm_ok) { + SetProp(env, result, "reason", MakeString(env, "hv_vm_create failed: " + std::to_string(rc))); + } + } catch (const std::exception& e) { + SetProp(env, result, "backend", MakeString(env, "hvf")); + SetProp(env, result, "available", MakeBool(env, false)); + SetProp(env, result, "arch", MakeString(env, "arm64")); + SetProp(env, result, "reason", MakeString(env, e.what())); + } + return result; +} + +template +static uint32_t ReadMmio32(Device& device, uint64_t offset) { + uint8_t data[4]{}; + device.read_mmio(offset, data, sizeof(data)); + return ReadU32(data); +} + +template +static void WriteMmio32(Device& device, uint64_t offset, uint32_t value) { + uint8_t data[4]{}; + WriteU32(data, value); + device.write_mmio(offset, data, sizeof(data)); +} + +static napi_value HvfFdtSmoke(napi_env env, napi_callback_info /*info*/) { + try { + FdtBuilder builder; + constexpr uint32_t kSmokeCpus = 2; + std::vector dtb = builder.Build( + kRamBase, 128ULL * 1024ULL * 1024ULL, kSmokeCpus, + "console=ttyAMA0,115200 root=/dev/vda", true); + + EmptyVirtioMmio empty; + uint8_t data[4]{}; + empty.read_mmio(0x000, data, sizeof(data)); + uint32_t empty_magic = ReadU32(data); + empty.read_mmio(0x008, data, sizeof(data)); + uint32_t empty_device_id = ReadU32(data); + + napi_value result = MakeObject(env); + SetProp(env, result, "backend", MakeString(env, "hvf")); + SetProp(env, result, "dtbBytes", MakeU32(env, static_cast(dtb.size()))); + SetProp(env, result, "rootInterruptParent", MakeU32(env, 1)); + SetProp(env, result, "rootDmaCoherent", MakeBool(env, true)); + SetProp(env, result, "gicPhandle", MakeU32(env, 1)); + SetProp(env, result, "cpuCount", MakeU32(env, kSmokeCpus)); + SetProp(env, result, "cpuEnableMethod", MakeString(env, "psci")); + SetProp(env, result, "gicRedistributorBytes", MakeU32(env, GicRedistributorBytes(kSmokeCpus))); + SetProp(env, result, "timerInterrupts", MakeU32(env, 4)); + SetProp(env, result, "timerIrqFlags", MakeU32(env, 4)); + SetProp(env, result, "chosenRandomness", MakeBool(env, true)); + SetProp(env, result, "serial0Alias", MakeString(env, "/pl011@9000000")); + SetProp(env, result, "stdoutPath", MakeString(env, "/pl011@9000000")); + SetProp(env, result, "uartBase", MakeU32(env, static_cast(kUartBase))); + SetProp(env, result, "uartSpi", MakeU32(env, kUartSpi)); + SetProp(env, result, "uartClockPhandles", MakeU32(env, 2)); + SetProp(env, result, "rtcBase", MakeU32(env, static_cast(kPl031RtcBase))); + SetProp(env, result, "rtcSpi", MakeU32(env, kRtcSpi)); + SetProp(env, result, "rtcIntid", MakeU32(env, kRtcIntid)); + SetProp(env, result, "gpioBase", MakeU32(env, static_cast(kPl061GpioBase))); + SetProp(env, result, "gpioSpi", MakeU32(env, kGpioSpi)); + SetProp(env, result, "gpioIntid", MakeU32(env, kGpioIntid)); + SetProp(env, result, "gpioPhandle", MakeU32(env, 4)); + SetProp(env, result, "gpioPowerKeyCode", MakeU32(env, 116)); + SetProp(env, result, "fwCfgBase", MakeU32(env, static_cast(kFwCfgBase))); + SetProp(env, result, "fwCfgSize", MakeU32(env, 0x18)); + SetProp(env, result, "fwCfgDmaCoherent", MakeBool(env, true)); + SetProp(env, result, "virtioBase", MakeU32(env, static_cast(kVirtioMmioBase))); + SetProp(env, result, "virtioStride", MakeU32(env, static_cast(kVirtioMmioStride))); + SetProp(env, result, "virtioCount", MakeU32(env, kVirtioMmioCount)); + SetProp(env, result, "virtioDmaCoherent", MakeBool(env, true)); + SetProp(env, result, "virtioFirstSpi", MakeU32(env, kVirtioFirstSpi)); + SetProp(env, result, "virtioBlkBase", MakeU32(env, static_cast(kVirtioBlkBase))); + SetProp(env, result, "virtioBlkIntid", MakeU32(env, kVirtioBlkIntid)); + SetProp(env, result, "virtioNetBase", MakeU32(env, static_cast(kVirtioNetBase))); + SetProp(env, result, "virtioNetIntid", MakeU32(env, kVirtioNetIntid)); + SetProp(env, result, "pcieBase", MakeU32(env, static_cast(kPcieMmioBase))); + SetProp(env, result, "pcieMmioSize", MakeU32(env, static_cast(kPcieMmioSize))); + SetProp(env, result, "pciePioBase", MakeU32(env, static_cast(kPciePioBase))); + SetProp(env, result, "pcieEcamBase", MakeU32(env, static_cast(kPcieEcamBase))); + SetProp(env, result, "pcieEcamSize", MakeU32(env, static_cast(kPcieEcamSize))); + SetProp(env, result, "pcieFirstSpi", MakeU32(env, kPcieFirstSpi)); + SetProp(env, result, "pcieIntxCount", MakeU32(env, kPcieIntxCount)); + SetProp(env, result, "pcieInterruptMapEntries", MakeU32(env, 4 * kPcieIntxCount)); + SetProp(env, result, "pcieInterruptMapMaskDevfn", MakeU32(env, 0x1800)); + SetProp(env, result, "pcieInterruptMapMaskPin", MakeU32(env, 0x7)); + SetProp(env, result, "pcieBusCount", MakeU32(env, PcieBusCount())); + SetProp(env, result, "pcieDmaCoherent", MakeBool(env, true)); + SetProp(env, result, "emptyTransportMagic", MakeU32(env, empty_magic)); + SetProp(env, result, "emptyTransportDeviceId", MakeU32(env, empty_device_id)); + return result; + } catch (const std::exception& e) { + return ThrowError(env, std::string("hvfFdtSmoke: ") + e.what()); + } +} + +static napi_value HvfPl011Smoke(napi_env env, napi_callback_info /*info*/) { + try { + Pl011Uart uart; + uart.set_console_limit(1024); + + uint8_t one[4]{}; + WriteU32(one, 'A'); + uart.write_mmio(0x000, one, 1); + const char cursor_query[] = "\x1b[6n"; + for (size_t i = 0; i + 1 < sizeof(cursor_query); i++) { + WriteU32(one, static_cast(cursor_query[i])); + uart.write_mmio(0x000, one, 1); + } + + std::string cursor_response; + for (size_t i = 0; i < 6; i++) { + uint8_t data[4]{}; + uart.read_mmio(0x000, data, sizeof(data)); + cursor_response.push_back(static_cast(ReadU32(data) & 0xFF)); + } + + uint32_t fr_empty = ReadMmio32(uart, 0x018); + uart.inject_char('z'); + uint32_t fr_with_rx = ReadMmio32(uart, 0x018); + WriteMmio32(uart, 0x038, 0x50); + uint32_t ris_before_clear = ReadMmio32(uart, 0x03C); + uint32_t mis_before_clear = ReadMmio32(uart, 0x040); + uint32_t rx_byte = ReadMmio32(uart, 0x000) & 0xFF; + WriteMmio32(uart, 0x044, 0xFFFFFFFF); + uint32_t ris_after_clear = ReadMmio32(uart, 0x03C); + + WriteMmio32(uart, 0x024, 26); + WriteMmio32(uart, 0x028, 3); + WriteMmio32(uart, 0x02C, 0x60); + WriteMmio32(uart, 0x030, 0x301); + WriteMmio32(uart, 0x034, 0x24); + WriteMmio32(uart, 0x048, 0x7); + + napi_value result = MakeObject(env); + SetProp(env, result, "backend", MakeString(env, "hvf")); + SetProp(env, result, "console", MakeString(env, uart.console())); + SetProp(env, result, "cursorResponse", MakeString(env, cursor_response)); + SetProp(env, result, "rxByte", MakeU32(env, rx_byte)); + SetProp(env, result, "frEmpty", MakeU32(env, fr_empty)); + SetProp(env, result, "frWithRx", MakeU32(env, fr_with_rx)); + SetProp(env, result, "risBeforeClear", MakeU32(env, ris_before_clear)); + SetProp(env, result, "misBeforeClear", MakeU32(env, mis_before_clear)); + SetProp(env, result, "risAfterClear", MakeU32(env, ris_after_clear)); + SetProp(env, result, "ibrd", MakeU32(env, ReadMmio32(uart, 0x024))); + SetProp(env, result, "fbrd", MakeU32(env, ReadMmio32(uart, 0x028))); + SetProp(env, result, "lcrH", MakeU32(env, ReadMmio32(uart, 0x02C))); + SetProp(env, result, "cr", MakeU32(env, ReadMmio32(uart, 0x030))); + SetProp(env, result, "ifls", MakeU32(env, ReadMmio32(uart, 0x034))); + SetProp(env, result, "dmacr", MakeU32(env, ReadMmio32(uart, 0x048))); + SetProp(env, result, "peripheralId0", MakeU32(env, ReadMmio32(uart, 0xFE0))); + SetProp(env, result, "primeCellId0", MakeU32(env, ReadMmio32(uart, 0xFF0))); + return result; + } catch (const std::exception& e) { + return ThrowError(env, std::string("hvfPl011Smoke: ") + e.what()); + } +} + +static napi_value HvfDeviceSmoke(napi_env env, napi_callback_info /*info*/) { + try { + Pl031Rtc rtc; + FwCfgMmio fw_cfg; + Pl061Gpio gpio; + EmptyPcieHost pcie; + EmptyVirtioMmio empty_virtio; + + uint32_t rtc_now = ReadMmio32(rtc, 0x000); + WriteMmio32(rtc, 0x008, 12345); + uint32_t rtc_loaded = ReadMmio32(rtc, 0x000); + uint32_t rtc_pid0 = ReadMmio32(rtc, 0xFE0); + uint32_t rtc_cid0 = ReadMmio32(rtc, 0xFF0); + + WriteMmio32(gpio, 0x400, 0x0F); + WriteMmio32(gpio, 0x3FC, 0x05); + uint32_t gpio_data = ReadMmio32(gpio, 0x3FC); + uint32_t gpio_dir = ReadMmio32(gpio, 0x400); + uint32_t gpio_pid0 = ReadMmio32(gpio, 0xFE0); + uint32_t gpio_cid0 = ReadMmio32(gpio, 0xFF0); + + uint8_t data[8]{}; + WriteU16(data, 0x0000); + fw_cfg.write_mmio(0x008, data, 2); + std::string signature; + for (size_t i = 0; i < 4; i++) { + uint8_t byte[1]{}; + fw_cfg.read_mmio(0x000, byte, 1); + signature.push_back(static_cast(byte[0])); + } + WriteU16(data, 0x0001); + fw_cfg.write_mmio(0x008, data, 2); + uint8_t id_bytes[4]{}; + fw_cfg.read_mmio(0x000, id_bytes, sizeof(id_bytes)); + uint32_t fw_cfg_id = ReadU32(id_bytes); + WriteU16(data, 0x0005); + fw_cfg.write_mmio(0x008, data, 2); + uint8_t cpu_bytes[2]{}; + fw_cfg.read_mmio(0x000, cpu_bytes, sizeof(cpu_bytes)); + uint32_t fw_cfg_cpus = ReadU16(cpu_bytes); + WriteU16(data, 0x0019); + fw_cfg.write_mmio(0x008, data, 2); + uint8_t dir_bytes[4]{}; + fw_cfg.read_mmio(0x000, dir_bytes, sizeof(dir_bytes)); + uint32_t fw_cfg_file_count = ReadU32(dir_bytes); + + uint32_t empty_magic = ReadMmio32(empty_virtio, 0x000); + uint32_t empty_device_id = ReadMmio32(empty_virtio, 0x008); + uint32_t empty_vendor = ReadMmio32(empty_virtio, 0x00C); + uint32_t pcie_vendor = ReadMmio32(pcie, 0x000); + + napi_value result = MakeObject(env); + SetProp(env, result, "backend", MakeString(env, "hvf")); + SetProp(env, result, "rtcNow", MakeU32(env, rtc_now)); + SetProp(env, result, "rtcLoaded", MakeU32(env, rtc_loaded)); + SetProp(env, result, "rtcPeripheralId0", MakeU32(env, rtc_pid0)); + SetProp(env, result, "rtcPrimeCellId0", MakeU32(env, rtc_cid0)); + SetProp(env, result, "gpioData", MakeU32(env, gpio_data)); + SetProp(env, result, "gpioDirection", MakeU32(env, gpio_dir)); + SetProp(env, result, "gpioPeripheralId0", MakeU32(env, gpio_pid0)); + SetProp(env, result, "gpioPrimeCellId0", MakeU32(env, gpio_cid0)); + SetProp(env, result, "fwCfgSignature", MakeString(env, signature)); + SetProp(env, result, "fwCfgId", MakeU32(env, fw_cfg_id)); + SetProp(env, result, "fwCfgCpus", MakeU32(env, fw_cfg_cpus)); + SetProp(env, result, "fwCfgFileCount", MakeU32(env, fw_cfg_file_count)); + SetProp(env, result, "emptyVirtioMagic", MakeU32(env, empty_magic)); + SetProp(env, result, "emptyVirtioDeviceId", MakeU32(env, empty_device_id)); + SetProp(env, result, "emptyVirtioVendor", MakeU32(env, empty_vendor)); + SetProp(env, result, "pcieEmptyVendor", MakeU32(env, pcie_vendor)); + return result; + } catch (const std::exception& e) { + return ThrowError(env, std::string("hvfDeviceSmoke: ") + e.what()); + } +} + +// ─── RunVm ──────────────────────────────────────────────────────────────────── +struct GuestExitReq { + bool requested{false}; + uint8_t status{0}; +}; + +enum class PsciCpuState : uint8_t { + Off, + OnPending, + On, +}; + +struct HvfCpuSlot { + hv_vcpu_t vcpu{}; + hv_vcpu_exit_t* exit_info{nullptr}; + bool created{false}; + bool live{false}; + bool powered{false}; + bool start_requested{false}; + bool paused{false}; + PsciCpuState state{PsciCpuState::Off}; + uint64_t entry{0}; + uint64_t context{0}; +}; + +static bool CpuIndexFromMpidr(uint64_t mpidr, uint32_t cpus, uint32_t* out) { + if ((mpidr & ~0xFFULL) != 0) return false; + uint32_t index = static_cast(mpidr & 0xFF); + if (index >= cpus) return false; + *out = index; + return true; +} + +static uint32_t PoweredCpuCountLocked(const std::vector& slots) { + uint32_t count = 0; + for (const auto& slot : slots) { + if (slot.state != PsciCpuState::Off) count++; + } + return std::max(count, 1); +} + +static int64_t PsciAffinityInfoState(const std::vector& slots, uint64_t mpidr, uint64_t level) { + if (level == 0) { + uint32_t target = 0; + if (!CpuIndexFromMpidr(mpidr, static_cast(slots.size()), &target)) { + return kPsciInvalidParams; + } + switch (slots[target].state) { + case PsciCpuState::On: return 0; + case PsciCpuState::Off: return 1; + case PsciCpuState::OnPending: return 2; + } + } + if (level <= 3 && mpidr == 0) { + bool pending = false; + for (const auto& slot : slots) { + if (slot.state == PsciCpuState::On) return 0; + if (slot.state == PsciCpuState::OnPending) pending = true; + } + return pending ? 2 : 1; + } + return kPsciInvalidParams; +} + +static void InitVcpuRegisters(hv_vcpu_t vcpu, uint32_t cpu_index, uint64_t pc, uint64_t x0) { + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_PC, pc), "hv_vcpu_set_reg PC"); + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_X0, x0), "hv_vcpu_set_reg X0"); + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_X1, 0), "hv_vcpu_set_reg X1"); + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_X2, 0), "hv_vcpu_set_reg X2"); + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_X3, 0), "hv_vcpu_set_reg X3"); + CheckHv(hv_vcpu_set_reg(vcpu, HV_REG_CPSR, 0x3C5), "hv_vcpu_set_reg CPSR"); + CheckHv(hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_MPIDR_EL1, cpu_index), + "hv_vcpu_set_sys_reg MPIDR_EL1"); +} + +static napi_value RunVm(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + if (argc < 1) return ThrowError(env, "runVm requires config argument"); + + std::string kernel_path = GetString(env, argv[0], "kernelPath"); + std::string rootfs_path = GetString(env, argv[0], "rootfsPath"); + std::string overlay_path = GetString(env, argv[0], "overlayPath"); + std::string tap_name = GetString(env, argv[0], "netTapName"); + std::string guest_mac = GetString(env, argv[0], "netGuestMac"); + std::string net_host_ip = GetString(env, argv[0], "netHostIp"); + std::string net_guest_ip = GetString(env, argv[0], "netGuestIp"); + std::string net_netmask = GetString(env, argv[0], "netNetmask"); + std::string net_dns = GetString(env, argv[0], "netDns"); + std::vector net_port_forwards = GetPortForwards(env, argv[0], "netPortForwards"); + std::vector attached_disks = GetAttachedDisks(env, argv[0]); + std::string cmdline = GetString(env, argv[0], "cmdline"); + uint32_t mem_mib = GetU32(env, argv[0], "memMiB", 256); + uint32_t cpus = GetU32(env, argv[0], "cpus", 1); + uint32_t timeout_ms = GetU32(env, argv[0], "timeoutMs", 0); + uint32_t console_limit = GetU32(env, argv[0], "consoleLimit", 1024 * 1024); + bool interactive = GetBool(env, argv[0], "interactive", false); + RunControl control = GetRunControl(env, argv[0]); + bool debug = HvfDebugEnabled(); + + if (kernel_path.empty()) return ThrowError(env, "kernelPath is required"); + if (rootfs_path.empty()) return ThrowError(env, "rootfsPath is required"); + if (cpus < 1 || cpus > 64) return ThrowError(env, "HVF backend supports 1-64 vCPUs"); + control.set_state(kControlStateStarting); + ControlExitGuard control_exit_guard{control}; + + bool has_net = !tap_name.empty() && tap_name != "none"; + bool use_net = has_net && + (tap_name == "auto" || + tap_name == "slirp" || + tap_name.rfind("slirp:", 0) == 0 || + tap_name.rfind("socket_vmnet:", 0) == 0 || + tap_name.find("vmnet") != std::string::npos); + uint32_t disk_count = 1 + static_cast(attached_disks.size()); + uint32_t transport_count = disk_count + (use_net ? 1U : 0U); + Check(transport_count <= kVirtioMmioCount, + "HVF backend supports at most " + std::to_string(kVirtioMmioCount) + + " virtio-mmio transports for disks and network"); + + try { + // ── Create VM ────────────────────────────────────────────────────────────── + CheckHv(hv_vm_create(nullptr), "hv_vm_create"); + + struct VmGuard { + ~VmGuard() { hv_vm_destroy(); } + } vm_guard; + + size_t hvf_redist_region_size = 0; + CheckHv(hv_gic_get_redistributor_region_size(&hvf_redist_region_size), + "hv_gic_get_redistributor_region_size"); + Check(uint64_t(GicRedistributorBytes(cpus)) <= hvf_redist_region_size, + "HVF GIC redistributor region is too small for " + std::to_string(cpus) + " vCPUs"); + + // ── Allocate guest RAM ───────────────────────────────────────────────────── + uint64_t mem_bytes = uint64_t(mem_mib) * 1024ULL * 1024ULL; + void* ram = mmap(nullptr, static_cast(mem_bytes), + PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + Check(ram != MAP_FAILED, ErrnoMessage("mmap guest RAM")); + Mapping ram_map(ram, static_cast(mem_bytes)); + + CheckHv(hv_vm_map(ram, kRamBase, mem_bytes, + HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC), + "hv_vm_map"); + HvMapGuard map_guard{kRamBase, mem_bytes, true}; + + GuestMemory mem(static_cast(ram), static_cast(mem_bytes)); + + // ── Load ARM64 kernel ────────────────────────────────────────────────────── + Arm64ImageInfo kernel = LoadArm64Image(kernel_path, mem); + + // ── Build DTB ───────────────────────────────────────────────────────────── + FdtBuilder fdt_builder; + std::vector dtb = fdt_builder.Build( + kRamBase, mem_bytes, cpus, cmdline, use_net); + Check(dtb.size() < kKernelOffset, "DTB too large"); + memcpy(mem.ptr(kDtbOffset, dtb.size()), dtb.data(), dtb.size()); + + // ── Create GIC v3 ───────────────────────────────────────────────────────── + hv_gic_config_t gic_config = hv_gic_config_create(); + CheckHv(hv_gic_config_set_distributor_base(gic_config, kGicDistBase), + "hv_gic_config_set_distributor_base"); + CheckHv(hv_gic_config_set_redistributor_base(gic_config, kGicRedistBase), + "hv_gic_config_set_redistributor_base"); + CheckHv(hv_gic_create(gic_config), "hv_gic_create"); + os_release(gic_config); + + // GIC inject callback (called from MMIO handlers) + auto gic_inject = [debug](uint32_t intid, bool level) { + hv_return_t rc = hv_gic_set_spi(intid, level); + if (debug && rc != HV_SUCCESS) { + fprintf(stderr, "[node-vmm hvf] hv_gic_set_spi intid=%u level=%d rc=%d\n", + intid, level ? 1 : 0, rc); + } + }; + + // ── UART ────────────────────────────────────────────────────────────────── + Pl011Uart uart; + uart.set_console_limit(static_cast(console_limit)); + uart.set_echo_stdout(interactive); + uart.set_debug(HvfDebugUartEnabled()); + uart.set_console_output_notify([control]() { control.mark_console_output(); }); + uart.set_gic_inject(gic_inject); + + // ── QEMU virt low MMIO probe devices ───────────────────────────────────── + Pl031Rtc rtc; + FwCfgMmio fw_cfg(static_cast(std::min(cpus, UINT16_MAX))); + Pl061Gpio gpio; + EmptyPcieHost pcie; + EmptyVirtioMmio empty_virtio; + + // ── VirtioBlk ───────────────────────────────────────────────────────────── + std::vector> blks; + blks.reserve(disk_count); + blks.push_back(std::make_unique(mem, 0, rootfs_path, overlay_path, false, gic_inject)); + for (uint32_t i = 0; i < attached_disks.size(); i++) { + const AttachedDiskConfig& disk = attached_disks[i]; + blks.push_back(std::make_unique(mem, i + 1, disk.path, "", disk.read_only, gic_inject)); + } + + // ── VirtioNet ───────────────────────────────────────────────────────────── + std::unique_ptr net_dev; + if (use_net) { + if (guest_mac.empty()) guest_mac = "52:54:00:12:34:56"; + net_dev = std::make_unique(mem, disk_count, guest_mac, gic_inject); + net_dev->start(tap_name, net_host_ip, net_guest_ip, net_netmask, net_dns, net_port_forwards); + } + + uint64_t entry_ipa = kRamBase + kernel.load_offset; + uint64_t dtb_ipa = kRamBase + kDtbOffset; + + std::vector cpu_slots(cpus); + std::vector vcpu_threads; + vcpu_threads.reserve(cpus); + struct ThreadJoinGuard { + std::vector& threads; + ~ThreadJoinGuard() { + for (auto& thread : threads) { + if (thread.joinable()) thread.join(); + } + } + } vcpu_join_guard{vcpu_threads}; + + std::mutex lifecycle_mu; + std::condition_variable lifecycle_cv; + bool start_vcpus = false; + uint32_t created_vcpus = 0; + uint32_t paused_vcpus = 0; + std::string thread_error; + std::mutex result_mu; + std::string exit_reason_name = "unknown"; + int exit_reason_code = -1; + std::atomic stop_requested{false}; + std::atomic result_set{false}; + std::atomic cancel_reason{0}; // 1=timeout, 2=stop, 3=pause + std::atomic runs{0}; + std::mutex device_mu; + SignalIgnoreGuard sigint_guard(SIGINT, interactive); + TerminalRawMode raw_mode(interactive); + + auto wake_all_vcpus = [&]() { + std::vector live_vcpus; + { + std::lock_guard lk(lifecycle_mu); + live_vcpus.reserve(cpu_slots.size()); + for (const auto& slot : cpu_slots) { + if (slot.live && slot.vcpu != 0) { + live_vcpus.push_back(slot.vcpu); + } + } + } + if (!live_vcpus.empty()) { + hv_vcpus_exit(live_vcpus.data(), static_cast(live_vcpus.size())); + } + }; + if (net_dev) { + net_dev->set_wake_callback(wake_all_vcpus); + } + + auto request_stop = [&](const std::string& reason, int code) { + bool expected = false; + if (result_set.compare_exchange_strong(expected, true)) { + std::lock_guard result_lk(result_mu); + exit_reason_name = reason; + exit_reason_code = code; + } + stop_requested.store(true, std::memory_order_relaxed); + lifecycle_cv.notify_all(); + wake_all_vcpus(); + }; + + auto poll_devices = [&]() { + std::lock_guard device_lk(device_mu); + uart.refresh_irq(); + if (net_dev && net_dev->enabled() && (net_dev->needs_poll() || net_dev->notify_readable())) { + net_dev->poll_rx(); + } + }; + + auto handle_pause_if_requested = [&](uint32_t cpu_index) { + if (!control.enabled() || control.command() != kControlPause) return false; + { + std::lock_guard lk(lifecycle_mu); + if (!cpu_slots[cpu_index].paused) { + cpu_slots[cpu_index].paused = true; + paused_vcpus++; + } + if (paused_vcpus >= PoweredCpuCountLocked(cpu_slots)) { + control.set_state(kControlStatePaused); + } + } + while (!stop_requested.load(std::memory_order_relaxed) && control.command() == kControlPause) { + struct timespec ts{0, 10 * 1000000L}; + nanosleep(&ts, nullptr); + } + { + std::lock_guard lk(lifecycle_mu); + if (cpu_slots[cpu_index].paused) { + cpu_slots[cpu_index].paused = false; + if (paused_vcpus > 0) paused_vcpus--; + } + } + return stop_requested.load(std::memory_order_relaxed); + }; + + auto trap_reason = [](const char* prefix, uint32_t ec, uint64_t syndrome, uint64_t ipa, uint64_t pc) { + return UnsupportedExceptionReason(prefix, ec, syndrome, ipa, pc); + }; + + auto dispatch_mmio = [&](uint64_t ipa, bool is_write, uint8_t* data, uint32_t len) -> bool { + std::lock_guard device_lk(device_mu); + if (ipa >= kUartBase && ipa < kUartBase + 0x1000) { + uint64_t off = ipa - kUartBase; + if (is_write) uart.write_mmio(off, data, len); + else uart.read_mmio(off, data, len); + } else if (ipa >= kPl031RtcBase && ipa < kPl031RtcBase + 0x1000) { + uint64_t off = ipa - kPl031RtcBase; + if (is_write) rtc.write_mmio(off, data, len); + else rtc.read_mmio(off, data, len); + } else if (ipa >= kFwCfgBase && ipa < kFwCfgBase + 0x18) { + uint64_t off = ipa - kFwCfgBase; + if (is_write) fw_cfg.write_mmio(off, data, len); + else fw_cfg.read_mmio(off, data, len); + } else if (ipa >= kPl061GpioBase && ipa < kPl061GpioBase + 0x1000) { + uint64_t off = ipa - kPl061GpioBase; + if (is_write) gpio.write_mmio(off, data, len); + else gpio.read_mmio(off, data, len); + } else if (ipa >= kPcieEcamBase && ipa < kPcieEcamBase + kPcieEcamSize) { + uint64_t off = ipa - kPcieEcamBase; + if (is_write) pcie.write_mmio(off, data, len); + else pcie.read_mmio(off, data, len); + } else if (ipa >= kPciePioBase && ipa < kPciePioBase + kPciePioSize) { + uint64_t off = ipa - kPciePioBase; + if (is_write) pcie.write_mmio(off, data, len); + else pcie.read_mmio(off, data, len); + } else if ([&]() { + for (const auto& blk : blks) { + if (blk->contains(ipa)) { + if (is_write) blk->write_mmio(ipa, data, len); + else blk->read_mmio(ipa, data, len); + return true; + } + } + return false; + }()) { + // Handled by one of the virtio-blk transports above. + } else if (net_dev && net_dev->contains(ipa)) { + if (is_write) net_dev->write_mmio(ipa, data, len); + else net_dev->read_mmio(ipa, data, len); + } else if (IsQemuVirtioMmioAddress(ipa)) { + uint64_t off = VirtioMmioOffset(ipa); + if (is_write) empty_virtio.write_mmio(off, data, len); + else empty_virtio.read_mmio(off, data, len); + } else if (ipa >= kExitDevBase && ipa < kExitDevBase + 0x1000) { + if (is_write) request_stop("guest-exit", data[0]); + } else { + return false; + } + return true; + }; + + auto vcpu_main = [&](uint32_t cpu_index) { + hv_vcpu_t vcpu{}; + hv_vcpu_exit_t* exit_info = nullptr; + bool active = false; + try { + CheckHv(hv_vcpu_create(&vcpu, &exit_info, nullptr), "hv_vcpu_create"); + active = true; + CheckHv(hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_MPIDR_EL1, cpu_index), + "hv_vcpu_set_sys_reg MPIDR_EL1"); + { + std::lock_guard lk(lifecycle_mu); + cpu_slots[cpu_index].vcpu = vcpu; + cpu_slots[cpu_index].exit_info = exit_info; + cpu_slots[cpu_index].created = true; + cpu_slots[cpu_index].live = true; + cpu_slots[cpu_index].powered = (cpu_index == 0); + cpu_slots[cpu_index].state = cpu_index == 0 ? PsciCpuState::On : PsciCpuState::Off; + created_vcpus++; + } + lifecycle_cv.notify_all(); + + { + std::unique_lock lk(lifecycle_mu); + lifecycle_cv.wait(lk, [&]() { + return start_vcpus || stop_requested.load(std::memory_order_relaxed); + }); + } + + bool boot_cpu_started = false; + for (;;) { + uint64_t start_pc = 0; + uint64_t start_x0 = 0; + if (cpu_index == 0 && !boot_cpu_started) { + boot_cpu_started = true; + start_pc = entry_ipa; + start_x0 = dtb_ipa; + } else { + std::unique_lock lk(lifecycle_mu); + if (!stop_requested.load(std::memory_order_relaxed)) { + cpu_slots[cpu_index].powered = false; + cpu_slots[cpu_index].state = PsciCpuState::Off; + cpu_slots[cpu_index].paused = false; + lifecycle_cv.notify_all(); + } + lifecycle_cv.wait(lk, [&]() { + return stop_requested.load(std::memory_order_relaxed) || cpu_slots[cpu_index].start_requested; + }); + if (stop_requested.load(std::memory_order_relaxed)) break; + start_pc = cpu_slots[cpu_index].entry; + start_x0 = cpu_slots[cpu_index].context; + cpu_slots[cpu_index].start_requested = false; + cpu_slots[cpu_index].powered = true; + cpu_slots[cpu_index].state = PsciCpuState::On; + } + + InitVcpuRegisters(vcpu, cpu_index, start_pc, start_x0); + control.set_state(kControlStateRunning); + + uint32_t debug_exit_logs = 0; + uint32_t debug_psci_logs = 0; + bool debug_exit_seen = false; + uint32_t last_debug_reason = 0; + uint64_t last_debug_pc = 0; + uint64_t last_debug_syndrome = 0; + uint64_t last_debug_ipa = 0; + bool vtimer_pending = false; + + for (;;) { + if (stop_requested.load(std::memory_order_relaxed)) break; + if (control.enabled()) { + int32_t command = control.command(); + if (command == kControlStop) { + cancel_reason.store(2, std::memory_order_relaxed); + control.set_state(kControlStateStopping); + request_stop("host-stop", 0); + break; + } + if (handle_pause_if_requested(cpu_index)) break; + control.set_state(kControlStateRunning); + } + + raw_mode.restore_after_sigcont(); + poll_devices(); + if (vtimer_pending) { + uint64_t pending = 0; + uint64_t active_gic = 0; + CheckHv(hv_gic_get_redistributor_reg(vcpu, HV_GIC_REDISTRIBUTOR_REG_GICR_ISPENDR0, &pending), + "hv_gic_get_redistributor_reg GICR_ISPENDR0"); + CheckHv(hv_gic_get_redistributor_reg(vcpu, HV_GIC_REDISTRIBUTOR_REG_GICR_ISACTIVER0, &active_gic), + "hv_gic_get_redistributor_reg GICR_ISACTIVER0"); + if (((pending | active_gic) & kVtimerIntidBit) == 0) { + CheckHv(hv_vcpu_set_vtimer_mask(vcpu, false), "hv_vcpu_set_vtimer_mask"); + vtimer_pending = false; + } + } + + hv_return_t run_rc = hv_vcpu_run(vcpu); + if (run_rc != HV_SUCCESS) { + request_stop("hv-error", static_cast(run_rc)); + break; + } + runs.fetch_add(1, std::memory_order_relaxed); + + uint32_t reason = exit_info->reason; + if (debug) { + uint64_t pc = 0; + hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + uint64_t syndrome = exit_info->exception.syndrome; + uint64_t ipa = exit_info->exception.physical_address; + bool changed = !debug_exit_seen || reason != last_debug_reason || + pc != last_debug_pc || syndrome != last_debug_syndrome || ipa != last_debug_ipa; + if (changed && debug_exit_logs < 128) { + fprintf(stderr, "[node-vmm hvf] cpu=%u exit reason=%u pc=0x%llx syndrome=0x%llx ipa=0x%llx va=0x%llx\n", + cpu_index, + reason, + static_cast(pc), + static_cast(syndrome), + static_cast(ipa), + static_cast(exit_info->exception.virtual_address)); + debug_exit_logs++; + debug_exit_seen = true; + last_debug_reason = reason; + last_debug_pc = pc; + last_debug_syndrome = syndrome; + last_debug_ipa = ipa; + } else if (changed && debug_exit_logs == 128) { + fprintf(stderr, "[node-vmm hvf] further exit logs suppressed for cpu=%u\n", cpu_index); + debug_exit_logs++; + } + } + + if (reason == HV_EXIT_REASON_CANCELED) { + if (stop_requested.load(std::memory_order_relaxed)) break; + if (cancel_reason.load(std::memory_order_relaxed) == 3) continue; + continue; + } + + if (reason == HV_EXIT_REASON_VTIMER_ACTIVATED) { + CheckHv(hv_gic_set_redistributor_reg(vcpu, HV_GIC_REDISTRIBUTOR_REG_GICR_ISPENDR0, kVtimerIntidBit), + "hv_gic_set_redistributor_reg GICR_ISPENDR0"); + vtimer_pending = true; + continue; + } + + if (reason == HV_EXIT_REASON_EXCEPTION) { + uint64_t syndrome = exit_info->exception.syndrome; + uint32_t ec = EsrEC(syndrome); + + if (ec == 0x17) { + hv_vcpu_set_reg(vcpu, HV_REG_X0, static_cast(kPsciNotSupported)); + uint64_t pc = 0; hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + hv_vcpu_set_reg(vcpu, HV_REG_PC, pc + 4); + continue; + } + + if (ec == 0x16) { + uint64_t x0 = 0; + uint64_t x1 = 0; + uint64_t x2 = 0; + uint64_t x3 = 0; + hv_vcpu_get_reg(vcpu, HV_REG_X0, &x0); + hv_vcpu_get_reg(vcpu, HV_REG_X1, &x1); + hv_vcpu_get_reg(vcpu, HV_REG_X2, &x2); + hv_vcpu_get_reg(vcpu, HV_REG_X3, &x3); + if (debug && debug_psci_logs < 64) { + fprintf(stderr, "[node-vmm hvf] cpu=%u psci hvc/smc x0=0x%llx x1=0x%llx x2=0x%llx x3=0x%llx\n", + cpu_index, + static_cast(x0), + static_cast(x1), + static_cast(x2), + static_cast(x3)); + debug_psci_logs++; + } else if (debug && debug_psci_logs == 64) { + fprintf(stderr, "[node-vmm hvf] further psci logs suppressed for cpu=%u\n", cpu_index); + debug_psci_logs++; + } + + bool cpu_powered_off = false; + if (x0 == kPsciVersion) { + hv_vcpu_set_reg(vcpu, HV_REG_X0, 0x00010000ULL); // PSCI 1.0 + } else if (x0 == kPsciFeatures) { + hv_vcpu_set_reg(vcpu, HV_REG_X0, + PsciFeatureSupported(x1) ? uint64_t(kPsciSuccess) : static_cast(kPsciNotSupported)); + } else if (x0 == kPsciMigrateInfoType) { + hv_vcpu_set_reg(vcpu, HV_REG_X0, 2); + } else if (x0 == kPsciAffinityInfo32 || x0 == kPsciAffinityInfo64) { + int64_t ret = kPsciInvalidParams; + { + std::lock_guard lk(lifecycle_mu); + ret = PsciAffinityInfoState(cpu_slots, x1, x2); + } + hv_vcpu_set_reg(vcpu, HV_REG_X0, static_cast(ret)); + } else if (x0 == kPsciCpuSuspend32 || x0 == kPsciCpuSuspend64) { + hv_vcpu_set_reg(vcpu, HV_REG_X0, static_cast(kPsciDenied)); + } else if (x0 == kPsciCpuOn32 || x0 == kPsciCpuOn64) { + uint32_t target = 0; + int64_t ret = kPsciInvalidParams; + if (CpuIndexFromMpidr(x1, cpus, &target) && x2 != 0) { + std::lock_guard lk(lifecycle_mu); + if (target == 0 || cpu_slots[target].state != PsciCpuState::Off || cpu_slots[target].start_requested) { + ret = kPsciAlreadyOn; + } else { + cpu_slots[target].entry = x2; + cpu_slots[target].context = x3; + cpu_slots[target].start_requested = true; + cpu_slots[target].state = PsciCpuState::OnPending; + ret = kPsciSuccess; + } + } + lifecycle_cv.notify_all(); + hv_vcpu_set_reg(vcpu, HV_REG_X0, static_cast(ret)); + } else if (x0 == kPsciCpuOff) { + if (cpu_index == 0) { + request_stop("hlt", 0); + } else { + std::lock_guard lk(lifecycle_mu); + cpu_slots[cpu_index].powered = false; + cpu_slots[cpu_index].state = PsciCpuState::Off; + cpu_slots[cpu_index].paused = false; + cpu_powered_off = true; + } + lifecycle_cv.notify_all(); + } else if (x0 == kPsciSystemOff) { + request_stop("shutdown", 0); + } else if (x0 == kPsciSystemReset) { + request_stop("reset", 0); + } else { + hv_vcpu_set_reg(vcpu, HV_REG_X0, static_cast(kPsciNotSupported)); + } + if (cpu_powered_off) break; + continue; + } + + if (ec == 0x01) { + uint64_t pc = 0; hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + hv_vcpu_set_reg(vcpu, HV_REG_PC, pc + 4); + { + std::lock_guard device_lk(device_mu); + if (net_dev && net_dev->enabled()) net_dev->poll_rx(); + } + struct timespec ts{0, 100000}; + nanosleep(&ts, nullptr); + continue; + } + + if (ec == 0x18) { + uint64_t pc = 0; + hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + if (HandleSystemRegisterTrap(vcpu, syndrome)) { + hv_vcpu_set_reg(vcpu, HV_REG_PC, pc + 4); + continue; + } + uint64_t ipa = exit_info->exception.physical_address; + request_stop(trap_reason("unhandled-sysreg", ec, syndrome, ipa, pc), 1); + break; + } + + if (ec == 0x24 || ec == 0x20) { + uint64_t ipa = exit_info->exception.physical_address; + uint64_t pc = 0; + hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + + if (ec == 0x24 && EsrISV(syndrome)) { + bool is_write = EsrWnR(syndrome); + uint32_t sas = EsrSAS(syndrome); + uint32_t len = 1u << sas; + uint32_t srt = EsrSRT(syndrome); + + uint8_t data[8] = {}; + if (is_write) { + uint64_t reg_val = 0; + hv_vcpu_get_reg(vcpu, static_cast(srt), ®_val); + for (uint32_t i = 0; i < len && i < 8; i++) { + data[i] = static_cast(reg_val >> (8*i)); + } + } + + if (!dispatch_mmio(ipa, is_write, data, len)) { + request_stop(trap_reason("unhandled-mmio", ec, syndrome, ipa, pc), 1); + break; + } + + if (!is_write) { + uint64_t reg_val = 0; + for (uint32_t i = 0; i < len && i < 8; i++) { + reg_val |= uint64_t(data[i]) << (8*i); + } + if (EsrSSE(syndrome)) { + uint64_t sign_bit = uint64_t(1) << (8*len - 1); + if (reg_val & sign_bit) reg_val |= ~((sign_bit << 1) - 1); + } + hv_vcpu_set_reg(vcpu, static_cast(srt), reg_val); + } + + hv_vcpu_set_reg(vcpu, HV_REG_PC, pc + 4); + continue; + } + + request_stop(trap_reason("unsupported-mmio-trap", ec, syndrome, ipa, pc), 1); + break; + } + + uint64_t pc = 0; hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + uint64_t ipa = exit_info->exception.physical_address; + request_stop(trap_reason("unhandled-trap", ec, syndrome, ipa, pc), 1); + break; + } + + { + uint64_t pc = 0; + hv_vcpu_get_reg(vcpu, HV_REG_PC, &pc); + request_stop("unknown-exit reason=" + std::to_string(reason) + + " syndrome=" + Hex64(exit_info->exception.syndrome) + + " ipa=" + Hex64(exit_info->exception.physical_address) + + " pc=" + Hex64(pc), + static_cast(reason)); + } + break; + } + if (stop_requested.load(std::memory_order_relaxed) || cpu_index == 0) break; + } + } catch (const std::exception& e) { + { + std::lock_guard lk(lifecycle_mu); + if (thread_error.empty()) thread_error = e.what(); + } + request_stop("hv-error", -1); + } + if (active) { + { + std::lock_guard lk(lifecycle_mu); + cpu_slots[cpu_index].live = false; + cpu_slots[cpu_index].vcpu = 0; + cpu_slots[cpu_index].exit_info = nullptr; + cpu_slots[cpu_index].powered = false; + cpu_slots[cpu_index].state = PsciCpuState::Off; + } + hv_vcpu_destroy(vcpu); + } + }; + + for (uint32_t i = 0; i < cpus; i++) { + vcpu_threads.emplace_back(vcpu_main, i); + } + + { + std::unique_lock lk(lifecycle_mu); + lifecycle_cv.wait(lk, [&]() { + return created_vcpus == cpus || !thread_error.empty(); + }); + if (!thread_error.empty()) { + throw std::runtime_error(thread_error); + } + start_vcpus = true; + } + lifecycle_cv.notify_all(); + + std::atomic monitor_done{false}; + std::thread monitor_thread; + MonitorThreadGuard monitor_guard{monitor_done, monitor_thread}; + bool needs_network_wake = net_dev && net_dev->enabled(); + if (timeout_ms > 0 || control.enabled() || needs_network_wake) { + uint64_t start_us = MonotonicMicros(); + monitor_thread = std::thread([timeout_ms, start_us, needs_network_wake, &control, &cancel_reason, &monitor_done, &request_stop, &wake_all_vcpus, + timeout_extension_us = uint64_t(0), pause_active = false, pause_start_us = uint64_t(0)]() mutable { + while (!monitor_done.load(std::memory_order_relaxed)) { + uint64_t now_us = MonotonicMicros(); + if (control.enabled()) { + int32_t command = control.command(); + if (command == kControlStop) { + cancel_reason.store(2, std::memory_order_relaxed); + control.set_state(kControlStateStopping); + request_stop("host-stop", 0); + return; + } + if (command == kControlPause) { + if (!pause_active) { + pause_active = true; + pause_start_us = now_us; + } + cancel_reason.store(3, std::memory_order_relaxed); + wake_all_vcpus(); + } else if (pause_active) { + timeout_extension_us += now_us - pause_start_us; + pause_active = false; + } + } + if (timeout_ms > 0) { + uint64_t paused_us = pause_active ? now_us - pause_start_us : 0; + uint64_t deadline_us = start_us + uint64_t(timeout_ms) * 1000ULL + + timeout_extension_us + paused_us; + if (now_us >= deadline_us) { + cancel_reason.store(1, std::memory_order_relaxed); + request_stop("timeout", 124); + return; + } + } + if (needs_network_wake) { + wake_all_vcpus(); + } + struct timespec ts{0, 10 * 1000000L}; + nanosleep(&ts, nullptr); + } + }); + } + + std::atomic input_done{false}; + std::thread input_thread; + InputThreadGuard input_guard{input_done, input_thread}; + if (interactive) { + input_thread = std::thread([&uart, &input_done, debug, &wake_all_vcpus, &request_stop]() { + uint8_t buf[256]; + while (!input_done.load(std::memory_order_relaxed)) { + struct pollfd pfd{}; + pfd.fd = STDIN_FILENO; + pfd.events = POLLIN; + int prc = poll(&pfd, 1, 20); + if (prc < 0) { + if (errno == EINTR) continue; + break; + } + if (prc == 0) continue; + if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) break; + if ((pfd.revents & POLLIN) == 0) continue; + ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN) continue; + break; + } + if (n == 0) break; + if (debug) { + fprintf(stderr, "[node-vmm hvf] stdin bytes=%zd\n", n); + } + size_t start = 0; + const size_t count = static_cast(n); + for (size_t i = 0; i < count; i++) { + if (buf[i] == 0x1D) { // Ctrl-], QEMU-style host escape for this backend. + if (i > start) { + uart.inject_bytes(buf + start, i - start); + } + request_stop("host-stop", 0); + input_done.store(true, std::memory_order_relaxed); + wake_all_vcpus(); + return; + } + } + uart.inject_bytes(buf + start, count - start); + wake_all_vcpus(); + } + }); + } + + for (auto& thread : vcpu_threads) { + if (thread.joinable()) thread.join(); + } + input_guard.stop(); + monitor_guard.stop(); + if (!thread_error.empty()) { + throw std::runtime_error(thread_error); + } + if (net_dev) net_dev->stop_vmnet(); + map_guard.unmap(); + + // ── Build result ────────────────────────────────────────────────────────── + napi_value result = MakeObject(env); + SetProp(env, result, "exitReason", MakeString(env, exit_reason_name)); + SetProp(env, result, "exitReasonCode", MakeU32(env, static_cast(exit_reason_code))); + SetProp(env, result, "runs", MakeU32(env, static_cast(std::min(runs.load(), UINT32_MAX)))); + SetProp(env, result, "console", MakeString(env, uart.console())); + return result; + + } catch (const std::exception& e) { + return ThrowError(env, std::string("hvf RunVm: ") + e.what()); + } +} + +} // namespace + +// ─── Module init ────────────────────────────────────────────────────────────── +extern "C" { + +static napi_value Init(napi_env env, napi_value exports) { + napi_value fn_probe, fn_run, fn_fdt_smoke, fn_pl011_smoke, fn_device_smoke; + napi_create_function(env, "probeHvf", NAPI_AUTO_LENGTH, ProbeHvf, nullptr, &fn_probe); + napi_create_function(env, "runVm", NAPI_AUTO_LENGTH, RunVm, nullptr, &fn_run); + napi_create_function(env, "hvfFdtSmoke", NAPI_AUTO_LENGTH, HvfFdtSmoke, nullptr, &fn_fdt_smoke); + napi_create_function(env, "hvfPl011Smoke", NAPI_AUTO_LENGTH, HvfPl011Smoke, nullptr, &fn_pl011_smoke); + napi_create_function(env, "hvfDeviceSmoke", NAPI_AUTO_LENGTH, HvfDeviceSmoke, nullptr, &fn_device_smoke); + napi_set_named_property(env, exports, "probeHvf", fn_probe); + napi_set_named_property(env, exports, "runVm", fn_run); + napi_set_named_property(env, exports, "hvfFdtSmoke", fn_fdt_smoke); + napi_set_named_property(env, exports, "hvfPl011Smoke", fn_pl011_smoke); + napi_set_named_property(env, exports, "hvfDeviceSmoke", fn_device_smoke); + return exports; +} + +NAPI_MODULE(node_vmm_native, Init) + +} // extern "C" diff --git a/native/kvm_backend.cc b/native/kvm/backend.cc similarity index 85% rename from native/kvm_backend.cc rename to native/kvm/backend.cc index 6818567..f801582 100644 --- a/native/kvm_backend.cc +++ b/native/kvm/backend.cc @@ -3,9 +3,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -26,13 +28,25 @@ #include #include #include +#include #include #include #include #include #include +#include #include +#ifdef NODE_VMM_HAVE_LIBSLIRP +extern "C" { +#if __has_include() +#include +#else +#include +#endif +} +#endif + #ifndef MFD_CLOEXEC #define MFD_CLOEXEC 0x0001U #endif @@ -42,11 +56,10 @@ namespace { constexpr uint64_t kBootParamsAddr = 0x7000; constexpr uint64_t kPageTableBase = 0x9000; constexpr uint64_t kCmdlineAddr = 0x20000; -constexpr uint64_t kVirtioBlkBase = 0xD0000000; -constexpr uint64_t kVirtioNetBase = 0xD0001000; +constexpr uint64_t kVirtioMmioBase = 0xD0000000; constexpr uint64_t kVirtioStride = 0x1000; -constexpr uint32_t kVirtioBlkIRQ = 5; -constexpr uint32_t kVirtioNetIRQ = 6; +constexpr uint32_t kVirtioMmioIrqBase = 5; +constexpr uint32_t kMaxIoApicPins = 24; constexpr uint16_t kCom1Base = 0x3F8; constexpr uint32_t kCom1IRQ = 4; constexpr uint16_t kNodeVmmExitPort = 0x501; @@ -66,6 +79,14 @@ constexpr int32_t kControlStatePaused = 2; constexpr int32_t kControlStateStopping = 3; constexpr int32_t kControlStateExited = 4; +constexpr uint64_t VirtioMmioBase(uint32_t index) { + return kVirtioMmioBase + uint64_t(index) * kVirtioStride; +} + +constexpr uint32_t VirtioMmioIrq(uint32_t index) { + return kVirtioMmioIrqBase + index; +} + std::string ErrnoMessage(const std::string& what) { return what + ": " + strerror(errno); } @@ -76,6 +97,18 @@ void Check(bool ok, const std::string& message) { } } +void CheckVirtioMmioDeviceCount(size_t count) { + Check(count > 0, "at least one virtio-mmio device is required"); + Check(kVirtioMmioIrqBase + count - 1 < kMaxIoApicPins, "too many virtio-mmio devices"); +} + +std::string VirtioMmioDeviceName(uint32_t index) { + Check(index <= 999, "too many virtio-mmio devices"); + char name[5]; + snprintf(name, sizeof(name), "V%03u", index); + return std::string(name, 4); +} + void CheckErr(int rc, const std::string& what) { if (rc < 0) { throw std::runtime_error(ErrnoMessage(what)); @@ -1170,11 +1203,17 @@ std::vector BuildVirtioMmioDevice(const std::string& name, uint32_t uid return AppendDevice(name, dev_children); } -std::vector BuildDsdtBody(bool network_enabled) { +std::vector BuildDsdtBody(bool network_enabled, uint32_t disk_count) { + CheckVirtioMmioDeviceCount(disk_count + (network_enabled ? 1 : 0)); std::vector> children; - children.push_back(BuildVirtioMmioDevice("V000", 0, kVirtioBlkBase, kVirtioBlkIRQ)); + for (uint32_t index = 0; index < disk_count; index++) { + children.push_back(BuildVirtioMmioDevice( + VirtioMmioDeviceName(index), index, VirtioMmioBase(index), VirtioMmioIrq(index))); + } if (network_enabled) { - children.push_back(BuildVirtioMmioDevice("V001", 1, kVirtioNetBase, kVirtioNetIRQ)); + uint32_t net_index = disk_count; + children.push_back(BuildVirtioMmioDevice( + VirtioMmioDeviceName(net_index), net_index, VirtioMmioBase(net_index), VirtioMmioIrq(net_index))); } return AppendScope("\\_SB_", children); } @@ -1202,8 +1241,8 @@ void FinalizeSdt(std::vector& table) { table[9] = TableChecksum(table); } -std::vector BuildDsdtTable(bool network_enabled) { - auto body = BuildDsdtBody(network_enabled); +std::vector BuildDsdtTable(bool network_enabled, uint32_t disk_count) { + auto body = BuildDsdtBody(network_enabled, disk_count); auto out = BuildSdtHeader("DSDT", static_cast(36 + body.size()), 2, "GCDSDT01"); out.insert(out.end(), body.begin(), body.end()); FinalizeSdt(out); @@ -1286,7 +1325,7 @@ std::vector BuildRsdp(uint64_t rsdt_addr, uint64_t xsdt_addr) { return out; } -uint64_t CreateAcpiTables(GuestMemory mem, bool network_enabled, int cpus) { +uint64_t CreateAcpiTables(GuestMemory mem, bool network_enabled, int cpus, uint32_t disk_count) { constexpr uint64_t rsdp_addr = 0x000E0000; uint64_t cursor = 0x000A0000; auto write_table = [&](const std::vector& table) { @@ -1297,7 +1336,7 @@ uint64_t CreateAcpiTables(GuestMemory mem, bool network_enabled, int cpus) { Check(cursor < rsdp_addr, "ACPI tables overflow low memory"); return addr; }; - auto dsdt = BuildDsdtTable(network_enabled); + auto dsdt = BuildDsdtTable(network_enabled, disk_count); uint64_t dsdt_addr = write_table(dsdt); uint64_t fadt_addr = write_table(BuildFadt(dsdt_addr)); uint64_t madt_addr = write_table(BuildMadt(cpus)); @@ -1311,25 +1350,26 @@ uint64_t CreateAcpiTables(GuestMemory mem, bool network_enabled, int cpus) { struct IRQRoutingTable { uint32_t nr; uint32_t flags; - struct kvm_irq_routing_entry entries[32]; + struct kvm_irq_routing_entry entries[64]; }; void SetIrqRouting(KvmSystem& sys) { IRQRoutingTable table {}; - for (uint32_t irq = 0; irq < 16; irq++) { - uint32_t pic_index = irq * 2; - uint32_t ioapic_index = pic_index + 1; - table.entries[pic_index].gsi = irq; - table.entries[pic_index].type = KVM_IRQ_ROUTING_IRQCHIP; - table.entries[pic_index].u.irqchip.irqchip = irq < 8 ? KVM_IRQCHIP_PIC_MASTER : KVM_IRQCHIP_PIC_SLAVE; - table.entries[pic_index].u.irqchip.pin = irq < 8 ? irq : irq - 8; - - table.entries[ioapic_index].gsi = irq; - table.entries[ioapic_index].type = KVM_IRQ_ROUTING_IRQCHIP; - table.entries[ioapic_index].u.irqchip.irqchip = KVM_IRQCHIP_IOAPIC; - table.entries[ioapic_index].u.irqchip.pin = irq; - } - table.nr = 32; + for (uint32_t irq = 0; irq < kMaxIoApicPins; irq++) { + if (irq < 16) { + struct kvm_irq_routing_entry& pic = table.entries[table.nr++]; + pic.gsi = irq; + pic.type = KVM_IRQ_ROUTING_IRQCHIP; + pic.u.irqchip.irqchip = irq < 8 ? KVM_IRQCHIP_PIC_MASTER : KVM_IRQCHIP_PIC_SLAVE; + pic.u.irqchip.pin = irq < 8 ? irq : irq - 8; + } + + struct kvm_irq_routing_entry& ioapic = table.entries[table.nr++]; + ioapic.gsi = irq; + ioapic.type = KVM_IRQ_ROUTING_IRQCHIP; + ioapic.u.irqchip.irqchip = KVM_IRQCHIP_IOAPIC; + ioapic.u.irqchip.pin = irq; + } CheckErr(IoctlPtr(sys.vm.get(), KVM_SET_GSI_ROUTING, &table), "KVM_SET_GSI_ROUTING"); } @@ -1342,8 +1382,15 @@ void IrqLine(KvmSystem& sys, uint32_t irq, bool level) { class Uart { public: - explicit Uart(size_t limit, bool echo_stdout = true, KvmSystem* sys = nullptr) - : limit_(limit), echo_stdout_(echo_stdout), sys_(sys) {} + explicit Uart( + size_t limit, + bool echo_stdout = true, + KvmSystem* sys = nullptr, + std::function console_output_notify = {}) + : limit_(limit), + echo_stdout_(echo_stdout), + sys_(sys), + console_output_notify_(std::move(console_output_notify)) {} uint8_t read(uint16_t offset) { bool dlab = (lcr_ & 0x80) != 0; @@ -1471,6 +1518,8 @@ class Uart { size_t limit_{1024 * 1024}; bool echo_stdout_{true}; KvmSystem* sys_{nullptr}; + bool console_output_seen_{false}; + std::function console_output_notify_; std::string console_; char last_stdout_byte_{0}; std::deque rx_; @@ -1501,6 +1550,12 @@ class Uart { console_.append(bytes.data(), std::min(available, bytes.size())); } if (echo_stdout_) { + if (!console_output_seen_) { + console_output_seen_ = true; + if (console_output_notify_) { + console_output_notify_(); + } + } write_stdout(bytes); } } @@ -1621,10 +1676,18 @@ struct DescChain { class VirtioBlk { public: - VirtioBlk(KvmSystem& sys, GuestMemory mem, const std::string& path, const std::string& overlay_path) - : sys_(sys), mem_(mem) { + VirtioBlk( + KvmSystem& sys, + GuestMemory mem, + uint64_t mmio_base, + uint32_t irq, + const std::string& path, + const std::string& overlay_path, + bool read_only) + : sys_(sys), mem_(mem), mmio_base_(mmio_base), irq_(irq), read_only_(read_only) { bool overlay = !overlay_path.empty(); - int flags = overlay ? (O_RDONLY | O_CLOEXEC) : (O_RDWR | O_CLOEXEC); + Check(!(read_only_ && overlay), "read-only disk cannot use an overlay"); + int flags = (overlay || read_only_) ? (O_RDONLY | O_CLOEXEC) : (O_RDWR | O_CLOEXEC); base_fd_.reset(open(path.c_str(), flags)); CheckErr(base_fd_.get(), "open rootfs " + path); struct stat st {}; @@ -1642,9 +1705,11 @@ class VirtioBlk { queues_[0].size = kMaxQueueSize; } + uint64_t mmio_base() const { return mmio_base_; } + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { memset(data, 0, len); - uint32_t off = static_cast(addr - kVirtioBlkBase); + uint32_t off = static_cast(addr - mmio_base_); if (off < 0x100) { if (len != 4) { return; @@ -1664,7 +1729,7 @@ class VirtioBlk { } void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { - uint32_t off = static_cast(addr - kVirtioBlkBase); + uint32_t off = static_cast(addr - mmio_base_); if (off >= 0x100 || len != 4) { return; } @@ -1693,6 +1758,9 @@ class VirtioBlk { return 0x554D4551; case 0x010: { uint64_t features = (1ULL << 32) | (1ULL << 9); + if (read_only_) { + features |= 1ULL << 5; + } return dev_features_sel_ == 1 ? uint32_t(features >> 32) : uint32_t(features); } case 0x034: @@ -1743,13 +1811,13 @@ class VirtioBlk { if (value < 8 && queues_[value].ready) { handle_queue(queues_[value]); interrupt_status_ |= 1; - IrqLine(sys_, kVirtioBlkIRQ, true); + IrqLine(sys_, irq_, true); } break; case 0x064: interrupt_status_ &= ~value; if (interrupt_status_ == 0) { - IrqLine(sys_, kVirtioBlkIRQ, false); + IrqLine(sys_, irq_, false); } break; case 0x070: @@ -1792,7 +1860,7 @@ class VirtioBlk { for (auto& q : queues_) { q = Queue {}; } - IrqLine(sys_, kVirtioBlkIRQ, false); + IrqLine(sys_, irq_, false); } Desc read_desc(const Queue& q, uint16_t idx) { @@ -1870,8 +1938,10 @@ class VirtioBlk { WriteDisk(sector, mem_.ptr(d.addr, d.len), d.len); sector += d.len / 512; } - } else if (type == 4) { - CheckErr(fsync(write_fd()), "virtio-blk flush"); + } else if (type == 4) { + if (!read_only_) { + CheckErr(fsync(write_fd()), "virtio-blk flush"); + } } else if (type == 8) { const char id[] = "node-vmm"; for (size_t i = 1; i + 1 < chain.size; i++) { @@ -1971,6 +2041,7 @@ class VirtioBlk { } void WriteDisk(uint64_t sector, const uint8_t* src, size_t len) { + Check(!read_only_, "virtio-blk write to read-only disk"); uint64_t byte_off = CheckedMul(sector, 512, "virtio-blk write offset"); CheckRange(disk_bytes_, byte_off, len, "virtio-blk write"); prepare_partial_overlay_sectors(byte_off, len); @@ -1980,6 +2051,9 @@ class VirtioBlk { KvmSystem& sys_; GuestMemory mem_; + uint64_t mmio_base_{0}; + uint32_t irq_{0}; + bool read_only_{false}; Fd base_fd_; Fd overlay_fd_; std::vector dirty_sectors_; @@ -2016,17 +2090,48 @@ Fd OpenTap(const std::string& tap_name) { return fd; } +struct SlirpHostFwdConfig { + bool udp{false}; + uint32_t host_ip{0}; + uint16_t host_port{0}; + uint16_t guest_port{0}; +}; + class VirtioNet { public: - VirtioNet(KvmSystem& sys, GuestMemory mem, const std::string& tap_name, const std::string& mac) - : sys_(sys), mem_(mem), tap_(OpenTap(tap_name)), mac_(ParseMac(mac)) { + VirtioNet( + KvmSystem& sys, + GuestMemory mem, + uint64_t mmio_base, + uint32_t irq, + const std::string& tap_name, + const std::string& mac, + bool slirp_enabled, + const std::string& slirp_host_ip, + const std::string& slirp_guest_ip, + const std::string& slirp_netmask, + const std::string& slirp_dns, + const std::vector& slirp_host_fwds) + : sys_(sys), + mem_(mem), + mmio_base_(mmio_base), + irq_(irq), + tap_(slirp_enabled ? Fd{} : OpenTap(tap_name)), + mac_(ParseMac(mac)) { queues_[0].size = kMaxQueueSize; queues_[1].size = kMaxQueueSize; + if (slirp_enabled) { + start_slirp(slirp_host_ip, slirp_guest_ip, slirp_netmask, slirp_dns, slirp_host_fwds); + } } + ~VirtioNet() { stop_slirp(); } + + uint64_t mmio_base() const { return mmio_base_; } + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { memset(data, 0, len); - uint32_t off = static_cast(addr - kVirtioNetBase); + uint32_t off = static_cast(addr - mmio_base_); if (off < 0x100) { if (len != 4) { return; @@ -2046,7 +2151,7 @@ class VirtioNet { } void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { - uint32_t off = static_cast(addr - kVirtioNetBase); + uint32_t off = static_cast(addr - mmio_base_); if (off >= 0x100 || len != 4) { return; } @@ -2054,6 +2159,11 @@ class VirtioNet { } void poll_rx() { + if (slirp_enabled()) { + poll_slirp(); + flush_slirp_rx(); + return; + } if (!queues_[0].ready) { drain_tap(false); return; @@ -2076,13 +2186,16 @@ class VirtioNet { if (n == 0) { break; } - inject_rx_frame(frame, static_cast(n)); + (void)inject_rx_frame(frame, static_cast(n)); } } - bool enabled() const { return tap_.get() >= 0; } + bool enabled() const { return tap_.get() >= 0 || slirp_enabled(); } bool tap_readable() const { + if (slirp_enabled()) { + return true; + } struct pollfd pfd {}; pfd.fd = tap_.get(); pfd.events = POLLIN; @@ -2168,7 +2281,7 @@ class VirtioNet { case 0x064: interrupt_status_ &= ~value; if (interrupt_status_ == 0) { - IrqLine(sys_, kVirtioNetIRQ, false); + IrqLine(sys_, irq_, false); } break; case 0x070: @@ -2211,7 +2324,7 @@ class VirtioNet { for (auto& q : queues_) { q = Queue {}; } - IrqLine(sys_, kVirtioNetIRQ, false); + IrqLine(sys_, irq_, false); } Desc read_desc(const Queue& q, uint16_t idx) { @@ -2250,15 +2363,18 @@ class VirtioNet { void signal_queue() { interrupt_status_ |= 1; - IrqLine(sys_, kVirtioNetIRQ, true); + IrqLine(sys_, irq_, true); } bool has_rx_buffer(const Queue& q) const { return q.ready && q.last_avail != ReadU16(mem_.ptr(q.driver_addr + 2, 2)); } - void inject_rx_frame(const uint8_t* frame, size_t len) { + bool inject_rx_frame(const uint8_t* frame, size_t len) { Queue& q = queues_[0]; + if (!has_rx_buffer(q)) { + return false; + } uint16_t head = ReadU16(mem_.ptr(q.driver_addr + 4 + uint64_t(q.last_avail % q.size) * 2, 2)); q.last_avail++; DescChain chain = walk_chain(q, head); @@ -2287,10 +2403,11 @@ class VirtioNet { } } if (offset < needed) { - return; + return false; } push_used(q, head, static_cast(needed)); signal_queue(); + return true; } void handle_tx_queue() { @@ -2309,6 +2426,7 @@ class VirtioNet { DescChain chain = walk_chain(q, head); std::array iov {}; int iov_len = 0; + std::vector packet; size_t skip = 12; for (size_t i = 0; i < chain.size; i++) { const Desc& d = chain[i]; @@ -2321,12 +2439,21 @@ class VirtioNet { pos = skip; skip = 0; if (d.len > pos && iov_len < static_cast(iov.size())) { - iov[iov_len].iov_base = mem_.ptr(d.addr + pos, d.len - pos); + uint8_t* data = mem_.ptr(d.addr + pos, d.len - pos); + if (slirp_enabled()) { + packet.insert(packet.end(), data, data + d.len - pos); + } else { + iov[iov_len].iov_base = data; iov[iov_len].iov_len = d.len - pos; iov_len++; + } } } - if (iov_len > 0) { + if (slirp_enabled()) { + input_slirp_packet(packet.data(), packet.size()); + poll_slirp(); + flush_slirp_rx(); + } else if (iov_len > 0) { ssize_t n = writev(tap_.get(), iov.data(), iov_len); if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { throw std::runtime_error(ErrnoMessage("write tap")); @@ -2337,6 +2464,9 @@ class VirtioNet { } void drain_tap(bool throw_errors) { + if (tap_.get() < 0) { + return; + } uint8_t buf[2048]; for (;;) { ssize_t n = read(tap_.get(), buf, sizeof(buf)); @@ -2353,8 +2483,219 @@ class VirtioNet { } } + bool slirp_enabled() const { +#ifdef NODE_VMM_HAVE_LIBSLIRP + return slirp_ != nullptr; +#else + return false; +#endif + } + + void start_slirp( + const std::string& host_ip, + const std::string& guest_ip, + const std::string& netmask, + const std::string& dns, + const std::vector& host_fwds) { +#ifdef NODE_VMM_HAVE_LIBSLIRP + in_addr host_addr{}; + in_addr guest_addr{}; + in_addr mask_addr{}; + in_addr dns_addr{}; + const std::string effective_host = host_ip.empty() ? "10.0.2.2" : host_ip; + const std::string effective_guest = guest_ip.empty() ? "10.0.2.15" : guest_ip; + const std::string effective_mask = netmask.empty() ? "255.255.255.0" : netmask; + const std::string effective_dns = dns.empty() ? "10.0.2.3" : dns; + Check(inet_pton(AF_INET, effective_host.c_str(), &host_addr) == 1, "invalid slirp host IP: " + effective_host); + Check(inet_pton(AF_INET, effective_guest.c_str(), &guest_addr) == 1, "invalid slirp guest IP: " + effective_guest); + Check(inet_pton(AF_INET, effective_mask.c_str(), &mask_addr) == 1, "invalid slirp netmask: " + effective_mask); + Check(inet_pton(AF_INET, effective_dns.c_str(), &dns_addr) == 1, "invalid slirp DNS IP: " + effective_dns); + + in_addr network_addr{}; + network_addr.s_addr = htonl(ntohl(host_addr.s_addr) & ntohl(mask_addr.s_addr)); + + SlirpConfig cfg{}; + cfg.version = SLIRP_CONFIG_VERSION_MAX; + cfg.in_enabled = true; + cfg.vnetwork = network_addr; + cfg.vnetmask = mask_addr; + cfg.vhost = host_addr; + cfg.vdhcp_start = guest_addr; + cfg.vnameserver = dns_addr; + cfg.if_mtu = 1500; + cfg.if_mru = 1500; + + memset(&slirp_cb_, 0, sizeof(slirp_cb_)); + slirp_cb_.send_packet = &VirtioNet::slirp_send_packet_cb; + slirp_cb_.guest_error = &VirtioNet::slirp_guest_error_cb; + slirp_cb_.clock_get_ns = &VirtioNet::slirp_clock_get_ns_cb; + slirp_cb_.notify = &VirtioNet::slirp_notify_cb; + slirp_cb_.register_poll_fd = &VirtioNet::slirp_register_poll_fd_cb; + slirp_cb_.unregister_poll_fd = &VirtioNet::slirp_unregister_poll_fd_cb; + + slirp_ = slirp_new(&cfg, &slirp_cb_, this); + Check(slirp_ != nullptr, "slirp_new failed"); + + for (const SlirpHostFwdConfig& fwd : host_fwds) { + in_addr host_bind{}; + in_addr guest_bind = guest_addr; + host_bind.s_addr = htonl(fwd.host_ip); + int rc = slirp_add_hostfwd(slirp_, fwd.udp ? 1 : 0, host_bind, fwd.host_port, guest_bind, fwd.guest_port); + Check(rc == 0, "slirp host forward failed: " + std::to_string(fwd.host_port) + " -> " + + effective_guest + ":" + std::to_string(fwd.guest_port)); + } +#else + (void)host_ip; + (void)guest_ip; + (void)netmask; + (void)dns; + (void)host_fwds; + throw std::runtime_error("Linux/KVM slirp networking is not available in this build; install libslirp-dev and rebuild"); +#endif + } + + void stop_slirp() { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ != nullptr) { + slirp_cleanup(slirp_); + slirp_ = nullptr; + } +#endif + } + + void input_slirp_packet(const uint8_t* data, size_t len) { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ != nullptr && data != nullptr && len > 0) { + slirp_input(slirp_, data, static_cast(len)); + } +#else + (void)data; + (void)len; +#endif + } + +#ifdef NODE_VMM_HAVE_LIBSLIRP + struct SlirpPollSet { + std::vector fds; + }; +#endif + + void poll_slirp() { +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (slirp_ == nullptr) { + return; + } + uint32_t timeout_ms = 0; + SlirpPollSet poll_set; + slirp_pollfds_fill(slirp_, &timeout_ms, &VirtioNet::slirp_add_poll_cb, &poll_set); + int rc = 0; + if (!poll_set.fds.empty()) { + rc = poll(poll_set.fds.data(), static_cast(poll_set.fds.size()), 0); + if (rc < 0 && errno == EINTR) { + rc = 0; + } + } + slirp_pollfds_poll(slirp_, rc < 0 ? 1 : 0, &VirtioNet::slirp_get_revents_cb, &poll_set); +#endif + } + + void enqueue_slirp_rx(const uint8_t* data, size_t len) { + if (data == nullptr || len == 0) { + return; + } + slirp_rx_frames_.emplace_back(data, data + len); + } + + void flush_slirp_rx() { + while (!slirp_rx_frames_.empty()) { + const std::vector& frame = slirp_rx_frames_.front(); + if (!inject_rx_frame(frame.data(), frame.size())) { + return; + } + slirp_rx_frames_.pop_front(); + } + } + +#ifdef NODE_VMM_HAVE_LIBSLIRP + static short poll_events_from_slirp(int events) { + short out = 0; + if (events & SLIRP_POLL_IN) out |= POLLIN; + if (events & SLIRP_POLL_OUT) out |= POLLOUT; + if (events & SLIRP_POLL_PRI) out |= POLLPRI; + if (events & SLIRP_POLL_ERR) out |= POLLERR; + if (events & SLIRP_POLL_HUP) out |= POLLHUP; + return out; + } + + static int slirp_events_from_poll(short events) { + int out = 0; + if (events & POLLIN) out |= SLIRP_POLL_IN; + if (events & POLLOUT) out |= SLIRP_POLL_OUT; + if (events & POLLPRI) out |= SLIRP_POLL_PRI; + if (events & POLLERR) out |= SLIRP_POLL_ERR; + if (events & POLLHUP) out |= SLIRP_POLL_HUP; + return out; + } + + static int slirp_add_poll_cb(int fd, int events, void* opaque) { + auto* set = static_cast(opaque); + pollfd pfd{}; + pfd.fd = fd; + pfd.events = poll_events_from_slirp(events); + set->fds.push_back(pfd); + return static_cast(set->fds.size() - 1); + } + + static int slirp_get_revents_cb(int index, void* opaque) { + auto* set = static_cast(opaque); + if (index < 0 || static_cast(index) >= set->fds.size()) { + return 0; + } + return slirp_events_from_poll(set->fds[static_cast(index)].revents); + } + + static ssize_t slirp_send_packet_cb(const void* buf, size_t len, void* opaque) { + auto* self = static_cast(opaque); + if (buf == nullptr || len == 0) { + return 0; + } + if (len < 60) { + uint8_t padded[64]{}; + memcpy(padded, buf, len); + self->enqueue_slirp_rx(padded, 60); + return static_cast(len); + } + self->enqueue_slirp_rx(static_cast(buf), len); + return static_cast(len); + } + + static void slirp_guest_error_cb(const char* msg, void* /*opaque*/) { + if (msg != nullptr) { + fprintf(stderr, "[node-vmm kvm] slirp guest error: %s\n", msg); + } + } + + static int64_t slirp_clock_get_ns_cb(void* /*opaque*/) { + struct timespec ts {}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return int64_t(ts.tv_sec) * 1000000000LL + int64_t(ts.tv_nsec); + } + + static void slirp_notify_cb(void* /*opaque*/) {} + + static void slirp_register_poll_fd_cb(int /*fd*/, void* opaque) { + slirp_notify_cb(opaque); + } + + static void slirp_unregister_poll_fd_cb(int /*fd*/, void* opaque) { + slirp_notify_cb(opaque); + } +#endif + KvmSystem& sys_; GuestMemory mem_; + uint64_t mmio_base_{0}; + uint32_t irq_{0}; Fd tap_; std::array mac_; uint32_t status_{0}; @@ -2364,6 +2705,11 @@ class VirtioNet { uint32_t queue_sel_{0}; uint32_t interrupt_status_{0}; Queue queues_[2]; + std::deque> slirp_rx_frames_; +#ifdef NODE_VMM_HAVE_LIBSLIRP + ::Slirp* slirp_{nullptr}; + SlirpCb slirp_cb_{}; +#endif }; void HandleIo(struct kvm_run* run, Uart& uart, GuestExit* guest_exit = nullptr) { @@ -2403,17 +2749,19 @@ void HandleIo(struct kvm_run* run, Uart& uart, GuestExit* guest_exit = nullptr) } } -bool HandleMmio(struct kvm_run* run, VirtioBlk& blk, VirtioNet* net) { +bool HandleMmio(struct kvm_run* run, const std::vector>& blks, VirtioNet* net) { uint64_t addr = run->mmio.phys_addr; - if (addr >= kVirtioBlkBase && addr < kVirtioBlkBase + kVirtioStride) { - if (run->mmio.is_write) { - blk.write_mmio(addr, run->mmio.data, run->mmio.len); - } else { - blk.read_mmio(addr, run->mmio.data, run->mmio.len); + for (const auto& blk : blks) { + if (addr >= blk->mmio_base() && addr < blk->mmio_base() + kVirtioStride) { + if (run->mmio.is_write) { + blk->write_mmio(addr, run->mmio.data, run->mmio.len); + } else { + blk->read_mmio(addr, run->mmio.data, run->mmio.len); + } + return true; } - return true; } - if (net != nullptr && addr >= kVirtioNetBase && addr < kVirtioNetBase + kVirtioStride) { + if (net != nullptr && addr >= net->mmio_base() && addr < net->mmio_base() + kVirtioStride) { if (run->mmio.is_write) { net->write_mmio(addr, run->mmio.data, run->mmio.len); } else { @@ -2571,6 +2919,88 @@ bool GetBool(napi_env env, napi_value obj, const char* name, bool fallback = fal return out; } +struct AttachedDiskConfig { + std::string path; + bool read_only{false}; +}; + +bool HasNonNullishNamed(napi_env env, napi_value obj, const char* name) { + if (!HasNamed(env, obj, name)) { + return false; + } + return !IsNullish(env, GetNamed(env, obj, name)); +} + +std::vector GetAttachedDisks(napi_env env, napi_value obj) { + bool has_disks = HasNonNullishNamed(env, obj, "disks"); + bool has_attached_disks = HasNonNullishNamed(env, obj, "attachedDisks"); + if (!has_disks && !has_attached_disks) { + return {}; + } + Check(!(has_disks && has_attached_disks), "use either disks or attachedDisks, not both"); + + const char* name = has_disks ? "disks" : "attachedDisks"; + napi_value value = GetNamed(env, obj, name); + bool is_array = false; + napi_is_array(env, value, &is_array); + Check(is_array, std::string(name) + " must be an array"); + + uint32_t length = 0; + napi_get_array_length(env, value, &length); + std::vector out; + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, value, i, &entry); + napi_valuetype type = napi_undefined; + napi_typeof(env, entry, &type); + Check(type == napi_object, std::string(name) + " entries must be objects"); + AttachedDiskConfig disk; + disk.path = GetString(env, entry, "path"); + Check(!disk.path.empty(), std::string(name) + " entries require path"); + disk.read_only = GetBool(env, entry, "readOnly", GetBool(env, entry, "readonly", false)); + out.push_back(std::move(disk)); + } + return out; +} + +uint32_t ParseIpv4HostOrder(const std::string& input, const std::string& label) { + in_addr addr {}; + Check(inet_pton(AF_INET, input.c_str(), &addr) == 1, "invalid IPv4 address for " + label + ": " + input); + return ntohl(addr.s_addr); +} + +std::vector GetSlirpHostFwds(napi_env env, napi_value obj) { + if (!HasNonNullishNamed(env, obj, "netSlirpHostFwds")) { + return {}; + } + napi_value value = GetNamed(env, obj, "netSlirpHostFwds"); + bool is_array = false; + napi_is_array(env, value, &is_array); + Check(is_array, "netSlirpHostFwds must be an array"); + + uint32_t length = 0; + napi_get_array_length(env, value, &length); + std::vector out; + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, value, i, &entry); + napi_valuetype type = napi_undefined; + napi_typeof(env, entry, &type); + Check(type == napi_object, "netSlirpHostFwds entries must be objects"); + SlirpHostFwdConfig fwd; + fwd.udp = GetBool(env, entry, "udp", false); + fwd.host_ip = ParseIpv4HostOrder(GetString(env, entry, "hostAddr", "127.0.0.1"), "netSlirpHostFwds.hostAddr"); + fwd.host_port = static_cast(GetUint32(env, entry, "hostPort", 0)); + fwd.guest_port = static_cast(GetUint32(env, entry, "guestPort", 0)); + Check(fwd.host_port > 0, "netSlirpHostFwds entries require hostPort"); + Check(fwd.guest_port > 0, "netSlirpHostFwds entries require guestPort"); + out.push_back(fwd); + } + return out; +} + struct RunControl { int32_t* words{nullptr}; size_t length{0}; @@ -2589,6 +3019,12 @@ struct RunControl { __atomic_store_n(&words[1], state, __ATOMIC_SEQ_CST); } } + + void mark_console_output() const { + if (words != nullptr && length >= 3) { + __atomic_store_n(&words[2], 1, __ATOMIC_SEQ_CST); + } + } }; RunControl GetRunControl(napi_env env, napi_value obj) { @@ -2625,7 +3061,14 @@ class TerminalRawMode { return; } struct termios raw = old_; - cfmakeraw(&raw); + raw.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | + INLCR | IGNCR | ICRNL | IXON); + raw.c_oflag |= OPOST; + raw.c_lflag &= ~(ECHO | ECHONL | ICANON | IEXTEN | ISIG); + raw.c_cflag &= ~(CSIZE | PARENB); + raw.c_cflag |= CS8; + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0) { active_ = true; } @@ -3036,28 +3479,61 @@ napi_value RunVm(napi_env env, napi_callback_info info) { bool interactive = GetBool(env, argv[0], "interactive", false); std::string tap_name = GetString(env, argv[0], "netTapName"); std::string guest_mac = GetString(env, argv[0], "netGuestMac"); + bool slirp_enabled = GetBool(env, argv[0], "netSlirpEnabled", false); + std::string slirp_host_ip = GetString(env, argv[0], "netHostIp", "10.0.2.2"); + std::string slirp_guest_ip = GetString(env, argv[0], "netGuestIp", "10.0.2.15"); + std::string slirp_netmask = GetString(env, argv[0], "netNetmask", "255.255.255.0"); + std::string slirp_dns = GetString(env, argv[0], "netDns", "10.0.2.3"); + std::vector slirp_host_fwds = GetSlirpHostFwds(env, argv[0]); + std::vector attached_disks = GetAttachedDisks(env, argv[0]); RunControl control = GetRunControl(env, argv[0]); control.set_state(kControlStateStarting); - bool network_enabled = !tap_name.empty(); + bool network_enabled = !tap_name.empty() || slirp_enabled; + Check(attached_disks.size() < kMaxIoApicPins, "too many attached disks"); + uint32_t disk_count = static_cast(attached_disks.size() + 1); + CheckVirtioMmioDeviceCount(disk_count + (network_enabled ? 1 : 0)); Check(!kernel_path.empty(), "kernelPath is required"); Check(!rootfs_path.empty(), "rootfsPath is required"); Check(!cmdline.empty(), "cmdline is required"); Check(cpus >= 1 && cpus <= kMaxVcpus, "cpus must be between 1 and 64"); - Check(!network_enabled || !guest_mac.empty(), "netGuestMac is required when netTapName is set"); + Check(!(slirp_enabled && !tap_name.empty()), "netSlirpEnabled cannot be combined with netTapName"); + Check(!network_enabled || !guest_mac.empty(), "netGuestMac is required when networking is enabled"); Check(cmdline.size() + 1 <= kKernelCmdlineMax, "kernel cmdline is too long"); KvmSystem sys = CreateVm(mem_mib, true); GuestMemory mem = sys.guest(); KernelInfo kernel = LoadElfKernel(mem, kernel_path); - uint64_t rsdp_addr = CreateAcpiTables(mem, network_enabled, static_cast(cpus)); + uint64_t rsdp_addr = CreateAcpiTables(mem, network_enabled, static_cast(cpus), disk_count); WriteBootParams(mem, uint64_t(mem_mib) * 1024ULL * 1024ULL, cmdline); WriteU64(mem.ptr(kBootParamsAddr + 0x70, 8), rsdp_addr); WriteMpTable(mem, static_cast(cpus)); SetIrqRouting(sys); - VirtioBlk blk(sys, mem, rootfs_path, overlay_path); + std::vector> blks; + blks.reserve(disk_count); + blks.push_back(std::make_unique( + sys, mem, VirtioMmioBase(0), VirtioMmioIrq(0), rootfs_path, overlay_path, false)); + for (uint32_t i = 0; i < attached_disks.size(); i++) { + const AttachedDiskConfig& disk = attached_disks[i]; + uint32_t index = i + 1; + blks.push_back(std::make_unique( + sys, mem, VirtioMmioBase(index), VirtioMmioIrq(index), disk.path, "", disk.read_only)); + } std::unique_ptr net; if (network_enabled) { - net = std::make_unique(sys, mem, tap_name, guest_mac); + uint32_t net_index = disk_count; + net = std::make_unique( + sys, + mem, + VirtioMmioBase(net_index), + VirtioMmioIrq(net_index), + tap_name, + guest_mac, + slirp_enabled, + slirp_host_ip, + slirp_guest_ip, + slirp_netmask, + slirp_dns, + slirp_host_fwds); } std::vector vcpus; @@ -3070,7 +3546,7 @@ napi_value RunVm(napi_env env, napi_callback_info info) { SetupApplicationVcpu(sys, vcpus[i], i, cpus); } - Uart uart(console_limit, interactive, &sys); + Uart uart(console_limit, interactive, &sys, [control]() { control.mark_console_output(); }); TerminalRawMode raw_mode(interactive); std::atomic input_done{false}; std::atomic host_interrupt_requested{false}; @@ -3329,7 +3805,7 @@ napi_value RunVm(napi_env env, napi_callback_info info) { case KVM_EXIT_MMIO: { std::lock_guard lock(device_mu); - HandleMmio(cpu.run, blk, net.get()); + HandleMmio(cpu.run, blks, net.get()); } break; case KVM_EXIT_IRQ_WINDOW_OPEN: diff --git a/native/whp/api.h b/native/whp/api.h new file mode 100644 index 0000000..258885f --- /dev/null +++ b/native/whp/api.h @@ -0,0 +1,165 @@ +#pragma once + +// Dynamic-loader for WinHvPlatform.dll + WinHvEmulation.dll. Holds one +// function pointer per WHP API call we use. Two construction modes: +// +// * `require_all = true` : every symbol must resolve, otherwise throw. +// Used by RunVm before partition creation. +// * `require_all = false` : best-effort, only loads what's needed by +// the probe / smoke entry points so we can +// tell users which features are available +// without requiring the full DLL surface. +// +// Header definition (no .cc) so header-only consumers like the new +// per-module .cc files can call into WhpApi without a circular include. + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include + +#include +#include + +#include "../common/bytes.h" + +namespace node_vmm::whp { + +using WHvGetCapabilityFn = HRESULT(WINAPI*)(WHV_CAPABILITY_CODE, VOID*, UINT32, UINT32*); +using WHvCreatePartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE*); +using WHvSetupPartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE); +using WHvDeletePartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE); +using WHvSetPartitionPropertyFn = + HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, WHV_PARTITION_PROPERTY_CODE, const VOID*, UINT32); +using WHvCreateVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, UINT32); +using WHvDeleteVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32); +using WHvMapGpaRangeFn = + HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, VOID*, WHV_GUEST_PHYSICAL_ADDRESS, UINT64, WHV_MAP_GPA_RANGE_FLAGS); +using WHvQueryGpaRangeDirtyBitmapFn = + HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, WHV_GUEST_PHYSICAL_ADDRESS, UINT64, UINT64*, UINT32); +using WHvTranslateGvaFn = HRESULT(WINAPI*)( + WHV_PARTITION_HANDLE, + UINT32, + WHV_GUEST_VIRTUAL_ADDRESS, + WHV_TRANSLATE_GVA_FLAGS, + WHV_TRANSLATE_GVA_RESULT*, + WHV_GUEST_PHYSICAL_ADDRESS*); +using WHvRunVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, VOID*, UINT32); +using WHvCancelRunVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, UINT32); +using WHvGetVirtualProcessorRegistersFn = + HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, const WHV_REGISTER_NAME*, UINT32, WHV_REGISTER_VALUE*); +using WHvSetVirtualProcessorRegistersFn = + HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, const WHV_REGISTER_NAME*, UINT32, const WHV_REGISTER_VALUE*); +using WHvRequestInterruptFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, const WHV_INTERRUPT_CONTROL*, UINT32); +using WHvEmulatorCreateEmulatorFn = HRESULT(WINAPI*)(const WHV_EMULATOR_CALLBACKS*, WHV_EMULATOR_HANDLE*); +using WHvEmulatorDestroyEmulatorFn = HRESULT(WINAPI*)(WHV_EMULATOR_HANDLE); +using WHvEmulatorTryIoEmulationFn = HRESULT(WINAPI*)( + WHV_EMULATOR_HANDLE, + VOID*, + const WHV_VP_EXIT_CONTEXT*, + const WHV_X64_IO_PORT_ACCESS_CONTEXT*, + WHV_EMULATOR_STATUS*); +using WHvEmulatorTryMmioEmulationFn = HRESULT(WINAPI*)( + WHV_EMULATOR_HANDLE, + VOID*, + const WHV_VP_EXIT_CONTEXT*, + const WHV_MEMORY_ACCESS_CONTEXT*, + WHV_EMULATOR_STATUS*); + +template +inline T LoadSymbol(HMODULE dll, const char* name) { + FARPROC proc = GetProcAddress(dll, name); + if (!proc) { + throw std::runtime_error(std::string("WinHvPlatform.dll does not export ") + name); + } + return reinterpret_cast(proc); +} + +struct WhpApi { + HMODULE dll{nullptr}; + HMODULE emu_dll{nullptr}; + WHvGetCapabilityFn get_capability{nullptr}; + WHvCreatePartitionFn create_partition{nullptr}; + WHvSetupPartitionFn setup_partition{nullptr}; + WHvDeletePartitionFn delete_partition{nullptr}; + WHvSetPartitionPropertyFn set_partition_property{nullptr}; + WHvCreateVirtualProcessorFn create_vp{nullptr}; + WHvDeleteVirtualProcessorFn delete_vp{nullptr}; + WHvMapGpaRangeFn map_gpa_range{nullptr}; + WHvQueryGpaRangeDirtyBitmapFn query_dirty_bitmap{nullptr}; + WHvTranslateGvaFn translate_gva{nullptr}; + WHvRunVirtualProcessorFn run_vp{nullptr}; + WHvCancelRunVirtualProcessorFn cancel_run_vp{nullptr}; + WHvGetVirtualProcessorRegistersFn get_vp_registers{nullptr}; + WHvSetVirtualProcessorRegistersFn set_vp_registers{nullptr}; + WHvRequestInterruptFn request_interrupt{nullptr}; + WHvEmulatorCreateEmulatorFn emulator_create{nullptr}; + WHvEmulatorDestroyEmulatorFn emulator_destroy{nullptr}; + WHvEmulatorTryIoEmulationFn emulator_try_io{nullptr}; + WHvEmulatorTryMmioEmulationFn emulator_try_mmio{nullptr}; + + explicit WhpApi(bool require_all) { + dll = LoadLibraryW(L"WinHvPlatform.dll"); + if (!dll) { + if (require_all) { + throw std::runtime_error("WinHvPlatform.dll is not available: " + + node_vmm::common::WindowsErrorMessage(GetLastError())); + } + return; + } + get_capability = LoadSymbol(dll, "WHvGetCapability"); + if (!require_all) { + query_dirty_bitmap = + reinterpret_cast(GetProcAddress(dll, "WHvQueryGpaRangeDirtyBitmap")); + create_partition = reinterpret_cast(GetProcAddress(dll, "WHvCreatePartition")); + setup_partition = reinterpret_cast(GetProcAddress(dll, "WHvSetupPartition")); + delete_partition = reinterpret_cast(GetProcAddress(dll, "WHvDeletePartition")); + set_partition_property = + reinterpret_cast(GetProcAddress(dll, "WHvSetPartitionProperty")); + return; + } + create_partition = LoadSymbol(dll, "WHvCreatePartition"); + setup_partition = LoadSymbol(dll, "WHvSetupPartition"); + delete_partition = LoadSymbol(dll, "WHvDeletePartition"); + set_partition_property = LoadSymbol(dll, "WHvSetPartitionProperty"); + create_vp = LoadSymbol(dll, "WHvCreateVirtualProcessor"); + delete_vp = LoadSymbol(dll, "WHvDeleteVirtualProcessor"); + map_gpa_range = LoadSymbol(dll, "WHvMapGpaRange"); + query_dirty_bitmap = LoadSymbol(dll, "WHvQueryGpaRangeDirtyBitmap"); + translate_gva = LoadSymbol(dll, "WHvTranslateGva"); + run_vp = LoadSymbol(dll, "WHvRunVirtualProcessor"); + cancel_run_vp = LoadSymbol(dll, "WHvCancelRunVirtualProcessor"); + get_vp_registers = LoadSymbol(dll, "WHvGetVirtualProcessorRegisters"); + set_vp_registers = LoadSymbol(dll, "WHvSetVirtualProcessorRegisters"); + request_interrupt = LoadSymbol(dll, "WHvRequestInterrupt"); + + emu_dll = LoadLibraryW(L"WinHvEmulation.dll"); + if (!emu_dll) { + throw std::runtime_error("WinHvEmulation.dll is not available: " + + node_vmm::common::WindowsErrorMessage(GetLastError())); + } + emulator_create = LoadSymbol(emu_dll, "WHvEmulatorCreateEmulator"); + emulator_destroy = LoadSymbol(emu_dll, "WHvEmulatorDestroyEmulator"); + emulator_try_io = LoadSymbol(emu_dll, "WHvEmulatorTryIoEmulation"); + emulator_try_mmio = LoadSymbol(emu_dll, "WHvEmulatorTryMmioEmulation"); + } + + WhpApi(const WhpApi&) = delete; + WhpApi& operator=(const WhpApi&) = delete; + + ~WhpApi() { + if (emu_dll) { + FreeLibrary(emu_dll); + } + if (dll) { + FreeLibrary(dll); + } + } +}; + +} // namespace node_vmm::whp diff --git a/native/whp/backend.cc b/native/whp/backend.cc new file mode 100644 index 0000000..7dd3f91 --- /dev/null +++ b/native/whp/backend.cc @@ -0,0 +1,4814 @@ +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "api.h" +#include "boot_params.h" +#include "console_writer.h" +#include "devices/acpi_pm_timer.h" +#include "devices/cmos.h" +#include "devices/hpet.h" +#include "devices/pic.h" +#include "devices/pit.h" +#include "devices/uart.h" +#include "elf_loader.h" +#include "guest_memory.h" +#include "irq.h" +#include "page_tables.h" +#include "virtio/blk.h" +#include "virtio/rng.h" +#include "win_console_ctrl.h" +#include "win_io.h" + +namespace { + +// Pull extracted module symbols into this anonymous namespace so existing +// call sites stay unqualified. All the new modules live in node_vmm::whp. +using node_vmm::whp::AcpiPmTimer; +using node_vmm::whp::ArmInterruptWindow; +using node_vmm::whp::BuildPageTables; +using node_vmm::whp::Cmos; +using node_vmm::whp::DisarmInterruptWindow; +using node_vmm::whp::Hpet; +using node_vmm::whp::InterruptibilitySnapshot; +using node_vmm::whp::KernelInfo; +using node_vmm::whp::KickVcpuOutOfHlt; +using node_vmm::whp::LoadElfKernel; +using node_vmm::whp::LongCodeSegment; +using node_vmm::whp::LongDataSegment; +using node_vmm::whp::Pic; +using node_vmm::whp::Pit; +using node_vmm::whp::Uart; +using node_vmm::whp::ReadInterruptibility; +using node_vmm::whp::RequestFixedInterrupt; +using node_vmm::whp::Segment; +using node_vmm::whp::SetPendingExtInt; +using node_vmm::whp::Table; +using node_vmm::whp::TryDeliverPendingExtInt; +using node_vmm::whp::UpdateVcpuFromExit; +using node_vmm::whp::VirtioBlk; +using node_vmm::whp::VirtioRng; +using node_vmm::whp::WhpApi; +using node_vmm::whp::WhpVcpuIrqState; +using node_vmm::whp::WriteBootParams; +// WriteMpTable is wrapped below to forward kPitIoApicPin from this TU. +} // namespace + +namespace { + +constexpr uint64_t kGuestCodeAddr = 0x1000; +constexpr uint64_t kGuestWriteAddr = 0x2000; +constexpr uint64_t kGuestRamBytes = 0x10000; +constexpr uint64_t kBootParamsAddr = 0x7000; +constexpr uint64_t kPageTableBase = 0x9000; +constexpr uint64_t kCmdlineAddr = 0x20000; +// Layout matches qemu/hw/i386/microvm.c VIRTIO_MMIO_BASE plus a 512 byte +// stride per device. The virtio-mmio v2 spec only mandates the registers up +// through offset 0x100 plus a 256 byte config area, so 0x200 is enough room +// for one device and lets the kernel pack multiple virtio-mmio devices in a +// single 4 KiB page like QEMU does. +constexpr uint64_t kVirtioMmioBase = 0xD0000000; +constexpr uint64_t kVirtioStride = 0x200; +constexpr uint64_t kIoApicBase = 0xFEC00000; +constexpr uint64_t kIoApicSize = 0x1000; +constexpr uint64_t kHpetBase = 0xFED00000; +constexpr uint64_t kHpetSize = 0x400; +constexpr uint32_t kVirtioMmioIrqBase = 5; +constexpr uint32_t kMaxIoApicPins = 24; +constexpr uint32_t kPitIoApicPin = 2; +constexpr uint32_t kHpetTimer0IoApicPin = kPitIoApicPin; +constexpr uint16_t kCom1Base = 0x3F8; +constexpr uint16_t kAcpiPmTimerPort = 0x408; +constexpr uint32_t kCom1IRQ = 4; +constexpr uint16_t kNodeVmmExitPort = 0x501; +constexpr uint16_t kNodeVmmConsolePort = 0x600; +constexpr uint32_t kHvCpuidVendorAndMax = 0x40000000; +constexpr uint32_t kHvCpuidInterface = 0x40000001; +constexpr uint32_t kHvCpuidVersion = 0x40000002; +constexpr uint32_t kHvCpuidFeatures = 0x40000003; +constexpr uint32_t kHvCpuidEnlightenmentInfo = 0x40000004; +constexpr uint32_t kHvCpuidImplementationLimits = 0x40000005; +constexpr uint32_t kHvMsrGuestOsId = 0x40000000; +constexpr uint32_t kHvMsrHypercall = 0x40000001; +constexpr uint32_t kHvMsrVpIndex = 0x40000002; +constexpr uint32_t kHvMsrTimeRefCount = 0x40000020; +constexpr uint32_t kHvMsrTscFrequency = 0x40000022; +constexpr uint32_t kHvMsrApicFrequency = 0x40000023; +constexpr uint64_t kFallbackTscFrequencyHz = 2500000000ULL; +constexpr uint64_t kFallbackApicFrequencyHz = 200000000ULL; +constexpr uint32_t kVirtioMagic = 0x74726976; // "virt" +constexpr uint32_t kVirtioStatusAck = 0x01; +constexpr uint32_t kVirtioStatusDriver = 0x02; +constexpr uint32_t kVirtioStatusDriverOk = 0x04; +constexpr uint32_t kVirtioStatusFeaturesOk = 0x08; +constexpr uint32_t kVirtioStatusFailed = 0x80; +constexpr uint32_t kVirtioRingDescFNext = 1; +constexpr uint32_t kVirtioRingDescFWrite = 2; +constexpr uint32_t kVirtioRingDescFIndirect = 4; +constexpr uint16_t kVirtioRingAvailFNoInterrupt = 1; +constexpr uint64_t kVirtioFVersion1 = 1ULL << 32; +constexpr uint64_t kVirtioNetFMac = 1ULL << 5; +constexpr uint64_t kVirtioNetFStatus = 1ULL << 16; +constexpr uint16_t kVirtioMmioMagicValue = 0x000; +constexpr uint16_t kVirtioMmioVersion = 0x004; +constexpr uint16_t kVirtioMmioDeviceId = 0x008; +constexpr uint16_t kVirtioMmioVendorId = 0x00C; +constexpr uint16_t kVirtioMmioDeviceFeatures = 0x010; +constexpr uint16_t kVirtioMmioDeviceFeaturesSel = 0x014; +constexpr uint16_t kVirtioMmioDriverFeatures = 0x020; +constexpr uint16_t kVirtioMmioDriverFeaturesSel = 0x024; +constexpr uint16_t kVirtioMmioQueueSel = 0x030; +constexpr uint16_t kVirtioMmioQueueNumMax = 0x034; +constexpr uint16_t kVirtioMmioQueueNum = 0x038; +constexpr uint16_t kVirtioMmioQueueReady = 0x044; +constexpr uint16_t kVirtioMmioQueueNotify = 0x050; +constexpr uint16_t kVirtioMmioInterruptStatus = 0x060; +constexpr uint16_t kVirtioMmioInterruptAck = 0x064; +constexpr uint16_t kVirtioMmioStatus = 0x070; +constexpr uint16_t kVirtioMmioQueueDescLow = 0x080; +constexpr uint16_t kVirtioMmioQueueDescHigh = 0x084; +constexpr uint16_t kVirtioMmioQueueDriverLow = 0x090; +constexpr uint16_t kVirtioMmioQueueDriverHigh = 0x094; +constexpr uint16_t kVirtioMmioQueueDeviceLow = 0x0A0; +constexpr uint16_t kVirtioMmioQueueDeviceHigh = 0x0A4; +constexpr uint16_t kVirtioMmioShmSel = 0x0AC; +constexpr uint16_t kVirtioMmioShmLenLow = 0x0B0; +constexpr uint16_t kVirtioMmioShmLenHigh = 0x0B4; +constexpr uint16_t kVirtioMmioShmBaseLow = 0x0B8; +constexpr uint16_t kVirtioMmioShmBaseHigh = 0x0BC; +constexpr uint16_t kVirtioMmioConfigGeneration = 0x0FC; +constexpr uint16_t kVirtioMmioConfig = 0x100; +constexpr uint32_t kVirtioInterruptVring = 0x1; +constexpr uint32_t kVirtioInterruptConfig = 0x2; +constexpr uint64_t kGdtAddr = 0x500; +constexpr uint64_t kIdtAddr = 0x520; +constexpr uint32_t kMaxQueueSize = 256; +constexpr uint32_t kKernelCmdlineMax = 2048; +constexpr uint32_t kMaxVcpus = 64; +constexpr int32_t kControlRun = 0; +constexpr int32_t kControlPause = 1; +constexpr int32_t kControlStop = 2; +constexpr int32_t kControlStateStarting = 0; +constexpr int32_t kControlStateRunning = 1; +constexpr int32_t kControlStatePaused = 2; +constexpr int32_t kControlStateStopping = 3; +constexpr int32_t kControlStateExited = 4; + +constexpr uint64_t VirtioMmioBase(uint32_t index) { + return kVirtioMmioBase + uint64_t(index) * kVirtioStride; +} + +constexpr uint32_t VirtioMmioIrq(uint32_t index) { + return kVirtioMmioIrqBase + index; +} + +// ELF constants (kElfMagic, kElfClass64, kElfDataLe, kElfMachineX64, +// kElfPtLoad) plus the Elf64Ehdr/Elf64Phdr structs and the LoadElfKernel +// implementation moved to native/whp_elf_loader.{h,cc} as part of the +// PR-2 modularization. The KernelInfo struct + LoadElfKernel function are +// re-exported into this anonymous namespace via `using` declarations near +// the top of the file. + +// Elf64Ehdr / Elf64Phdr / KernelInfo moved to whp_elf_loader.{h,cc}. +// `KernelInfo` is re-imported into this anonymous namespace at the top. + +std::string WindowsErrorMessage(DWORD code) { + char* buffer = nullptr; + DWORD size = FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + code, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + reinterpret_cast(&buffer), + 0, + nullptr); + std::string message = size && buffer ? std::string(buffer, size) : "unknown Windows error"; + if (buffer) { + LocalFree(buffer); + } + while (!message.empty() && (message.back() == '\n' || message.back() == '\r' || message.back() == ' ')) { + message.pop_back(); + } + return message; +} + +std::string HresultMessage(HRESULT hr, const std::string& what) { + char code[16]; + std::snprintf(code, sizeof(code), "0x%08lx", static_cast(hr)); + return what + " failed with HRESULT " + code + ": " + WindowsErrorMessage(static_cast(hr)); +} + +void Check(bool ok, const std::string& message) { + if (!ok) { + throw std::runtime_error(message); + } +} + +void CheckVirtioMmioDeviceCount(size_t count) { + Check(count > 0, "at least one virtio-mmio device is required"); + Check(kVirtioMmioIrqBase + count - 1 < kMaxIoApicPins, "too many virtio-mmio devices"); +} + +std::string VirtioMmioDeviceName(uint32_t index) { + Check(index <= 999, "too many virtio-mmio devices"); + char name[5]; + std::snprintf(name, sizeof(name), "V%03u", index); + return std::string(name, 4); +} + +void CheckHr(HRESULT hr, const std::string& what) { + if (FAILED(hr)) { + throw std::runtime_error(HresultMessage(hr, what)); + } +} + +napi_value MakeObject(napi_env env) { + napi_value obj; + napi_create_object(env, &obj); + return obj; +} + +void SetString(napi_env env, napi_value obj, const char* name, const std::string& value) { + napi_value v; + napi_create_string_utf8(env, value.c_str(), value.size(), &v); + napi_set_named_property(env, obj, name, v); +} + +void SetBool(napi_env env, napi_value obj, const char* name, bool value) { + napi_value v; + napi_get_boolean(env, value, &v); + napi_set_named_property(env, obj, name, v); +} + +void SetUint32(napi_env env, napi_value obj, const char* name, uint32_t value) { + napi_value v; + napi_create_uint32(env, value, &v); + napi_set_named_property(env, obj, name, v); +} + +void SetDouble(napi_env env, napi_value obj, const char* name, double value) { + napi_value v; + napi_create_double(env, value, &v); + napi_set_named_property(env, obj, name, v); +} + +std::string GetString(napi_env env, napi_value obj, const char* name) { + napi_value v; + bool has = false; + napi_has_named_property(env, obj, name, &has); + if (!has) { + return ""; + } + napi_get_named_property(env, obj, name, &v); + napi_valuetype type; + napi_typeof(env, v, &type); + if (type == napi_undefined || type == napi_null) { + return ""; + } + if (type != napi_string) { + throw std::runtime_error(std::string(name) + " must be a string"); + } + size_t len = 0; + napi_get_value_string_utf8(env, v, nullptr, 0, &len); + std::vector buffer(len + 1); + napi_get_value_string_utf8(env, v, buffer.data(), buffer.size(), &len); + std::string out(buffer.data(), len); + return out; +} + +uint32_t GetUint32(napi_env env, napi_value obj, const char* name, uint32_t fallback) { + napi_value v; + bool has = false; + napi_has_named_property(env, obj, name, &has); + if (!has) { + return fallback; + } + napi_get_named_property(env, obj, name, &v); + napi_valuetype type; + napi_typeof(env, v, &type); + if (type == napi_undefined || type == napi_null) { + return fallback; + } + uint32_t out = 0; + napi_get_value_uint32(env, v, &out); + return out; +} + +bool GetBool(napi_env env, napi_value obj, const char* name, bool fallback) { + napi_value v; + bool has = false; + napi_has_named_property(env, obj, name, &has); + if (!has) { + return fallback; + } + napi_get_named_property(env, obj, name, &v); + napi_valuetype type; + napi_typeof(env, v, &type); + if (type == napi_undefined || type == napi_null) { + return fallback; + } + bool out = fallback; + napi_get_value_bool(env, v, &out); + return out; +} + +struct AttachedDiskConfig { + std::string path; + bool read_only{false}; +}; + +bool HasNonNullishNamed(napi_env env, napi_value obj, const char* name) { + bool has = false; + napi_has_named_property(env, obj, name, &has); + if (!has) { + return false; + } + napi_value value; + napi_get_named_property(env, obj, name, &value); + napi_valuetype type; + napi_typeof(env, value, &type); + return type != napi_undefined && type != napi_null; +} + +std::vector GetAttachedDisks(napi_env env, napi_value obj) { + bool has_disks = HasNonNullishNamed(env, obj, "disks"); + bool has_attached_disks = HasNonNullishNamed(env, obj, "attachedDisks"); + if (!has_disks && !has_attached_disks) { + return {}; + } + Check(!(has_disks && has_attached_disks), "use either disks or attachedDisks, not both"); + + const char* name = has_disks ? "disks" : "attachedDisks"; + napi_value value; + napi_get_named_property(env, obj, name, &value); + bool is_array = false; + napi_is_array(env, value, &is_array); + Check(is_array, std::string(name) + " must be an array"); + + uint32_t length = 0; + napi_get_array_length(env, value, &length); + std::vector out; + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, value, i, &entry); + napi_valuetype type = napi_undefined; + napi_typeof(env, entry, &type); + Check(type == napi_object, std::string(name) + " entries must be objects"); + AttachedDiskConfig disk; + disk.path = GetString(env, entry, "path"); + Check(!disk.path.empty(), std::string(name) + " entries require path"); + disk.read_only = GetBool(env, entry, "readOnly", GetBool(env, entry, "readonly", false)); + out.push_back(std::move(disk)); + } + return out; +} + +napi_value Throw(napi_env env, const std::exception& err) { + napi_throw_error(env, nullptr, err.what()); + return nullptr; +} + +// WhpApi + LoadSymbol + WHv*Fn typedefs moved to whp/api.h. + +struct Partition { + WhpApi& api; + WHV_PARTITION_HANDLE handle{nullptr}; + + explicit Partition(WhpApi& api_ref) : api(api_ref) { + CheckHr(api.create_partition(&handle), "WHvCreatePartition"); + } + + Partition(const Partition&) = delete; + Partition& operator=(const Partition&) = delete; + + ~Partition() { + if (handle) { + api.delete_partition(handle); + } + } +}; + +struct VirtualProcessor { + WhpApi& api; + WHV_PARTITION_HANDLE partition{nullptr}; + UINT32 index{0}; + bool created{false}; + + VirtualProcessor(WhpApi& api_ref, WHV_PARTITION_HANDLE partition_handle, UINT32 vp_index) + : api(api_ref), partition(partition_handle), index(vp_index) { + CheckHr(api.create_vp(partition, index, 0), "WHvCreateVirtualProcessor"); + created = true; + } + + VirtualProcessor(const VirtualProcessor&) = delete; + VirtualProcessor& operator=(const VirtualProcessor&) = delete; + + ~VirtualProcessor() { + if (created) { + api.delete_vp(partition, index); + } + } +}; + +struct VirtualAllocMemory { + void* ptr{nullptr}; + + explicit VirtualAllocMemory(size_t bytes) { + ptr = VirtualAlloc(nullptr, bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); + Check(ptr != nullptr, "VirtualAlloc guest RAM failed: " + WindowsErrorMessage(GetLastError())); + } + + VirtualAllocMemory(const VirtualAllocMemory&) = delete; + VirtualAllocMemory& operator=(const VirtualAllocMemory&) = delete; + + ~VirtualAllocMemory() { + if (ptr) { + VirtualFree(ptr, 0, MEM_RELEASE); + } + } + + uint8_t* bytes() const { return reinterpret_cast(ptr); } +}; + +void CheckRange(uint64_t total, uint64_t offset, uint64_t len, const std::string& what); + +struct RunControl { + int32_t* words{nullptr}; + size_t length{0}; + + bool enabled() const { return words != nullptr && length >= 2; } + + int32_t command() const { + if (!enabled()) { + return kControlRun; + } + volatile LONG* ptr = reinterpret_cast(&words[0]); + return InterlockedCompareExchange(ptr, 0, 0); + } + + void set_state(int32_t state) const { + if (enabled()) { + volatile LONG* ptr = reinterpret_cast(&words[1]); + InterlockedExchange(ptr, state); + } + } +}; + +RunControl GetRunControl(napi_env env, napi_value obj) { + napi_value value; + bool has = false; + napi_has_named_property(env, obj, "control", &has); + if (!has) { + return {}; + } + napi_get_named_property(env, obj, "control", &value); + napi_valuetype value_type; + napi_typeof(env, value, &value_type); + if (value_type == napi_undefined || value_type == napi_null) { + return {}; + } + bool is_typedarray = false; + napi_is_typedarray(env, value, &is_typedarray); + Check(is_typedarray, "control must be an Int32Array"); + + napi_typedarray_type type; + size_t length = 0; + void* data = nullptr; + napi_value arraybuffer; + size_t byte_offset = 0; + Check(napi_get_typedarray_info(env, value, &type, &length, &data, &arraybuffer, &byte_offset) == napi_ok, + "napi_get_typedarray_info control failed"); + Check(type == napi_int32_array, "control must be an Int32Array"); + Check(length >= 2, "control Int32Array must have at least 2 entries"); + return {reinterpret_cast(data), length}; +} + +// GuestMemory moved to whp/guest_memory.h. +using node_vmm::whp::GuestMemory; + +// WinHandle / PreadAll / PwriteAll / TruncateFileTo / MarkFileSparse +// moved to whp/win_io.h. +using node_vmm::whp::MarkFileSparse; +using node_vmm::whp::PreadAll; +using node_vmm::whp::PwriteAll; +using node_vmm::whp::TruncateFileTo; +using node_vmm::whp::WinHandle; + +// Segment(selector, attributes) helper moved to whp_page_tables.{h,cc}. +// Re-imported via the `using` declaration at the top of the file. + +std::string WhpExitReason(uint32_t reason) { + switch (reason) { + case WHvRunVpExitReasonX64Halt: + return "hlt"; + case WHvRunVpExitReasonMemoryAccess: + return "memory-access"; + case WHvRunVpExitReasonX64IoPortAccess: + return "io-port"; + case WHvRunVpExitReasonUnrecoverableException: + return "unrecoverable-exception"; + case WHvRunVpExitReasonInvalidVpRegisterValue: + return "invalid-vp-register"; + case WHvRunVpExitReasonUnsupportedFeature: + return "unsupported-feature"; + case WHvRunVpExitReasonX64InterruptWindow: + return "interrupt-window"; + case WHvRunVpExitReasonX64ApicEoi: + return "apic-eoi"; + case WHvRunVpExitReasonX64MsrAccess: + return "msr-access"; + case WHvRunVpExitReasonX64Cpuid: + return "cpuid"; + case WHvRunVpExitReasonCanceled: + return "canceled"; + default: + return "whp-exit-" + std::to_string(reason); + } +} + +bool WhpTraceEnabled() { + char value[8]{}; + DWORD len = GetEnvironmentVariableA("NODE_VMM_WHP_TRACE", value, static_cast(sizeof(value))); + return len > 0 && value[0] != '0'; +} + +std::string Hex(uint64_t value) { + char buf[32]{}; + std::snprintf(buf, sizeof(buf), "0x%llx", static_cast(value)); + return buf; +} + +std::string DescribeWhpExit(const WHV_RUN_VP_EXIT_CONTEXT& ctx) { + std::string out = "exit=" + WhpExitReason(ctx.ExitReason) + " rip=" + Hex(ctx.VpContext.Rip); + switch (ctx.ExitReason) { + case WHvRunVpExitReasonX64IoPortAccess: + out += " port=" + Hex(ctx.IoPortAccess.PortNumber); + out += " size=" + std::to_string(ctx.IoPortAccess.AccessInfo.AccessSize); + out += " write=" + std::to_string(ctx.IoPortAccess.AccessInfo.IsWrite); + out += " len=" + std::to_string(ctx.IoPortAccess.InstructionByteCount); + out += " rax=" + Hex(ctx.IoPortAccess.Rax); + break; + case WHvRunVpExitReasonMemoryAccess: + out += " gpa=" + Hex(ctx.MemoryAccess.Gpa); + out += " gva=" + Hex(ctx.MemoryAccess.Gva); + out += " access=" + std::to_string(ctx.MemoryAccess.AccessInfo.AccessType); + out += " len=" + std::to_string(ctx.MemoryAccess.InstructionByteCount); + break; + case WHvRunVpExitReasonUnrecoverableException: + out += " exception=" + std::to_string(ctx.VpException.ExceptionType); + out += " error=" + Hex(ctx.VpException.ErrorCode); + out += " param=" + Hex(ctx.VpException.ExceptionParameter); + break; + case WHvRunVpExitReasonX64ApicEoi: + out += " vector=" + Hex(ctx.ApicEoi.InterruptVector); + break; + case WHvRunVpExitReasonX64MsrAccess: + out += " msr=" + Hex(ctx.MsrAccess.MsrNumber); + out += " write=" + std::to_string(ctx.MsrAccess.AccessInfo.IsWrite); + break; + case WHvRunVpExitReasonX64Cpuid: + out += " leaf=" + Hex(ctx.CpuidAccess.Rax & 0xFFFFFFFFULL); + out += " subleaf=" + Hex(ctx.CpuidAccess.Rcx & 0xFFFFFFFFULL); + break; + default: + break; + } + return out; +} + +uint32_t CountBits(uint64_t value) { + uint32_t count = 0; + while (value) { + value &= value - 1; + count++; + } + return count; +} + +uint64_t QueryWhpFrequency( + WhpApi& api, + WHV_CAPABILITY_CODE code, + uint64_t fallback, + uint64_t WHV_CAPABILITY::*field) { + WHV_CAPABILITY capability{}; + UINT32 written = 0; + HRESULT hr = api.get_capability(code, &capability, sizeof(capability), &written); + if (FAILED(hr) || capability.*field == 0) { + return fallback; + } + return capability.*field; +} + +uint64_t CheckedAdd(uint64_t a, uint64_t b, const std::string& what) { + Check(a <= UINT64_MAX - b, what + " overflows"); + return a + b; +} + +uint64_t CheckedMul(uint64_t a, uint64_t b, const std::string& what) { + if (a == 0 || b == 0) { + return 0; + } + Check(a <= UINT64_MAX / b, what + " overflows"); + return a * b; +} + +void CheckRange(uint64_t total, uint64_t offset, uint64_t len, const std::string& what) { + Check(offset <= total && len <= total - offset, what + " out of bounds"); +} + +uint16_t ReadU16(const uint8_t* p) { + return uint16_t(p[0]) | (uint16_t(p[1]) << 8); +} + +uint32_t ReadU32(const uint8_t* p) { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} + +uint64_t ReadU64(const uint8_t* p) { + return uint64_t(ReadU32(p)) | (uint64_t(ReadU32(p + 4)) << 32); +} + +void WriteU16(uint8_t* p, uint16_t v) { + p[0] = uint8_t(v); + p[1] = uint8_t(v >> 8); +} + +void WriteU32(uint8_t* p, uint32_t v) { + p[0] = uint8_t(v); + p[1] = uint8_t(v >> 8); + p[2] = uint8_t(v >> 16); + p[3] = uint8_t(v >> 24); +} + +void WriteU64(uint8_t* p, uint64_t v) { + WriteU32(p, uint32_t(v)); + WriteU32(p + 4, uint32_t(v >> 32)); +} + +// ReadLe / WriteLe / DepositBits moved to common/bytes.h. + +// ReadWholeFile moved to whp_elf_loader.cc (TU-private helper for +// LoadElfKernel). FileSizeBytes stays here because it's also called from +// RunVm to size the rootfs image before partition mapping. +uint64_t FileSizeBytes(const std::string& path) { + WinHandle file(CreateFileA( + path.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr)); + Check(file.valid(), "open " + path + " failed: " + WindowsErrorMessage(GetLastError())); + LARGE_INTEGER size{}; + Check(GetFileSizeEx(file.get(), &size) != 0, "stat " + path + " failed: " + WindowsErrorMessage(GetLastError())); + Check(size.QuadPart >= 0, "negative file size: " + path); + return static_cast(size.QuadPart); +} + +// LoadElfKernel moved to whp_elf_loader.cc. + +// PutE820 / WriteBootParams / Checksum / WriteMpTable moved to +// whp_boot_params.{h,cc}. WriteBootParams is re-imported via `using` +// above; WriteMpTable is wrapped below to forward kPitIoApicPin (which +// stays in this TU because ACPI MADT and the timer-irq delivery path +// also reference it). + +void WriteMpTable(uint8_t* mem, uint64_t mem_size, int cpus) { + node_vmm::whp::WriteMpTable(mem, mem_size, cpus, kPitIoApicPin); +} + + +uint64_t AlignUp(uint64_t value, uint64_t align) { + return (value + align - 1) & ~(align - 1); +} + +void AppendPkgLength(std::vector& out, size_t payload_len, bool include_self) { + size_t length_len = 1; + if (payload_len >= (1U << 20) - 3) { + length_len = 4; + } else if (payload_len >= (1U << 12) - 2) { + length_len = 3; + } else if (payload_len >= (1U << 6) - 1) { + length_len = 2; + } + size_t length = payload_len + (include_self ? length_len : 0); + switch (length_len) { + case 1: + out.push_back(static_cast(length)); + break; + case 2: + out.push_back(static_cast((1U << 6) | (length & 0x0F))); + out.push_back(static_cast((length >> 4) & 0xFF)); + break; + case 3: + out.push_back(static_cast((2U << 6) | (length & 0x0F))); + out.push_back(static_cast((length >> 4) & 0xFF)); + out.push_back(static_cast((length >> 12) & 0xFF)); + break; + default: + out.push_back(static_cast((3U << 6) | (length & 0x0F))); + out.push_back(static_cast((length >> 4) & 0xFF)); + out.push_back(static_cast((length >> 12) & 0xFF)); + out.push_back(static_cast((length >> 20) & 0xFF)); + break; + } +} + +std::vector EncodePath(std::string name) { + std::vector out; + bool root = !name.empty() && name[0] == '\\'; + if (root) { + out.push_back('\\'); + name.erase(0, 1); + } + std::vector parts; + size_t start = 0; + for (;;) { + size_t dot = name.find('.', start); + std::string part = name.substr(start, dot == std::string::npos ? std::string::npos : dot - start); + Check(part.size() == 4, "AML path part must be four characters: " + part); + parts.push_back(part); + if (dot == std::string::npos) { + break; + } + start = dot + 1; + } + if (parts.size() == 2) { + out.push_back(0x2E); + } else if (parts.size() > 2) { + out.push_back(0x2F); + out.push_back(static_cast(parts.size())); + } + for (const auto& part : parts) { + out.insert(out.end(), part.begin(), part.end()); + } + return out; +} + +std::vector EncodeString(const std::string& value) { + std::vector out; + out.push_back(0x0D); + out.insert(out.end(), value.begin(), value.end()); + out.push_back(0); + return out; +} + +std::vector EncodeByte(uint8_t value) { + return std::vector{0x0A, value}; +} + +std::vector EncodeWord(uint16_t value) { + std::vector out{0x0B, 0, 0}; + WriteU16(out.data() + 1, value); + return out; +} + +std::vector EncodeDword(uint32_t value) { + std::vector out{0x0C, 0, 0, 0, 0}; + WriteU32(out.data() + 1, value); + return out; +} + +std::vector EncodeInteger(uint64_t value) { + if (value == 0) return std::vector{0x00}; + if (value == 1) return std::vector{0x01}; + if (value <= 0xFF) return EncodeByte(static_cast(value)); + if (value <= 0xFFFF) return EncodeWord(static_cast(value)); + if (value <= 0xFFFFFFFF) return EncodeDword(static_cast(value)); + std::vector out{0x0E, 0, 0, 0, 0, 0, 0, 0, 0}; + WriteU64(out.data() + 1, value); + return out; +} + +std::vector AppendName(const std::string& path, const std::vector& value) { + std::vector out{0x08}; + auto path_bytes = EncodePath(path); + out.insert(out.end(), path_bytes.begin(), path_bytes.end()); + out.insert(out.end(), value.begin(), value.end()); + return out; +} + +std::vector ResMemory32Fixed(bool read_write, uint32_t base, uint32_t length) { + std::vector out(12); + out[0] = 0x86; + WriteU16(out.data() + 1, 9); + out[3] = read_write ? 1 : 0; + WriteU32(out.data() + 4, base); + WriteU32(out.data() + 8, length); + return out; +} + +std::vector ResInterrupt(bool consumer, bool edge_triggered, bool active_low, bool shared, uint32_t number) { + std::vector out(9); + out[0] = 0x89; + WriteU16(out.data() + 1, 6); + uint8_t flags = 0; + if (shared) flags |= 1 << 3; + if (active_low) flags |= 1 << 2; + if (edge_triggered) flags |= 1 << 1; + if (consumer) flags |= 1; + out[3] = flags; + out[4] = 1; + WriteU32(out.data() + 5, number); + return out; +} + +std::vector ResIoPort(uint16_t base, uint8_t length) { + std::vector out(8); + out[0] = 0x47; // I/O port descriptor. + out[1] = 0x01; // Decode 16-bit addresses. + WriteU16(out.data() + 2, base); + WriteU16(out.data() + 4, base); + out[6] = 0x01; // Alignment. + out[7] = length; + return out; +} + +std::vector BuildResourceTemplate(const std::vector>& resources) { + std::vector payload; + for (const auto& resource : resources) { + payload.insert(payload.end(), resource.begin(), resource.end()); + } + payload.push_back(0x79); + payload.push_back(0x00); + auto len_obj = EncodeInteger(payload.size()); + std::vector tmp = len_obj; + tmp.insert(tmp.end(), payload.begin(), payload.end()); + std::vector out{0x11}; + AppendPkgLength(out, tmp.size(), true); + out.insert(out.end(), tmp.begin(), tmp.end()); + return out; +} + +std::vector BuildResourceTemplate(const std::vector& first, const std::vector& second) { + return BuildResourceTemplate(std::vector>{first, second}); +} + +std::vector AppendDevice(const std::string& name, const std::vector>& children) { + std::vector payload = EncodePath(name); + for (const auto& child : children) { + payload.insert(payload.end(), child.begin(), child.end()); + } + std::vector out{0x5B, 0x82}; + AppendPkgLength(out, payload.size(), true); + out.insert(out.end(), payload.begin(), payload.end()); + return out; +} + +std::vector AppendScope(const std::string& name, const std::vector>& children) { + std::vector payload = EncodePath(name); + for (const auto& child : children) { + payload.insert(payload.end(), child.begin(), child.end()); + } + std::vector out{0x10}; + AppendPkgLength(out, payload.size(), true); + out.insert(out.end(), payload.begin(), payload.end()); + return out; +} + +std::vector BuildVirtioMmioDevice(const std::string& name, uint32_t uid, uint64_t base, uint32_t irq) { + std::vector> dev_children; + dev_children.push_back(AppendName("_HID", EncodeString("LNRO0005"))); + dev_children.push_back(AppendName("_UID", EncodeInteger(uid))); + dev_children.push_back(AppendName("_CCA", std::vector{0x01})); + dev_children.push_back(AppendName( + "_CRS", + BuildResourceTemplate( + ResMemory32Fixed(true, static_cast(base), static_cast(kVirtioStride)), + ResInterrupt(true, true, false, false, irq)))); + return AppendDevice(name, dev_children); +} + +std::vector BuildHpetDevice() { + std::vector> dev_children; + dev_children.push_back(AppendName("_HID", EncodeString("PNP0103"))); + dev_children.push_back(AppendName("_UID", EncodeInteger(0))); + dev_children.push_back(AppendName( + "_CRS", + BuildResourceTemplate(std::vector>{ + ResMemory32Fixed(false, static_cast(kHpetBase), static_cast(kHpetSize)), + }))); + return AppendDevice("HPET", dev_children); +} + +std::vector BuildCom1Device() { + std::vector> dev_children; + dev_children.push_back(AppendName("_HID", EncodeString("PNP0501"))); + dev_children.push_back(AppendName("_UID", EncodeInteger(0))); + dev_children.push_back(AppendName( + "_CRS", + BuildResourceTemplate(std::vector>{ + ResIoPort(kCom1Base, 8), + ResInterrupt(true, true, false, false, kCom1IRQ), + }))); + return AppendDevice("COM1", dev_children); +} + +std::vector BuildDsdtBody(bool network_enabled, uint32_t disk_count) { + uint32_t rng_index = disk_count + 1; + CheckVirtioMmioDeviceCount(rng_index + 1); + std::vector> children; + children.push_back(BuildHpetDevice()); + children.push_back(BuildCom1Device()); + for (uint32_t index = 0; index < disk_count; index++) { + children.push_back(BuildVirtioMmioDevice( + VirtioMmioDeviceName(index), index, VirtioMmioBase(index), VirtioMmioIrq(index))); + } + children.push_back(BuildVirtioMmioDevice( + VirtioMmioDeviceName(rng_index), rng_index, VirtioMmioBase(rng_index), VirtioMmioIrq(rng_index))); + if (network_enabled) { + uint32_t net_index = disk_count; + children.push_back(BuildVirtioMmioDevice( + VirtioMmioDeviceName(net_index), net_index, VirtioMmioBase(net_index), VirtioMmioIrq(net_index))); + } + return AppendScope("\\_SB_", children); +} + +uint8_t TableChecksum(const std::vector& table) { + uint8_t sum = 0; + for (uint8_t b : table) sum = static_cast(sum + b); + return static_cast(~sum + 1); +} + +std::vector BuildSdtHeader(const char sig[4], uint32_t len, uint8_t rev, const char table_id[8]) { + std::vector out(36); + std::memcpy(out.data(), sig, 4); + WriteU32(out.data() + 4, len); + out[8] = rev; + std::memcpy(out.data() + 10, "GOCRKR", 6); + std::memcpy(out.data() + 16, table_id, 8); + WriteU32(out.data() + 28, 0x47434154); + WriteU32(out.data() + 32, 0x20260402); + return out; +} + +void FinalizeSdt(std::vector& table) { + table[9] = 0; + table[9] = TableChecksum(table); +} + +std::vector BuildDsdtTable(bool network_enabled, uint32_t disk_count) { + auto body = BuildDsdtBody(network_enabled, disk_count); + auto out = BuildSdtHeader("DSDT", static_cast(36 + body.size()), 2, "GCDSDT01"); + out.insert(out.end(), body.begin(), body.end()); + FinalizeSdt(out); + return out; +} + +std::vector BuildHpetTable() { + auto out = BuildSdtHeader("HPET", 56, 1, "GCHPET01"); + out.resize(56); + // Keep this event timer block ID in sync with Hpet::kCapabilities: Intel + // vendor, legacy replacement capable, 3 timers, revision 1. + WriteU32(out.data() + 36, 0x8086A201); + out[40] = 0; // Generic Address Structure: system memory. + out[41] = 0; // Register bit width, per QEMU's HPET table. + out[42] = 0; // Register bit offset. + out[43] = 0; // Access size. + WriteU64(out.data() + 44, kHpetBase); + out[52] = 0; // HPET number. + WriteU16(out.data() + 53, 0); // Minimum tick in periodic mode. + out[55] = 0; // Page protection and OEM attribute. + FinalizeSdt(out); + return out; +} + +std::vector BuildFadt(uint64_t dsdt_addr) { + std::vector out(276); + auto hdr = BuildSdtHeader("FACP", 276, 6, "GCFADT01"); + std::memcpy(out.data(), hdr.data(), hdr.size()); + WriteU32(out.data() + 40, static_cast(dsdt_addr)); + WriteU32(out.data() + 76, kAcpiPmTimerPort); + out[91] = 4; // PM timer register length. + out[108] = 0x32; // Century register in CMOS/RTC. + WriteU16(out.data() + 109, 1 << 2); + WriteU32(out.data() + 112, (1 << 20) | (1 << 8) | (1 << 4) | (1 << 5)); + out[131] = 5; + WriteU64(out.data() + 140, dsdt_addr); + out[208] = 1; // X_PM_TMR_BLK GAS: system I/O. + out[209] = 32; // 32-bit PM timer. + out[210] = 0; + out[211] = 3; // DWord access. + WriteU64(out.data() + 212, kAcpiPmTimerPort); + std::memcpy(out.data() + 268, "GOCRKVM ", 8); + FinalizeSdt(out); + return out; +} + +std::vector BuildMadt(int cpus) { + std::vector body(8 + 12 + 10 + cpus * 8); + WriteU32(body.data(), 0xFEE00000); + WriteU32(body.data() + 4, 1); // PCAT_COMPAT: dual 8259 PICs are present. + uint8_t* ioapic = body.data() + 8; + ioapic[0] = 1; + ioapic[1] = 12; + WriteU32(ioapic + 4, 0xFEC00000); + uint8_t* irq0_override = body.data() + 20; + irq0_override[0] = 2; // Interrupt Source Override. + irq0_override[1] = 10; + irq0_override[2] = 0; // ISA bus. + irq0_override[3] = 0; // ISA IRQ0 (PIT). + WriteU32(irq0_override + 4, kPitIoApicPin); + WriteU16(irq0_override + 8, 0); // Bus-conforming polarity/trigger. + size_t off = 30; + for (int i = 0; i < cpus; i++) { + uint8_t* lapic = body.data() + off; + lapic[0] = 0; + lapic[1] = 8; + lapic[2] = static_cast(i); + lapic[3] = static_cast(i); + WriteU32(lapic + 4, 1); + off += 8; + } + auto out = BuildSdtHeader("APIC", static_cast(36 + body.size()), 6, "GCMADT01"); + out.insert(out.end(), body.begin(), body.end()); + FinalizeSdt(out); + return out; +} + +std::vector BuildRsdt(const std::vector& addrs) { + std::vector body(addrs.size() * 4); + for (size_t i = 0; i < addrs.size(); i++) { + WriteU32(body.data() + i * 4, static_cast(addrs[i])); + } + auto out = BuildSdtHeader("RSDT", static_cast(36 + body.size()), 1, "GCRSDT01"); + out.insert(out.end(), body.begin(), body.end()); + FinalizeSdt(out); + return out; +} + +std::vector BuildXsdt(const std::vector& addrs) { + std::vector body(addrs.size() * 8); + for (size_t i = 0; i < addrs.size(); i++) { + WriteU64(body.data() + i * 8, addrs[i]); + } + auto out = BuildSdtHeader("XSDT", static_cast(36 + body.size()), 1, "GCXSDT01"); + out.insert(out.end(), body.begin(), body.end()); + FinalizeSdt(out); + return out; +} + +std::vector BuildRsdp(uint64_t rsdt_addr, uint64_t xsdt_addr) { + std::vector out(36); + std::memcpy(out.data(), "RSD PTR ", 8); + std::memcpy(out.data() + 9, "GOCRKR", 6); + out[15] = 2; + WriteU32(out.data() + 16, static_cast(rsdt_addr)); + WriteU32(out.data() + 20, 36); + WriteU64(out.data() + 24, xsdt_addr); + uint8_t sum20 = 0; + for (size_t i = 0; i < 20; i++) sum20 = static_cast(sum20 + out[i]); + out[8] = static_cast(~sum20 + 1); + uint8_t sum36 = 0; + for (uint8_t b : out) sum36 = static_cast(sum36 + b); + out[32] = static_cast(~sum36 + 1); + return out; +} + +uint64_t CreateAcpiTables(uint8_t* mem, uint64_t mem_size, bool network_enabled, int cpus, uint32_t disk_count) { + constexpr uint64_t rsdp_addr = 0x000E0000; + uint64_t cursor = 0x000A0000; + auto write_table = [&](const std::vector& table) { + cursor = AlignUp(cursor, 8); + uint64_t addr = cursor; + CheckRange(mem_size, addr, table.size(), "ACPI table"); + std::memcpy(mem + addr, table.data(), table.size()); + cursor += table.size(); + Check(cursor < rsdp_addr, "ACPI tables overflow low memory"); + return addr; + }; + auto dsdt = BuildDsdtTable(network_enabled, disk_count); + uint64_t dsdt_addr = write_table(dsdt); + uint64_t fadt_addr = write_table(BuildFadt(dsdt_addr)); + uint64_t madt_addr = write_table(BuildMadt(cpus)); + uint64_t hpet_addr = write_table(BuildHpetTable()); + uint64_t rsdt_addr = write_table(BuildRsdt({fadt_addr, madt_addr, hpet_addr})); + uint64_t xsdt_addr = write_table(BuildXsdt({fadt_addr, madt_addr, hpet_addr})); + auto rsdp = BuildRsdp(rsdt_addr, xsdt_addr); + CheckRange(mem_size, rsdp_addr, rsdp.size(), "RSDP"); + std::memcpy(mem + rsdp_addr, rsdp.data(), rsdp.size()); + return rsdp_addr; +} + +// BuildPageTables / Table / LongCodeSegment / LongDataSegment moved to +// whp_page_tables.{h,cc}. Re-imported via `using` declarations at the +// top of this file so call sites in SetupLongMode below remain unchanged. + +void SetupLongMode(WhpApi& api, WHV_PARTITION_HANDLE partition, UINT32 vp_index, uint8_t* mem, uint64_t entry) { + BuildPageTables(mem, kPageTableBase); + uint64_t gdt[] = { + 0x0000000000000000ULL, + 0x00AF9B000000FFFFULL, + 0x00CF93000000FFFFULL, + 0x008F8B000000FFFFULL, + }; + for (size_t i = 0; i < 4; i++) { + WriteU64(mem + kGdtAddr + i * 8, gdt[i]); + } + WriteU64(mem + kIdtAddr, 0); + + WHV_REGISTER_NAME names[] = { + WHvX64RegisterRip, WHvX64RegisterRsi, WHvX64RegisterRdi, WHvX64RegisterRflags, WHvX64RegisterRsp, WHvX64RegisterRbp, + WHvX64RegisterCr0, WHvX64RegisterCr3, WHvX64RegisterCr4, WHvX64RegisterEfer, + WHvX64RegisterCs, WHvX64RegisterDs, WHvX64RegisterEs, WHvX64RegisterFs, WHvX64RegisterGs, WHvX64RegisterSs, + WHvX64RegisterTr, WHvX64RegisterGdtr, WHvX64RegisterIdtr, + }; + WHV_REGISTER_VALUE values[sizeof(names) / sizeof(names[0])] = {}; + values[0].Reg64 = entry; + values[1].Reg64 = kBootParamsAddr; + values[2].Reg64 = vp_index; + values[3].Reg64 = 0x2; + values[4].Reg64 = 0x8FF0; + values[5].Reg64 = 0x8FF0; + values[6].Reg64 = 0x80000001ULL; + values[7].Reg64 = kPageTableBase; + values[8].Reg64 = 0x20; + values[9].Reg64 = 0x500; + values[10].Segment = LongCodeSegment(); + values[11].Segment = LongDataSegment(); + values[12].Segment = LongDataSegment(); + values[13].Segment = LongDataSegment(); + values[14].Segment = LongDataSegment(); + values[15].Segment = LongDataSegment(); + values[16].Segment = Segment(0x18, 0x8b); + values[16].Segment.Limit = 0xFFFFF; + values[16].Segment.Present = 1; + values[16].Segment.Granularity = 1; + values[17].Table = Table(kGdtAddr, 31); + values[18].Table = Table(kIdtAddr, 7); + CheckHr(api.set_vp_registers(partition, vp_index, names, static_cast(sizeof(names) / sizeof(names[0])), values), + "WHvSetVirtualProcessorRegisters(long mode)"); +} + +void SetupWhpBootstrapVcpu( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + uint8_t* mem, + uint64_t entry, + bool enable_apic, + bool rootfs_smp_ap) { + auto set_optional_reg = [&](WHV_REGISTER_NAME name, uint64_t value) { + WHV_REGISTER_VALUE reg{}; + reg.Reg64 = value; + (void)api.set_vp_registers(partition, vp_index, &name, 1, ®); + }; + + // For Linux/rootfs SMP, only the BSP (vp_index == 0) is bootstrapped into + // long mode at the kernel entry point. The APs are left in their reset + // state (real mode, RIP=0); WHP's X2APIC emulation parks them and starts + // them at the SIPI vector when the BSP issues INIT/SIPI through the LAPIC + // ICR, mirroring qemu/target/i386/whpx/whpx-apic.c:241-247 ("WHPX does not + // use wait_for_sipi") and qemu/hw/intc/apic.c:apic_sipi. + if (!rootfs_smp_ap) { + SetupLongMode(api, partition, vp_index, mem, entry); + set_optional_reg(WHvX64RegisterPat, 0x0007040600070406ULL); + set_optional_reg(WHvX64RegisterMsrMtrrDefType, (1U << 11) | 0x6); + } + if (enable_apic) { + set_optional_reg( + WHvX64RegisterApicBase, + 0xFEE00000ULL | (1ULL << 11) | (vp_index == 0 ? (1ULL << 8) : 0)); + } +} + +void SetRip(WhpApi& api, WHV_PARTITION_HANDLE partition, UINT32 vp_index, uint64_t rip) { + WHV_REGISTER_NAME name = WHvX64RegisterRip; + WHV_REGISTER_VALUE value{}; + value.Reg64 = rip; + CheckHr(api.set_vp_registers(partition, vp_index, &name, 1, &value), "WHvSetVirtualProcessorRegisters(RIP)"); +} + +void SetRaxRip(WhpApi& api, WHV_PARTITION_HANDLE partition, UINT32 vp_index, uint64_t rax, uint64_t rip) { + WHV_REGISTER_NAME names[] = {WHvX64RegisterRax, WHvX64RegisterRip}; + WHV_REGISTER_VALUE values[2]{}; + values[0].Reg64 = rax; + values[1].Reg64 = rip; + CheckHr(api.set_vp_registers(partition, vp_index, names, 2, values), "WHvSetVirtualProcessorRegisters(RAX/RIP)"); +} + +uint64_t GetRegister64(WhpApi& api, WHV_PARTITION_HANDLE partition, UINT32 vp_index, WHV_REGISTER_NAME name) { + WHV_REGISTER_VALUE value{}; + CheckHr(api.get_vp_registers(partition, vp_index, &name, 1, &value), "WHvGetVirtualProcessorRegisters"); + return value.Reg64; +} + +// KickVcpuOutOfHlt + WhpVcpuIrqState + InterruptibilitySnapshot + +// ReadInterruptibility + SetPendingExtInt + ArmInterruptWindow + +// DisarmInterruptWindow + UpdateVcpuFromExit + TryDeliverPendingExtInt +// moved to whp/irq.{h,cc}. + +struct GuestExit { + bool requested{false}; + uint32_t status{0}; +}; + +// ===================================================================== +// SECTION: serial UART 8250/16550 emulation (qemu/hw/char/serial.c parity) +// ===================================================================== +class IoApic; +// Uart class moved to whp/devices/uart.{h,cc}. + +// ===================================================================== +// SECTION: legacy interrupt controllers +// IoApic, Pic 8259, Pit i8254, Cmos/RTC. The 8259/8254/CMOS layout follows +// qemu/hw/intc/i8259.c, qemu/hw/timer/i8254.c, qemu/hw/rtc/mc146818rtc.c. +// ===================================================================== +class IoApic { + static constexpr uint64_t kRteVectorMask = 0xFF; + static constexpr uint64_t kRteDestinationMode = 1ULL << 11; + static constexpr uint64_t kRteRemoteIrr = 1ULL << 14; + static constexpr uint64_t kRteTriggerMode = 1ULL << 15; + static constexpr uint64_t kRteMask = 1ULL << 16; + + public: + IoApic(WhpApi& api, WHV_PARTITION_HANDLE partition) : api_(api), partition_(partition) { + for (auto& entry : redir_) { + entry = kRteMask; + } + } + + void attach_waker(std::function waker) { + std::lock_guard lock(mu_); + waker_ = std::move(waker); + } + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + std::lock_guard lock(mu_); + std::memset(data, 0, len); + if (len != 4) { + return; + } + uint32_t off = static_cast(addr - kIoApicBase); + if (off == 0x00) { + WriteU32(data, selected_); + return; + } + if (off == 0x10) { + WriteU32(data, read_reg(selected_)); + } + } + + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + std::lock_guard lock(mu_); + if (len != 4) { + return; + } + uint32_t off = static_cast(addr - kIoApicBase); + uint32_t value = ReadU32(data); + if (off == 0x00) { + selected_ = value & 0xFF; + return; + } + if (off == 0x10) { + write_reg(selected_, value); + } + } + + void request_irq(uint32_t irq) { + std::lock_guard lock(mu_); + request_irq_locked(irq); + } + + void set_irq(uint32_t irq, bool level) { + if (irq >= redir_.size()) { + return; + } + std::lock_guard lock(mu_); + line_level_[irq] = level; + if (level) { + request_irq_locked(irq); + } + } + + uint32_t vector_for_irq(uint32_t irq) const { + std::lock_guard lock(mu_); + if (irq >= redir_.size()) { + return 0x20 + irq; + } + return vector_for_irq_locked(irq); + } + + bool irq_unmasked(uint32_t irq) const { + std::lock_guard lock(mu_); + return irq < redir_.size() && (redir_[irq] & kRteMask) == 0; + } + + void drain_pending() { + std::lock_guard lock(mu_); + drain_pending_locked(); + } + + void eoi(uint32_t vector) { + std::lock_guard lock(mu_); + if (boot_dbg_) { + std::fprintf(stderr, "[node-vmm ioapic] eoi vector=0x%02x\n", vector & 0xFF); + } + for (uint32_t irq = 0; irq < redir_.size(); irq++) { + uint64_t entry = redir_[irq]; + if ((entry & kRteTriggerMode) == 0 || (entry & kRteRemoteIrr) == 0) { + continue; + } + if (vector_for_irq_locked(irq) != (vector & 0xFF)) { + continue; + } + redir_[irq] &= ~kRteRemoteIrr; + if (line_level_[irq] && (redir_[irq] & kRteMask) == 0) { + request_irq_locked(irq); + } + } + drain_pending_locked(); + } + + void enable_debug() { + std::lock_guard lock(mu_); + boot_dbg_ = true; + } + + private: + uint32_t read_reg(uint32_t reg) const { + if (reg == 0x00) { + return 12U << 24; + } + if (reg == 0x01) { + return 0x11 | ((static_cast(redir_.size()) - 1) << 16); + } + if (reg >= 0x10 && reg < 0x10 + redir_.size() * 2) { + uint32_t irq = (reg - 0x10) / 2; + bool high = ((reg - 0x10) & 1) != 0; + return high ? static_cast(redir_[irq] >> 32) : static_cast(redir_[irq]); + } + return 0; + } + + void write_reg(uint32_t reg, uint32_t value) { + if (reg >= 0x10 && reg < 0x10 + redir_.size() * 2) { + uint32_t irq = (reg - 0x10) / 2; + bool high = ((reg - 0x10) & 1) != 0; + uint64_t old = redir_[irq]; + if (high) { + redir_[irq] = (redir_[irq] & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + } else { + redir_[irq] = (redir_[irq] & 0xFFFFFFFF00000000ULL) | value; + redir_[irq] = (redir_[irq] & ~kRteRemoteIrr) | (old & kRteRemoteIrr); + if ((redir_[irq] & kRteTriggerMode) == 0) { + redir_[irq] &= ~kRteRemoteIrr; + } + } + if (boot_dbg_) { + std::fprintf(stderr, "[node-vmm ioapic] rte[%u].%s=0x%08x entry=0x%016llx masked=%d\n", + irq, + high ? "high" : "low", + value, + (unsigned long long)redir_[irq], + (int)((redir_[irq] >> 16) & 1)); + } + if (!high && (old & kRteMask) != 0 && (redir_[irq] & kRteMask) == 0 && line_level_[irq]) { + request_irq_locked(irq); + } + } + } + + uint32_t vector_for_irq_locked(uint32_t irq) const { + uint32_t vector = static_cast(redir_[irq] & kRteVectorMask); + return vector == 0 ? 0x20 + irq : vector; + } + + void request_irq_locked(uint32_t irq) { + if (irq >= redir_.size()) { + return; + } + uint64_t& entry = redir_[irq]; + if (boot_dbg_) { + std::fprintf(stderr, "[node-vmm ioapic] request_irq(%u) entry=0x%016llx masked=%d remote_irr=%d\n", + irq, + (unsigned long long)entry, + (int)((entry & kRteMask) != 0), + (int)((entry & kRteRemoteIrr) != 0)); + } + if (entry & kRteMask) { + return; + } + bool level_triggered = (entry & kRteTriggerMode) != 0; + if (level_triggered && (entry & kRteRemoteIrr) != 0) { + return; + } + if (pending_.size() > 64) { + return; + } + WHV_INTERRUPT_CONTROL control{}; + control.Type = WHvX64InterruptTypeFixed; + control.DestinationMode = (entry & kRteDestinationMode) ? WHvX64InterruptDestinationModeLogical : WHvX64InterruptDestinationModePhysical; + control.TriggerMode = level_triggered ? WHvX64InterruptTriggerModeLevel : WHvX64InterruptTriggerModeEdge; + control.Destination = static_cast((entry >> 56) & 0xFF); + control.Vector = vector_for_irq_locked(irq); + if (level_triggered) { + entry |= kRteRemoteIrr; + } + pending_.push_back(control); + drain_pending_locked(); + } + + void drain_pending_locked() { + bool delivered = false; + while (!pending_.empty()) { + WHV_INTERRUPT_CONTROL control = pending_.front(); + HRESULT hr = api_.request_interrupt(partition_, &control, sizeof(control)); + if (FAILED(hr)) { + return; + } + pending_.pop_front(); + delivered = true; + } + if (delivered && waker_) { + waker_(); + } + } + + WhpApi& api_; + WHV_PARTITION_HANDLE partition_{nullptr}; + mutable std::mutex mu_; + uint32_t selected_{0}; + std::array redir_{}; + std::array line_level_{}; + std::deque pending_; + std::function waker_; + bool boot_dbg_{false}; +}; + +// Uart::update_interrupt_locked moved to whp/devices/uart.cc. + +// RequestFixedInterrupt moved to whp/irq.{h,cc}. +// Pic class moved to whp/devices/pic.{h,cc}. + +// Pit / Hpet / Cmos / AcpiPmTimer classes moved to whp/devices/. + +struct HyperVState { + using Clock = std::chrono::steady_clock; + uint64_t tsc_frequency_hz{kFallbackTscFrequencyHz}; + uint64_t apic_frequency_hz{kFallbackApicFrequencyHz}; + std::atomic guest_os_id{0}; + std::atomic hypercall{0}; + Clock::time_point started_at{Clock::now()}; + + uint64_t time_ref_count() const { + auto elapsed = std::chrono::duration_cast( + Clock::now() - started_at) + .count(); + return elapsed <= 0 ? 0 : static_cast(elapsed) / 100; + } +}; + +// Desc / DescChain moved to whp/virtio/desc.h. +// VirtioBlk moved to whp/virtio/blk.{h,cc}. + +// VirtioRng moved to whp/virtio/rng.{h,cc}. + +#ifdef NODE_VMM_HAVE_LIBSLIRP +extern "C" { +#include +} + +class VirtioNet; + +// Wraps a libslirp instance running on its own polling thread. Mirrors the +// QEMU adapter in qemu/net/slirp.c. The callbacks are designed for v6 of the +// libslirp ABI (`register_poll_socket` / SLIRP_CONFIG_VERSION_MAX = 6 from +// libslirp/src/libslirp.h:160-161). +class Slirp { + public: + struct HostFwd { + bool udp{false}; + uint32_t host_ip{0}; + uint16_t host_port{0}; + uint16_t guest_port{0}; + }; + + Slirp(VirtioNet* net, std::vector host_fwds); + ~Slirp(); + + Slirp(const Slirp&) = delete; + Slirp& operator=(const Slirp&) = delete; + + // Hand a packet from the guest virtio-net TX queue over to libslirp for + // dispatch out the host network stack. + void input_packet(const uint8_t* data, size_t len) { + std::lock_guard lock(mu_); + if (slirp_ != nullptr) { + slirp_input(slirp_, data, static_cast(len)); + } + } + + private: + void poll_thread_main(); + + static slirp_ssize_t SendPacketCb(const void* buf, size_t len, void* opaque) { + return reinterpret_cast(opaque)->on_send_packet(buf, len); + } + static void GuestErrorCb(const char* msg, void* opaque) { + (void)opaque; + std::fprintf(stderr, "[node-vmm slirp guest-error] %s\n", msg); + } + static int64_t ClockGetNsCb(void* opaque) { + (void)opaque; + auto now = std::chrono::steady_clock::now().time_since_epoch(); + return std::chrono::duration_cast(now).count(); + } + static void* TimerNewOpaqueCb(SlirpTimerId id, void* cb_opaque, void* opaque) { + return reinterpret_cast(opaque)->on_timer_new(id, cb_opaque); + } + static void TimerFreeCb(void* timer, void* opaque) { + reinterpret_cast(opaque)->on_timer_free(timer); + } + static void TimerModCb(void* timer, int64_t expire_ms, void* opaque) { + reinterpret_cast(opaque)->on_timer_mod(timer, expire_ms); + } + static void RegisterPollSocketCb(slirp_os_socket fd, void* opaque) { + (void)fd; + (void)opaque; + // libslirp polls via slirp_pollfds_fill_socket; nothing to register. + } + static void UnregisterPollSocketCb(slirp_os_socket fd, void* opaque) { + (void)fd; + (void)opaque; + } + static void NotifyCb(void* opaque) { + reinterpret_cast(opaque)->on_notify(); + } + + slirp_ssize_t on_send_packet(const void* buf, size_t len); + void* on_timer_new(SlirpTimerId id, void* cb_opaque); + void on_timer_free(void* timer); + void on_timer_mod(void* timer, int64_t expire_ms); + void on_notify(); + + struct Timer { + SlirpTimerId id; + void* cb_opaque; + int64_t expire_ms{INT64_MAX}; + }; + + struct PollEntry { + slirp_os_socket fd; + int events; + int revents; + }; + + static int AddPollSocketCb(slirp_os_socket fd, int events, void* opaque); + static int GetREventsCb(int idx, void* opaque); + + VirtioNet* net_{nullptr}; + std::vector host_fwds_; + + std::mutex mu_; + ::Slirp* slirp_{nullptr}; + std::vector> timers_; + std::vector poll_entries_; + + std::thread poll_thread_; + std::atomic stop_{false}; + HANDLE wakeup_event_{nullptr}; +}; + +// virtio-net device-config layout (offset relative to kVirtioMmioConfig). +constexpr uint16_t kVirtioNetCfgMac = 0x00; // 6 bytes +constexpr uint16_t kVirtioNetCfgStatus = 0x06; // 2 bytes (LE) +constexpr uint16_t kVirtioNetSLinkUp = 0x1; + +// virtio-net device, MMIO transport. Two queues: 0=RX, 1=TX. The driver and +// device share descriptor/avail/used rings in guest memory. Layout follows +// the Virtio 1.0 spec, transport bits from qemu/hw/virtio/virtio-mmio.c +// switch tables. +class VirtioNet { + public: + VirtioNet(IoApic& ioapic, GuestMemory mem, uint64_t mmio_base, uint32_t irq, std::array mac) + : ioapic_(ioapic), mem_(mem), mmio_base_(mmio_base), irq_(irq), mac_(mac) {} + + void attach_slirp(Slirp* slirp) { slirp_ = slirp; } + uint64_t mmio_base() const { return mmio_base_; } + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + std::memset(data, 0, len); + if (len != 1 && len != 2 && len != 4) { + return; + } + uint32_t off = static_cast(addr - mmio_base_); + uint32_t value = read_reg(off); + for (uint32_t i = 0; i < len; i++) { + data[i] = static_cast((value >> (i * 8)) & 0xFF); + } + } + + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + if (len != 4) { + return; + } + uint32_t off = static_cast(addr - mmio_base_); + uint32_t value = ReadU32(data); + write_reg(off, value); + } + + // Called from the slirp polling thread when a packet should reach the + // guest. Pulls a descriptor chain off RX queue 0, prepends an empty + // virtio_net_hdr_v1 (12 bytes), copies the payload, and notifies via IRQ. + void deliver_rx_packet(const uint8_t* data, size_t len); + + private: + static constexpr uint32_t kQueueSize = 256; + static constexpr size_t kRxBacklogLimit = 256; + // virtio_net_hdr_v1 is 12 bytes; with VIRTIO_NET_F_MRG_RXBUF negotiated the + // guest expects an extra 2-byte num_buffers field, total 14. We pick the + // size lazily from driver_features_ so the same device adapts when the + // guest opts into mergeable rx buffers. + static constexpr size_t kVirtioNetHdrLenBase = 12; + static constexpr uint64_t kVirtioNetFMrgRxbuf = 1ULL << 15; + size_t virtio_net_hdr_len_locked() const { + return (driver_features_ & kVirtioNetFMrgRxbuf) ? 14 : kVirtioNetHdrLenBase; + } + + struct Queue { + bool ready{false}; + uint32_t num{kQueueSize}; + uint64_t desc_addr{0}; + uint64_t driver_addr{0}; + uint64_t device_addr{0}; + uint16_t last_avail_idx{0}; + uint16_t last_used_idx{0}; + }; + + // Layout matches struct virtio_net_config from the Virtio 1.0 spec; we + // expose the subset that linux/drivers/net/virtio_net.c queries (mac + + // status + max_virtqueue_pairs + mtu). +#pragma pack(push, 1) + struct VirtioNetConfig { + uint8_t mac[6]; + uint16_t status; + uint16_t max_virtqueue_pairs; + uint16_t mtu; + }; +#pragma pack(pop) + static_assert(sizeof(VirtioNetConfig) == 12, "virtio_net config drifted"); + + uint32_t read_reg(uint32_t off); + void write_reg(uint32_t off, uint32_t value); + void process_tx_queue(); + bool deliver_rx_packet_now(const uint8_t* data, size_t len); + void flush_rx_backlog(); + void raise_used_buffer_irq(uint32_t queue_index); + void raise_config_change_irq_locked(); + void update_status_locked(uint32_t new_status); + VirtioNetConfig build_net_config_locked() const; + + uint16_t read_u16(uint64_t gpa) { + uint16_t value = 0; + auto* ptr = ptr_or_null(gpa, 2); + if (ptr != nullptr) { + std::memcpy(&value, ptr, 2); + } + return value; + } + void write_u16(uint64_t gpa, uint16_t value) { + auto* ptr = ptr_or_null(gpa, 2); + if (ptr != nullptr) { + std::memcpy(ptr, &value, 2); + } + } + void write_u32(uint64_t gpa, uint32_t value) { + auto* ptr = ptr_or_null(gpa, 4); + if (ptr != nullptr) { + std::memcpy(ptr, &value, 4); + } + } + uint8_t* ptr_or_null(uint64_t gpa, uint64_t len) const { + if (gpa > mem_.size() || len > mem_.size() - gpa) { + return nullptr; + } + return mem_.data + gpa; + } + + IoApic& ioapic_; + GuestMemory mem_; + uint64_t mmio_base_{0}; + uint32_t irq_{0}; + std::array mac_; + Slirp* slirp_{nullptr}; + + std::mutex mu_; // protects ring state, status, interrupt_status + uint32_t device_features_sel_{0}; + uint32_t driver_features_sel_{0}; + uint64_t driver_features_{0}; + uint32_t status_{0}; + uint32_t interrupt_status_{0}; + uint32_t queue_sel_{0}; + uint32_t config_generation_{0}; + bool link_up_{true}; + bool driver_ok_announced_{false}; + bool rx_flush_active_{false}; + Queue queues_[2]; // 0 = RX, 1 = TX + std::deque> rx_backlog_; +}; + +inline uint64_t VirtioNetDeviceFeatures() { + return kVirtioNetFMac | kVirtioNetFStatus | kVirtioFVersion1; +} + +uint32_t VirtioNet::read_reg(uint32_t off) { + std::lock_guard lock(mu_); + switch (off) { + case kVirtioMmioMagicValue: + return kVirtioMagic; + case kVirtioMmioVersion: + return 2; + case kVirtioMmioDeviceId: + return 1; // virtio-net + case kVirtioMmioVendorId: + return 0x554D4551; // "QEMU" + case kVirtioMmioDeviceFeatures: { + uint64_t feats = VirtioNetDeviceFeatures(); + return device_features_sel_ == 1 + ? static_cast(feats >> 32) + : static_cast(feats & 0xFFFFFFFFu); + } + case kVirtioMmioQueueNumMax: + return kQueueSize; + case kVirtioMmioQueueReady: + return queue_sel_ < 2 && queues_[queue_sel_].ready ? 1 : 0; + case kVirtioMmioInterruptStatus: + return interrupt_status_; + case kVirtioMmioStatus: + return status_; + case kVirtioMmioConfigGeneration: + return config_generation_; + // virtio-mmio v2 shared-memory probe; we have no SHM regions, so report + // -1 in both halves like qemu/hw/virtio/virtio-mmio.c:211-218. + case kVirtioMmioShmLenLow: + case kVirtioMmioShmLenHigh: + return 0xFFFFFFFFu; + case kVirtioMmioShmBaseLow: + case kVirtioMmioShmBaseHigh: + return 0xFFFFFFFFu; + default: + // Device config space. The driver may read individual bytes (MAC) or + // 16-bit words (status), so we serve the request from a packed buffer + // and let the byte-extraction in read_mmio() pick the right bytes. + if (off >= kVirtioMmioConfig && + off < kVirtioMmioConfig + sizeof(VirtioNetConfig)) { + VirtioNetConfig cfg = build_net_config_locked(); + const auto* bytes = reinterpret_cast(&cfg); + size_t cfg_off = off - kVirtioMmioConfig; + uint32_t value = 0; + for (size_t i = 0; i < 4 && cfg_off + i < sizeof(VirtioNetConfig); i++) { + value |= static_cast(bytes[cfg_off + i]) << (i * 8); + } + return value; + } + return 0; + } +} + +VirtioNet::VirtioNetConfig VirtioNet::build_net_config_locked() const { + VirtioNetConfig cfg{}; + std::memcpy(cfg.mac, mac_.data(), 6); + cfg.status = link_up_ ? kVirtioNetSLinkUp : 0; + cfg.max_virtqueue_pairs = 1; + cfg.mtu = 1500; + return cfg; +} + +void VirtioNet::write_reg(uint32_t off, uint32_t value) { + bool kick_tx = false; + bool kick_rx = false; + { + std::lock_guard lock(mu_); + switch (off) { + case kVirtioMmioDeviceFeaturesSel: + device_features_sel_ = value; + return; + case kVirtioMmioDriverFeatures: + if (driver_features_sel_ == 0) { + driver_features_ = (driver_features_ & 0xFFFFFFFF00000000ULL) | value; + } else { + driver_features_ = (driver_features_ & 0x00000000FFFFFFFFULL) | + (uint64_t(value) << 32); + } + return; + case kVirtioMmioDriverFeaturesSel: + driver_features_sel_ = value; + return; + case kVirtioMmioQueueSel: + queue_sel_ = value; + return; + case kVirtioMmioQueueNum: + if (queue_sel_ < 2) { + queues_[queue_sel_].num = value == 0 ? 0 : std::min(value, kQueueSize); + } + return; + case kVirtioMmioQueueReady: + if (queue_sel_ < 2) { + queues_[queue_sel_].ready = value != 0; + } + return; + case kVirtioMmioQueueNotify: + kick_tx = (value == 1); + kick_rx = (value == 0); + break; + case kVirtioMmioInterruptAck: + interrupt_status_ &= ~value; + return; + case kVirtioMmioStatus: + update_status_locked(value); + return; + case kVirtioMmioQueueDescLow: + if (queue_sel_ < 2) { + queues_[queue_sel_].desc_addr = + (queues_[queue_sel_].desc_addr & 0xFFFFFFFF00000000ULL) | value; + } + return; + case kVirtioMmioQueueDescHigh: + if (queue_sel_ < 2) { + queues_[queue_sel_].desc_addr = + (queues_[queue_sel_].desc_addr & 0xFFFFFFFFULL) | + (uint64_t(value) << 32); + } + return; + case kVirtioMmioQueueDriverLow: + if (queue_sel_ < 2) { + queues_[queue_sel_].driver_addr = + (queues_[queue_sel_].driver_addr & 0xFFFFFFFF00000000ULL) | value; + } + return; + case kVirtioMmioQueueDriverHigh: + if (queue_sel_ < 2) { + queues_[queue_sel_].driver_addr = + (queues_[queue_sel_].driver_addr & 0xFFFFFFFFULL) | + (uint64_t(value) << 32); + } + return; + case kVirtioMmioQueueDeviceLow: + if (queue_sel_ < 2) { + queues_[queue_sel_].device_addr = + (queues_[queue_sel_].device_addr & 0xFFFFFFFF00000000ULL) | value; + } + return; + case kVirtioMmioQueueDeviceHigh: + if (queue_sel_ < 2) { + queues_[queue_sel_].device_addr = + (queues_[queue_sel_].device_addr & 0xFFFFFFFFULL) | + (uint64_t(value) << 32); + } + return; + default: + return; + } + } + if (kick_tx) { + process_tx_queue(); + } else if (kick_rx) { + // RX kicks make new buffers available. QEMU's virtio-net backend can hold + // packets until the guest posts descriptors; mirror that instead of + // dropping slirp packets during bursts. + flush_rx_backlog(); + } +} + +void VirtioNet::update_status_locked(uint32_t new_status) { + // Mirror QEMU's virtio_set_status semantics for the bits we care about + // (qemu/hw/virtio/virtio-mmio.c:426-445 + qemu/hw/net/virtio-net.c handlers): + // * On RESET, drop ring state and re-arm DRIVER_OK announcement. + // * On the first DRIVER_OK transition, leave link state available through + // config space; don't inject a config IRQ before Linux has installed + // the virtio-net IRQ handler. + // * On FAILED, mark queues unready so subsequent kicks are dropped. + uint32_t prev = status_; + status_ = new_status; + if (new_status == 0) { + for (auto& q : queues_) { + q = Queue{}; + } + interrupt_status_ = 0; + driver_ok_announced_ = false; + return; + } + if ((new_status & kVirtioStatusFailed) != 0) { + for (auto& q : queues_) { + q.ready = false; + } + return; + } + // When the guest commits FEATURES_OK we must ensure the driver only acked + // features we actually advertise; otherwise spec-compliant drivers expect + // us to refuse the bit so they can fall back. See qemu/hw/virtio/virtio.c + // virtio_set_features for the canonical handling. + bool now_features_ok = (new_status & kVirtioStatusFeaturesOk) != 0; + bool was_features_ok = (prev & kVirtioStatusFeaturesOk) != 0; + if (now_features_ok && !was_features_ok) { + uint64_t supported = VirtioNetDeviceFeatures(); + if ((driver_features_ & ~supported) != 0) { + status_ &= ~kVirtioStatusFeaturesOk; + } + } + bool now_driver_ok = (new_status & kVirtioStatusDriverOk) != 0; + bool was_driver_ok = (prev & kVirtioStatusDriverOk) != 0; + if (now_driver_ok && !was_driver_ok && !driver_ok_announced_) { + driver_ok_announced_ = true; + } +} + +void VirtioNet::raise_config_change_irq_locked() { + // virtio-mmio defines bit 1 of INTERRUPT_STATUS as "configuration change", + // see qemu/hw/virtio/virtio-mmio.c VIRTIO_MMIO_INT_CONFIG handling and the + // Linux driver's vm_interrupt() in drivers/virtio/virtio_mmio.c. + interrupt_status_ |= kVirtioInterruptConfig; + ioapic_.request_irq(irq_); +} + +void VirtioNet::process_tx_queue() { + Slirp* slirp = slirp_; + if (slirp == nullptr) { + return; + } + Queue& q = queues_[1]; + bool processed = false; + while (true) { + uint16_t avail_idx; + uint16_t desc_idx; + uint32_t queue_num; + uint64_t desc_addr; + { + std::lock_guard lock(mu_); + if (!q.ready || q.num == 0 || q.driver_addr == 0 || q.desc_addr == 0 || + q.device_addr == 0) { + return; + } + queue_num = q.num; + desc_addr = q.desc_addr; + avail_idx = read_u16(q.driver_addr + 2); + if (q.last_avail_idx == avail_idx) { + break; + } + uint16_t ring_off = q.last_avail_idx % queue_num; + desc_idx = read_u16(q.driver_addr + 4 + ring_off * 2); + if (desc_idx >= queue_num) { + q.last_avail_idx++; + continue; + } + q.last_avail_idx++; + } + + // Walk the descriptor chain into a contiguous buffer. Skip the leading + // virtio_net_hdr_v1 - the host network stack only wants the L2 frame. + std::vector packet; + packet.reserve(2048); + uint16_t current = desc_idx; + size_t hops = 0; + bool truncated = false; + while (true) { + hops++; + if (current >= queue_num || hops > queue_num) { + truncated = true; + break; + } + auto* desc = ptr_or_null(desc_addr + current * 16, 16); + if (desc == nullptr) { + truncated = true; + break; + } + uint64_t addr; + uint32_t segment_len; + uint16_t flags, next; + std::memcpy(&addr, desc, 8); + std::memcpy(&segment_len, desc + 8, 4); + std::memcpy(&flags, desc + 12, 2); + std::memcpy(&next, desc + 14, 2); + if ((flags & kVirtioRingDescFWrite) != 0) { + // Driver-write segments shouldn't appear in TX chains; ignore. + } else { + auto* segment = ptr_or_null(addr, segment_len); + if (segment != nullptr) { + packet.insert(packet.end(), segment, segment + segment_len); + } + } + if ((flags & kVirtioRingDescFNext) == 0) { + break; + } + if (next >= queue_num) { + truncated = true; + break; + } + current = next; + } + (void)truncated; + + size_t hdr_len; + { + std::lock_guard lock(mu_); + hdr_len = virtio_net_hdr_len_locked(); + } + if (packet.size() > hdr_len) { + slirp->input_packet(packet.data() + hdr_len, packet.size() - hdr_len); + } + + { + std::lock_guard lock(mu_); + if (!q.ready || q.num == 0 || q.device_addr == 0) { + continue; + } + uint16_t used_off = q.last_used_idx % q.num; + uint64_t used_elem_addr = q.device_addr + 4 + used_off * 8; + write_u32(used_elem_addr, desc_idx); + write_u32(used_elem_addr + 4, 0); // length: TX uses 0 + q.last_used_idx++; + write_u16(q.device_addr + 2, q.last_used_idx); + } + processed = true; + } + if (processed) { + raise_used_buffer_irq(1); + } +} + +void VirtioNet::deliver_rx_packet(const uint8_t* data, size_t len) { + bool backlog_has_packets = false; + { + std::lock_guard lock(mu_); + backlog_has_packets = !rx_backlog_.empty(); + } + if (!backlog_has_packets && deliver_rx_packet_now(data, len)) { + flush_rx_backlog(); + return; + } + { + std::lock_guard lock(mu_); + if (rx_backlog_.size() < kRxBacklogLimit) { + rx_backlog_.emplace_back(data, data + len); + } + } + flush_rx_backlog(); +} + +void VirtioNet::flush_rx_backlog() { + { + std::lock_guard lock(mu_); + if (rx_flush_active_) { + return; + } + rx_flush_active_ = true; + } + for (;;) { + std::vector packet; + { + std::lock_guard lock(mu_); + if (rx_backlog_.empty()) { + rx_flush_active_ = false; + return; + } + packet = rx_backlog_.front(); + } + if (!deliver_rx_packet_now(packet.data(), packet.size())) { + std::lock_guard lock(mu_); + rx_flush_active_ = false; + return; + } + { + std::lock_guard lock(mu_); + if (!rx_backlog_.empty()) { + rx_backlog_.pop_front(); + } + } + } +} + +bool VirtioNet::deliver_rx_packet_now(const uint8_t* data, size_t len) { + if (len > 1500 + 14 + 4) { + return true; // jumbo frames not supported by our defaults; drop. + } + Queue& q = queues_[0]; + uint16_t desc_idx; + uint32_t queue_num; + uint64_t desc_addr; + { + std::lock_guard lock(mu_); + if (!q.ready || q.num == 0 || q.driver_addr == 0 || q.desc_addr == 0 || + q.device_addr == 0) { + return false; + } + queue_num = q.num; + desc_addr = q.desc_addr; + uint16_t avail_idx = read_u16(q.driver_addr + 2); + if (q.last_avail_idx == avail_idx) { + return false; // no buffer available yet + } + uint16_t ring_off = q.last_avail_idx % queue_num; + desc_idx = read_u16(q.driver_addr + 4 + ring_off * 2); + if (desc_idx >= queue_num) { + q.last_avail_idx++; + return true; + } + q.last_avail_idx++; + } + + // Walk descriptor chain and write virtio_net_hdr + payload into it. + size_t hdr_len; + { + std::lock_guard lock(mu_); + hdr_len = virtio_net_hdr_len_locked(); + } + std::vector framed(hdr_len + len, 0); + if (hdr_len >= 14) { + // virtio_net_hdr_v1.num_buffers (LE u16) - we only ever use one buffer + // per packet, so 1 is the right value when MRG_RXBUF is negotiated. + framed[12] = 0x01; + framed[13] = 0x00; + } + std::memcpy(framed.data() + hdr_len, data, len); + size_t copied = 0; + uint16_t current = desc_idx; + size_t hops = 0; + bool ok = true; + while (copied < framed.size()) { + hops++; + if (current >= queue_num || hops > queue_num) { + ok = false; + break; + } + auto* desc = ptr_or_null(desc_addr + current * 16, 16); + if (desc == nullptr) { + ok = false; + break; + } + uint64_t addr; + uint32_t segment_len; + uint16_t flags, next; + std::memcpy(&addr, desc, 8); + std::memcpy(&segment_len, desc + 8, 4); + std::memcpy(&flags, desc + 12, 2); + std::memcpy(&next, desc + 14, 2); + if ((flags & kVirtioRingDescFWrite) == 0) { + ok = false; + break; + } + size_t take = std::min(segment_len, framed.size() - copied); + auto* segment = ptr_or_null(addr, take); + if (segment == nullptr) { + ok = false; + break; + } + std::memcpy(segment, framed.data() + copied, take); + copied += take; + if ((flags & kVirtioRingDescFNext) == 0) { + break; + } + if (next >= queue_num) { + ok = false; + break; + } + current = next; + } + if (!ok) { + return true; + } + + { + std::lock_guard lock(mu_); + if (!q.ready || q.num == 0 || q.device_addr == 0) { + return true; + } + uint16_t used_off = q.last_used_idx % q.num; + uint64_t used_elem_addr = q.device_addr + 4 + used_off * 8; + write_u32(used_elem_addr, desc_idx); + write_u32(used_elem_addr + 4, static_cast(framed.size())); + q.last_used_idx++; + write_u16(q.device_addr + 2, q.last_used_idx); + } + raise_used_buffer_irq(0); + return true; +} + +void VirtioNet::raise_used_buffer_irq(uint32_t queue_index) { + { + std::lock_guard lock(mu_); + if (queue_index >= 2) { + return; + } + Queue& q = queues_[queue_index]; + if (!q.ready || q.driver_addr == 0) { + return; + } + uint16_t avail_flags = read_u16(q.driver_addr); + if ((avail_flags & kVirtioRingAvailFNoInterrupt) != 0) { + return; + } + interrupt_status_ |= kVirtioInterruptVring; + } + ioapic_.request_irq(irq_); +} + +Slirp::Slirp(VirtioNet* net, std::vector host_fwds) + : net_(net), host_fwds_(std::move(host_fwds)) { + wakeup_event_ = WSACreateEvent(); + Check(wakeup_event_ != WSA_INVALID_EVENT, + "WSACreateEvent failed for slirp wakeup"); + + SlirpConfig cfg{}; + cfg.version = SLIRP_CONFIG_VERSION_MAX; + cfg.in_enabled = true; + cfg.vnetwork.s_addr = htonl(0x0A000200u); // 10.0.2.0 + cfg.vnetmask.s_addr = htonl(0xFFFFFF00u); // 255.255.255.0 + cfg.vhost.s_addr = htonl(0x0A000202u); // 10.0.2.2 + cfg.vdhcp_start.s_addr = htonl(0x0A00020Fu); // 10.0.2.15 + cfg.vnameserver.s_addr = htonl(0x0A000203u); // 10.0.2.3 + cfg.if_mtu = 1500; + cfg.if_mru = 1500; + + static const SlirpCb callbacks = { + /* send_packet */ &Slirp::SendPacketCb, + /* guest_error */ &Slirp::GuestErrorCb, + /* clock_get_ns */ &Slirp::ClockGetNsCb, + /* timer_new */ nullptr, + /* timer_free */ &Slirp::TimerFreeCb, + /* timer_mod */ &Slirp::TimerModCb, + /* register_poll_fd */ nullptr, + /* unregister_poll_fd */ nullptr, + /* notify */ &Slirp::NotifyCb, + /* init_completed */ nullptr, + /* timer_new_opaque */ &Slirp::TimerNewOpaqueCb, + /* register_poll_socket */ &Slirp::RegisterPollSocketCb, + /* unregister_poll_socket */ &Slirp::UnregisterPollSocketCb, + }; + + slirp_ = slirp_new(&cfg, &callbacks, this); + Check(slirp_ != nullptr, "slirp_new failed"); + + for (const HostFwd& fwd : host_fwds_) { + in_addr host{}; + in_addr guest{}; + host.s_addr = htonl(fwd.host_ip); + guest.s_addr = htonl(0x0A00020Fu); // forward to dhcp lease addr + int rc = slirp_add_hostfwd(slirp_, fwd.udp ? 1 : 0, host, fwd.host_port, + guest, fwd.guest_port); + if (rc != 0) { + std::fprintf(stderr, + "[node-vmm slirp] hostfwd %s:%u -> guest:%u failed (rc=%d)\n", + fwd.udp ? "udp" : "tcp", fwd.host_port, fwd.guest_port, rc); + } + } + + poll_thread_ = std::thread(&Slirp::poll_thread_main, this); +} + +Slirp::~Slirp() { + stop_.store(true); + if (wakeup_event_ != nullptr) { + WSASetEvent(wakeup_event_); + } + if (poll_thread_.joinable()) { + poll_thread_.join(); + } + { + std::lock_guard lock(mu_); + if (slirp_ != nullptr) { + slirp_cleanup(slirp_); + slirp_ = nullptr; + } + timers_.clear(); + } + if (wakeup_event_ != nullptr) { + WSACloseEvent(wakeup_event_); + wakeup_event_ = nullptr; + } +} + +slirp_ssize_t Slirp::on_send_packet(const void* buf, size_t len) { + if (net_ != nullptr) { + // Mirror eth_pad_short_frame from qemu/net/slirp.c:net_slirp_send_packet: + // Ethernet frames must be at least 60 bytes (excluding FCS) for the guest + // NIC to accept them. ARP / DHCP replies routinely come in shorter than + // that and were silently dropped on the guest side until we pad them. + if (len < 60) { + uint8_t padded[64]{}; + std::memcpy(padded, buf, len); + net_->deliver_rx_packet(padded, 60); + return static_cast(len); + } + net_->deliver_rx_packet(reinterpret_cast(buf), len); + } + return static_cast(len); +} + +void* Slirp::on_timer_new(SlirpTimerId id, void* cb_opaque) { + std::lock_guard lock(mu_); + auto timer = std::make_unique(); + timer->id = id; + timer->cb_opaque = cb_opaque; + timer->expire_ms = INT64_MAX; + Timer* raw = timer.get(); + timers_.push_back(std::move(timer)); + return raw; +} + +void Slirp::on_timer_free(void* timer) { + std::lock_guard lock(mu_); + for (auto it = timers_.begin(); it != timers_.end(); ++it) { + if (it->get() == timer) { + timers_.erase(it); + return; + } + } +} + +void Slirp::on_timer_mod(void* timer, int64_t expire_ms) { + std::lock_guard lock(mu_); + for (auto& t : timers_) { + if (t.get() == timer) { + t->expire_ms = expire_ms; + break; + } + } + if (wakeup_event_ != nullptr) { + WSASetEvent(wakeup_event_); + } +} + +void Slirp::on_notify() { + if (wakeup_event_ != nullptr) { + WSASetEvent(wakeup_event_); + } +} + +int Slirp::AddPollSocketCb(slirp_os_socket fd, int events, void* opaque) { + auto* self = reinterpret_cast(opaque); + PollEntry entry{}; + entry.fd = fd; + entry.events = events; + entry.revents = 0; + self->poll_entries_.push_back(entry); + return static_cast(self->poll_entries_.size() - 1); +} + +int Slirp::GetREventsCb(int idx, void* opaque) { + auto* self = reinterpret_cast(opaque); + if (idx < 0 || static_cast(idx) >= self->poll_entries_.size()) { + return 0; + } + return self->poll_entries_[idx].revents; +} + +void Slirp::poll_thread_main() { + while (!stop_.load()) { + poll_entries_.clear(); + uint32_t timeout_ms = 1000; + { + std::lock_guard lock(mu_); + if (slirp_ == nullptr) { + return; + } + slirp_pollfds_fill_socket(slirp_, &timeout_ms, &Slirp::AddPollSocketCb, + this); + } + + // Build WSAPOLLFD array. Always include our wakeup event via a dummy + // fd we control by signaling wakeup_event_ from the foreign thread. + std::vector fds; + fds.reserve(poll_entries_.size()); + for (auto& e : poll_entries_) { + WSAPOLLFD pfd{}; + pfd.fd = e.fd; + pfd.events = 0; + if (e.events & SLIRP_POLL_IN) pfd.events |= POLLRDNORM; + if (e.events & SLIRP_POLL_OUT) pfd.events |= POLLWRNORM; + if (e.events & SLIRP_POLL_PRI) pfd.events |= POLLRDBAND; + fds.push_back(pfd); + } + + DWORD wait_ms = std::min(timeout_ms, 50); + if (!fds.empty()) { + WSAPoll(fds.data(), static_cast(fds.size()), + static_cast(wait_ms)); + } else { + WaitForSingleObject(wakeup_event_, wait_ms); + } + WSAResetEvent(wakeup_event_); + + if (stop_.load()) { + break; + } + + int select_error = 0; + for (size_t i = 0; i < fds.size(); i++) { + const auto& pfd = fds[i]; + int re = 0; + if (pfd.revents & (POLLRDNORM | POLLIN)) re |= SLIRP_POLL_IN; + if (pfd.revents & (POLLWRNORM | POLLOUT)) re |= SLIRP_POLL_OUT; + if (pfd.revents & POLLRDBAND) re |= SLIRP_POLL_PRI; + if (pfd.revents & POLLERR) re |= SLIRP_POLL_ERR; + if (pfd.revents & POLLHUP) re |= SLIRP_POLL_HUP; + poll_entries_[i].revents = re; + if (pfd.revents & POLLNVAL) { + select_error = 1; + } + } + + { + std::lock_guard lock(mu_); + if (slirp_ == nullptr) { + break; + } + slirp_pollfds_poll(slirp_, select_error, &Slirp::GetREventsCb, this); + + // Fire any expired timers. + auto now_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + for (auto& t : timers_) { + if (t->expire_ms != INT64_MAX && t->expire_ms <= now_ms) { + int64_t expired = t->expire_ms; + t->expire_ms = INT64_MAX; + slirp_handle_timer(slirp_, t->id, t->cb_opaque); + (void)expired; + } + } + } + } +} +#endif // NODE_VMM_HAVE_LIBSLIRP + +struct EmulatorContext { + WhpApi* api{nullptr}; + WHV_PARTITION_HANDLE partition{nullptr}; + UINT32 vp_index{0}; + GuestMemory mem{}; + Uart* uart{nullptr}; + GuestExit* guest_exit{nullptr}; + Pic* pic{nullptr}; + Pit* pit{nullptr}; + Hpet* hpet{nullptr}; + AcpiPmTimer* pm_timer{nullptr}; + IoApic* ioapic{nullptr}; + std::vector blks; + VirtioRng* rng{nullptr}; + uint64_t rng_base{0}; + Cmos* cmos{nullptr}; +#ifdef NODE_VMM_HAVE_LIBSLIRP + VirtioNet* net{nullptr}; +#endif +}; + +// ===================================================================== +// SECTION: WHP emulator I/O + MMIO callbacks + RunVm orchestration +// ===================================================================== +HRESULT CALLBACK EmulatorGetRegisters( + VOID* context, + const WHV_REGISTER_NAME* names, + UINT32 count, + WHV_REGISTER_VALUE* values) { + auto* ctx = reinterpret_cast(context); + return ctx->api->get_vp_registers(ctx->partition, ctx->vp_index, names, count, values); +} + +HRESULT CALLBACK EmulatorSetRegisters( + VOID* context, + const WHV_REGISTER_NAME* names, + UINT32 count, + const WHV_REGISTER_VALUE* values) { + auto* ctx = reinterpret_cast(context); + return ctx->api->set_vp_registers(ctx->partition, ctx->vp_index, names, count, values); +} + +HRESULT CALLBACK EmulatorTranslateGva( + VOID* context, + WHV_GUEST_VIRTUAL_ADDRESS gva, + WHV_TRANSLATE_GVA_FLAGS flags, + WHV_TRANSLATE_GVA_RESULT_CODE* result, + WHV_GUEST_PHYSICAL_ADDRESS* gpa) { + auto* ctx = reinterpret_cast(context); + WHV_TRANSLATE_GVA_RESULT translation{}; + HRESULT hr = ctx->api->translate_gva(ctx->partition, ctx->vp_index, gva, flags, &translation, gpa); + *result = translation.ResultCode; + return hr; +} + +HRESULT CALLBACK EmulatorIoPort(VOID* context, WHV_EMULATOR_IO_ACCESS_INFO* access) { + auto* ctx = reinterpret_cast(context); + bool write = access->Direction != 0; + uint16_t port = access->Port; + if (WhpTraceEnabled()) { + std::fprintf( + stderr, + "[node-vmm whp emu-io] port=%s size=%u dir=%u data=%s write=%u\n", + Hex(port).c_str(), + access->AccessSize, + access->Direction, + Hex(access->Data).c_str(), + write ? 1U : 0U); + } + if (port == kNodeVmmExitPort && access->AccessSize == 1) { + if (write && ctx->guest_exit != nullptr) { + ctx->guest_exit->requested = true; + ctx->guest_exit->status = access->Data & 0xFF; + } else if (!write) { + access->Data = 0; + } + return S_OK; + } + if (port == kNodeVmmConsolePort && access->AccessSize == 1) { + if (write) { + uint8_t byte = static_cast(access->Data & 0xFF); + ctx->uart->emit_bytes(&byte, 1); + } else { + access->Data = 0; + } + return S_OK; + } + if (port >= kCom1Base && port < kCom1Base + 8 && access->AccessSize == 1) { + uint16_t off = static_cast(port - kCom1Base); + if (write) { + ctx->uart->write(off, static_cast(access->Data & 0xFF)); + } else { + access->Data = ctx->uart->read(off); + } + return S_OK; + } + if ((port == 0x20 || port == 0x21 || port == 0xA0 || port == 0xA1) && access->AccessSize == 1) { + if (write) { + ctx->pic->write_port(port, static_cast(access->Data & 0xFF)); + } else { + access->Data = ctx->pic->read_port(port); + } + return S_OK; + } + if (port >= 0x40 && port <= 0x43 && access->AccessSize == 1) { + if (write) { + ctx->pit->write_port(port, static_cast(access->Data & 0xFF)); + } else { + access->Data = ctx->pit->read_port(port); + } + return S_OK; + } + if (port == 0x61 && access->AccessSize == 1) { + // PC AT NMI Status / Speaker control: Linux uses bit 0 to gate PIT + // channel 2 and reads bit 5 to detect terminal count for TSC calibration + // (arch/x86/kernel/tsc.c:pit_calibrate_tsc). + if (write) { + uint8_t value = static_cast(access->Data & 0xFF); + ctx->pit->set_channel2_gate((value & 0x01) != 0); + } else { + // Layout matches qemu/hw/audio/pcspk.c:pcspk_io_read: gate(b0) | + // data_on(b1) | dummy_refresh(b4) | ch2_out(b5). We don't model the + // speaker; data_on stays 0. The refresh-clock toggles to satisfy any + // probe that polls bit 4 for time progression. + static std::atomic refresh_toggle{0}; + uint8_t value = ctx->pit->channel2_gated() ? 0x01 : 0x00; + value |= (refresh_toggle.fetch_xor(0x10) & 0x10); + if (ctx->pit->channel2_out_high()) { + value |= 0x20; + } + access->Data = value; + } + return S_OK; + } + if (port >= kAcpiPmTimerPort && port < kAcpiPmTimerPort + 4 && access->AccessSize <= 4) { + if (!write && ctx->pm_timer != nullptr) { + access->Data = ctx->pm_timer->read(port, static_cast(access->AccessSize)); + } + return S_OK; + } + if ((port == 0x70 || port == 0x71) && access->AccessSize == 1 && ctx->cmos != nullptr) { + if (write) { + ctx->cmos->write_port(port, static_cast(access->Data & 0xFF)); + } else { + access->Data = ctx->cmos->read_port(port); + } + return S_OK; + } + if (!write) { + access->Data = 0; + } + return S_OK; +} + +HRESULT CALLBACK EmulatorMemory(VOID* context, WHV_EMULATOR_MEMORY_ACCESS_INFO* access) { + auto* ctx = reinterpret_cast(context); + bool write = access->Direction != 0; + uint64_t gpa = access->GpaAddress; + uint32_t size = access->AccessSize; + if (size == 0 || size > 8) { + return E_INVALIDARG; + } + for (VirtioBlk* blk : ctx->blks) { + if (blk != nullptr && gpa >= blk->mmio_base() && gpa < blk->mmio_base() + kVirtioStride) { + if (write) { + blk->write_mmio(gpa, access->Data, size); + } else { + blk->read_mmio(gpa, access->Data, size); + } + return S_OK; + } + } + if (ctx->rng != nullptr && gpa >= ctx->rng_base && gpa < ctx->rng_base + kVirtioStride) { + if (write) { + ctx->rng->write_mmio(gpa, access->Data, size); + } else { + ctx->rng->read_mmio(gpa, access->Data, size); + } + return S_OK; + } + if (gpa >= kIoApicBase && gpa < kIoApicBase + kIoApicSize) { + if (write) { + ctx->ioapic->write_mmio(gpa, access->Data, size); + } else { + ctx->ioapic->read_mmio(gpa, access->Data, size); + } + return S_OK; + } + if (ctx->hpet != nullptr && gpa >= kHpetBase && gpa < kHpetBase + kHpetSize) { + if (write) { + ctx->hpet->write_mmio(gpa, access->Data, size); + } else { + ctx->hpet->read_mmio(gpa, access->Data, size); + } + return S_OK; + } +#ifdef NODE_VMM_HAVE_LIBSLIRP + if (ctx->net != nullptr && gpa >= ctx->net->mmio_base() && + gpa < ctx->net->mmio_base() + kVirtioStride) { + if (write) { + ctx->net->write_mmio(gpa, access->Data, size); + } else { + ctx->net->read_mmio(gpa, access->Data, size); + } + return S_OK; + } +#endif + if (gpa < ctx->mem.size() && size <= ctx->mem.size() - gpa) { + if (write) { + std::memcpy(ctx->mem.ptr(gpa, size), access->Data, size); + } else { + std::memcpy(access->Data, ctx->mem.ptr(gpa, size), size); + } + return S_OK; + } + if (!write) { + std::memset(access->Data, 0, size); + } + return S_OK; +} + +bool TranslateGuestAddress(WhpApi& api, WHV_PARTITION_HANDLE partition, UINT32 vp_index, uint64_t gva, uint64_t* gpa) { + WHV_TRANSLATE_GVA_RESULT translation{}; + HRESULT hr = api.translate_gva(partition, vp_index, gva, WHvTranslateGvaFlagNone, &translation, gpa); + return SUCCEEDED(hr) && translation.ResultCode == WHvTranslateGvaResultSuccess; +} + +uint8_t FetchGuestInstructionByte( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + GuestMemory& guest, + uint64_t gva) { + uint64_t gpa = gva; + (void)TranslateGuestAddress(api, partition, vp_index, gva, &gpa); + return *guest.ptr(gpa, 1); +} + +uint8_t DecodeIoInstructionLength(const WHV_X64_IO_PORT_ACCESS_CONTEXT& io) { + if (io.InstructionByteCount != 0) { + return io.InstructionByteCount; + } + uint8_t pos = 0; + while (pos < sizeof(io.InstructionBytes)) { + uint8_t byte = io.InstructionBytes[pos]; + bool prefix = + byte == 0x66 || byte == 0x67 || byte == 0xF2 || byte == 0xF3 || + byte == 0x2E || byte == 0x36 || byte == 0x3E || byte == 0x26 || + byte == 0x64 || byte == 0x65 || (byte >= 0x40 && byte <= 0x4F); + if (!prefix) { + break; + } + pos++; + } + if (pos >= sizeof(io.InstructionBytes)) { + return 0; + } + switch (io.InstructionBytes[pos]) { + case 0xE4: + case 0xE5: + case 0xE6: + case 0xE7: + return pos + 2; + case 0xEC: + case 0xED: + case 0xEE: + case 0xEF: + return pos + 1; + default: + return 0; + } +} + +WHV_X64_IO_PORT_ACCESS_CONTEXT PrepareWhpIoContext( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + GuestMemory& guest, + const WHV_RUN_VP_EXIT_CONTEXT& exit_context) { + WHV_X64_IO_PORT_ACCESS_CONTEXT io = exit_context.IoPortAccess; + io.Rax = GetRegister64(api, partition, vp_index, WHvX64RegisterRax); + uint64_t rdx = WhpTraceEnabled() ? GetRegister64(api, partition, vp_index, WHvX64RegisterRdx) : 0; + if (WhpTraceEnabled()) { + std::fprintf(stderr, "[node-vmm whp io-prep] rax=%s rdx=%s vplen=%u", Hex(io.Rax).c_str(), Hex(rdx).c_str(), exit_context.VpContext.InstructionLength); + } + if (io.InstructionByteCount == 0) { + for (size_t i = 0; i < sizeof(io.InstructionBytes); i++) { + io.InstructionBytes[i] = FetchGuestInstructionByte(api, partition, vp_index, guest, exit_context.VpContext.Rip + i); + } + io.InstructionByteCount = DecodeIoInstructionLength(io); + } + if (WhpTraceEnabled()) { + std::fprintf( + stderr, + " len=%u bytes=%02x %02x %02x %02x\n", + io.InstructionByteCount, + io.InstructionBytes[0], + io.InstructionBytes[1], + io.InstructionBytes[2], + io.InstructionBytes[3]); + } + return io; +} + +void HandleWhpIo( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + uint64_t rip, + const WHV_X64_IO_PORT_ACCESS_CONTEXT& io, + Uart& uart, + GuestExit& guest_exit, + Cmos* cmos, + AcpiPmTimer* pm_timer) { + Check(io.AccessInfo.AccessSize == 1 || io.AccessInfo.AccessSize == 2 || io.AccessInfo.AccessSize == 4, "unsupported I/O access size"); + Check(!io.AccessInfo.StringOp && !io.AccessInfo.RepPrefix, "string I/O is not supported yet"); + Check(io.InstructionByteCount != 0, "WHP I/O exit did not include a decodable instruction length"); + uint16_t port = io.PortNumber; + bool write = io.AccessInfo.IsWrite != 0; + uint64_t rax = GetRegister64(api, partition, vp_index, WHvX64RegisterRax); + uint32_t mask = io.AccessInfo.AccessSize == 1 ? 0xFFU : io.AccessInfo.AccessSize == 2 ? 0xFFFFU : 0xFFFFFFFFU; + uint32_t data = static_cast(rax) & mask; + + if (port == kNodeVmmExitPort && io.AccessInfo.AccessSize == 1) { + if (write) { + guest_exit.requested = true; + guest_exit.status = data & 0xFF; + } else { + rax = (rax & ~uint64_t(mask)) | 0; + } + } else if (port == kNodeVmmConsolePort && io.AccessInfo.AccessSize == 1) { + if (write) { + uint8_t byte = static_cast(data & 0xFF); + uart.emit_bytes(&byte, 1); + } else { + rax = (rax & ~uint64_t(mask)) | 0; + } + } else if (port >= kCom1Base && port < kCom1Base + 8 && io.AccessInfo.AccessSize == 1) { + uint16_t off = static_cast(port - kCom1Base); + if (write) { + uart.write(off, static_cast(data & 0xFF)); + } else { + rax = (rax & ~uint64_t(mask)) | uart.read(off); + } + } else if (port == 0x61 && io.AccessInfo.AccessSize == 1) { + // Fallback path matches the emulator-side handler above. + if (write) { + // No PIT pointer here; the value still settles via the emulator path. + } else { + rax = (rax & ~uint64_t(mask)) | 0; + } + } else if ((port == 0x70 || port == 0x71) && io.AccessInfo.AccessSize == 1 && cmos != nullptr) { + if (write) { + cmos->write_port(port, static_cast(data & 0xFF)); + } else { + rax = (rax & ~uint64_t(mask)) | cmos->read_port(port); + } + } else if (port >= kAcpiPmTimerPort && port < kAcpiPmTimerPort + 4 && + io.AccessInfo.AccessSize <= 4 && pm_timer != nullptr) { + if (!write) { + rax = (rax & ~uint64_t(mask)) | + (pm_timer->read(port, static_cast(io.AccessInfo.AccessSize)) & mask); + } + } else if (!write) { + rax = (rax & ~uint64_t(mask)) | 0; + } + + uint64_t next_rip = rip + io.InstructionByteCount; + if (write) { + SetRip(api, partition, vp_index, next_rip); + } else { + SetRaxRip(api, partition, vp_index, rax, next_rip); + } +} + +void HandleWhpCpuid( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + const WHV_RUN_VP_EXIT_CONTEXT& exit_context, + uint32_t cpus) { + const auto& cpuid = exit_context.CpuidAccess; + uint32_t leaf = static_cast(cpuid.Rax); + uint64_t rax = uint32_t(cpuid.DefaultResultRax); + uint64_t rbx = uint32_t(cpuid.DefaultResultRbx); + uint64_t rcx = uint32_t(cpuid.DefaultResultRcx); + uint64_t rdx = uint32_t(cpuid.DefaultResultRdx); + + switch (leaf) { + case 0x00000001: + rcx |= 1U << 31; // Hypervisor present. + rcx &= ~(1U << 24); // Hide TSC deadline until we emulate it. + break; + case 0x00000006: + rax &= ~(1U << 2); // Hide ARAT; WHP's LAPIC timer frequency comes via Hyper-V MSR. + break; + case 0x80000007: + rdx |= 1U << 8; // Invariant TSC: keep Linux off the ACPI PM timer clocksource. + break; + case kHvCpuidVendorAndMax: + rax = kHvCpuidImplementationLimits; + rbx = 0x7263694D; // "Micr" + rcx = 0x666F736F; // "osof" + rdx = 0x76482074; // "t Hv" + break; + case kHvCpuidInterface: + rax = 0x31237648; // "Hv#1" + rbx = rcx = rdx = 0; + break; + case kHvCpuidVersion: + rax = 0x00001DB1; // Build number, informational only. + rbx = 0x000A0000; + rcx = rdx = 0; + break; + case kHvCpuidFeatures: + rax = (1U << 5) | (1U << 6) | (1U << 11); // Hypercall, VP index, frequency MSRs. + rbx = 0; + rcx = 0; + rdx = 1U << 8; // Frequency MSRs available. + break; + case kHvCpuidEnlightenmentInfo: + rax = rbx = rcx = rdx = 0; + break; + case kHvCpuidImplementationLimits: + rax = std::max(1, cpus); + rbx = 0; + rcx = 0; + rdx = 0; + break; + default: + break; + } + + uint64_t next_rip = exit_context.VpContext.Rip + exit_context.VpContext.InstructionLength; + WHV_REGISTER_NAME names[] = { + WHvX64RegisterRax, + WHvX64RegisterRbx, + WHvX64RegisterRcx, + WHvX64RegisterRdx, + WHvX64RegisterRip, + }; + WHV_REGISTER_VALUE values[5]{}; + values[0].Reg64 = rax; + values[1].Reg64 = rbx; + values[2].Reg64 = rcx; + values[3].Reg64 = rdx; + values[4].Reg64 = next_rip; + CheckHr(api.set_vp_registers(partition, vp_index, names, 5, values), "WHvSetVirtualProcessorRegisters(CPUID)"); +} + +void HandleWhpMsr( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + const WHV_RUN_VP_EXIT_CONTEXT& exit_context, + HyperVState& hyperv) { + const auto& msr = exit_context.MsrAccess; + uint64_t next_rip = exit_context.VpContext.Rip + exit_context.VpContext.InstructionLength; + uint64_t write_value = (msr.Rax & 0xFFFFFFFFULL) | ((msr.Rdx & 0xFFFFFFFFULL) << 32); + if (msr.AccessInfo.IsWrite) { + switch (msr.MsrNumber) { + case kHvMsrGuestOsId: + hyperv.guest_os_id.store(write_value); + break; + case kHvMsrHypercall: + hyperv.hypercall.store(write_value); + break; + default: + break; + } + SetRip(api, partition, vp_index, next_rip); + return; + } + + uint64_t value = 0; + switch (msr.MsrNumber) { + case kHvMsrGuestOsId: + value = hyperv.guest_os_id.load(); + break; + case kHvMsrHypercall: + value = hyperv.hypercall.load(); + break; + case kHvMsrVpIndex: + value = vp_index; + break; + case kHvMsrTimeRefCount: + value = hyperv.time_ref_count(); + break; + case kHvMsrTscFrequency: + value = hyperv.tsc_frequency_hz; + break; + case kHvMsrApicFrequency: + value = hyperv.apic_frequency_hz; + break; + default: + value = 0; + break; + } + SetRaxRip(api, partition, vp_index, value & 0xFFFFFFFFULL, next_rip); + WHV_REGISTER_NAME name = WHvX64RegisterRdx; + WHV_REGISTER_VALUE reg{}; + reg.Reg64 = value >> 32; + CheckHr(api.set_vp_registers(partition, vp_index, &name, 1, ®), "WHvSetVirtualProcessorRegisters(MSR RDX)"); +} + +struct WhpEmulator { + WhpApi& api; + WHV_EMULATOR_HANDLE handle{nullptr}; + + explicit WhpEmulator(WhpApi& api_ref) : api(api_ref) { + WHV_EMULATOR_CALLBACKS callbacks{}; + callbacks.Size = sizeof(callbacks); + callbacks.WHvEmulatorIoPortCallback = EmulatorIoPort; + callbacks.WHvEmulatorMemoryCallback = EmulatorMemory; + callbacks.WHvEmulatorGetVirtualProcessorRegisters = EmulatorGetRegisters; + callbacks.WHvEmulatorSetVirtualProcessorRegisters = EmulatorSetRegisters; + callbacks.WHvEmulatorTranslateGvaPage = EmulatorTranslateGva; + CheckHr(api.emulator_create(&callbacks, &handle), "WHvEmulatorCreateEmulator"); + } + + WhpEmulator(const WhpEmulator&) = delete; + WhpEmulator& operator=(const WhpEmulator&) = delete; + + ~WhpEmulator() { + if (handle) { + api.emulator_destroy(handle); + } + } +}; + +bool ProbePartition(WhpApi& api, bool* setup_ok, std::string* reason) { + *setup_ok = false; + if (!api.create_partition || !api.set_partition_property || !api.setup_partition || !api.delete_partition) { + *reason = "WinHvPlatform.dll is missing partition lifecycle exports"; + return false; + } + Partition partition(api); + WHV_PARTITION_PROPERTY property{}; + property.ProcessorCount = 1; + HRESULT hr = api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeProcessorCount, + &property, + sizeof(property)); + if (FAILED(hr)) { + *reason = HresultMessage(hr, "WHvSetPartitionProperty(ProcessorCount)"); + return true; + } + hr = api.setup_partition(partition.handle); + if (FAILED(hr)) { + *reason = HresultMessage(hr, "WHvSetupPartition"); + return true; + } + *setup_ok = true; + return true; +} + +napi_value ProbeWhp(napi_env env, napi_callback_info) { + napi_value out = MakeObject(env); + SetString(env, out, "backend", "whp"); + SetString(env, out, "arch", "x86_64"); + SetBool(env, out, "available", false); + SetBool(env, out, "hypervisorPresent", false); + SetBool(env, out, "dirtyPageTracking", false); + SetBool(env, out, "queryDirtyBitmapExport", false); + SetBool(env, out, "partitionCreate", false); + SetBool(env, out, "partitionSetup", false); + + try { + WhpApi api(false); + if (!api.dll) { + SetString(env, out, "reason", "WinHvPlatform.dll is not available"); + return out; + } + SetBool(env, out, "queryDirtyBitmapExport", api.query_dirty_bitmap != nullptr); + + WHV_CAPABILITY present{}; + UINT32 written = 0; + HRESULT hr = api.get_capability(WHvCapabilityCodeHypervisorPresent, &present, sizeof(present), &written); + if (FAILED(hr)) { + SetString(env, out, "reason", HresultMessage(hr, "WHvGetCapability(HypervisorPresent)")); + return out; + } + SetBool(env, out, "hypervisorPresent", present.HypervisorPresent != FALSE); + + WHV_CAPABILITY features{}; + hr = api.get_capability(WHvCapabilityCodeFeatures, &features, sizeof(features), &written); + if (SUCCEEDED(hr)) { + SetBool(env, out, "dirtyPageTracking", features.Features.DirtyPageTracking != 0); + } + + bool setup_ok = false; + std::string reason; + bool partition_ok = ProbePartition(api, &setup_ok, &reason); + SetBool(env, out, "partitionCreate", partition_ok); + SetBool(env, out, "partitionSetup", setup_ok); + SetBool(env, out, "available", present.HypervisorPresent != FALSE && partition_ok && setup_ok); + if (!reason.empty()) { + SetString(env, out, "reason", reason); + } + return out; + } catch (const std::exception& err) { + SetString(env, out, "reason", err.what()); + return out; + } +} + +napi_value WhpSmokeHlt(napi_env env, napi_callback_info) { + try { + auto start = std::chrono::steady_clock::now(); + WhpApi api(true); + Partition partition(api); + + WHV_PARTITION_PROPERTY property{}; + property.ProcessorCount = 1; + CheckHr( + api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeProcessorCount, + &property, + sizeof(property)), + "WHvSetPartitionProperty(ProcessorCount)"); + CheckHr(api.setup_partition(partition.handle), "WHvSetupPartition"); + + VirtualAllocMemory ram(kGuestRamBytes); + uint8_t* bytes = ram.bytes(); + bytes[kGuestCodeAddr + 0] = 0xc6; // mov byte ptr [0x2000], 0x41 + bytes[kGuestCodeAddr + 1] = 0x06; + bytes[kGuestCodeAddr + 2] = static_cast(kGuestWriteAddr & 0xff); + bytes[kGuestCodeAddr + 3] = static_cast((kGuestWriteAddr >> 8) & 0xff); + bytes[kGuestCodeAddr + 4] = 0x41; + bytes[kGuestCodeAddr + 5] = 0xf4; // hlt + + WHV_MAP_GPA_RANGE_FLAGS flags = static_cast( + 0x00000001 | 0x00000002 | 0x00000004 | 0x00000008); + CheckHr(api.map_gpa_range(partition.handle, ram.ptr, 0, kGuestRamBytes, flags), "WHvMapGpaRange"); + + VirtualProcessor vp(api, partition.handle, 0); + + WHV_REGISTER_NAME names[] = { + WHvX64RegisterRip, + WHvX64RegisterRflags, + WHvX64RegisterCs, + WHvX64RegisterDs, + WHvX64RegisterEs, + WHvX64RegisterSs, + }; + WHV_REGISTER_VALUE values[sizeof(names) / sizeof(names[0])] = {}; + values[0].Reg64 = kGuestCodeAddr; + values[1].Reg64 = 0x2; + values[2].Segment = Segment(0, 0x9b); + values[3].Segment = Segment(0, 0x93); + values[4].Segment = Segment(0, 0x93); + values[5].Segment = Segment(0, 0x93); + CheckHr( + api.set_vp_registers( + partition.handle, + 0, + names, + static_cast(sizeof(names) / sizeof(names[0])), + values), + "WHvSetVirtualProcessorRegisters"); + + WHV_RUN_VP_EXIT_CONTEXT exit_context{}; + uint32_t runs = 0; + HRESULT hr = api.run_vp(partition.handle, 0, &exit_context, sizeof(exit_context)); + CheckHr(hr, "WHvRunVirtualProcessor"); + runs++; + + uint64_t dirty_bitmap = 0; + CheckHr( + api.query_dirty_bitmap(partition.handle, 0, kGuestRamBytes, &dirty_bitmap, sizeof(dirty_bitmap)), + "WHvQueryGpaRangeDirtyBitmap"); + auto end = std::chrono::steady_clock::now(); + auto elapsed_us = std::chrono::duration_cast(end - start).count(); + + napi_value out = MakeObject(env); + SetString(env, out, "backend", "whp"); + SetString(env, out, "exitReason", WhpExitReason(exit_context.ExitReason)); + SetUint32(env, out, "exitReasonCode", exit_context.ExitReason); + SetUint32(env, out, "runs", runs); + SetBool(env, out, "dirtyTracking", true); + SetDouble(env, out, "dirtyPages", CountBits(dirty_bitmap)); + SetDouble(env, out, "totalMs", static_cast(elapsed_us) / 1000.0); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +napi_value Unsupported(napi_env env, const char* method) { + napi_throw_error(env, nullptr, (std::string(method) + " is only available in the Linux KVM backend").c_str()); + return nullptr; +} + +napi_value ProbeKvm(napi_env env, napi_callback_info) { return Unsupported(env, "probeKvm"); } +napi_value SmokeHlt(napi_env env, napi_callback_info) { return Unsupported(env, "smokeHlt"); } +napi_value UartSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "uartSmoke"); } +napi_value GuestExitSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "guestExitSmoke"); } +napi_value RamSnapshotSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "ramSnapshotSmoke"); } +napi_value DirtyRamSnapshotSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "dirtyRamSnapshotSmoke"); } + +napi_value RunVm(napi_env env, napi_callback_info info) { + const auto t_run_enter = std::chrono::steady_clock::now(); + bool boot_trace = false; + { + char v[8] = {0}; + DWORD n = GetEnvironmentVariableA("NODE_VMM_BOOT_TIME", v, sizeof(v)); + boot_trace = (n > 0 && v[0] == '1'); + } + auto trace_phase = [&](const char* name) { + if (!boot_trace) return; + auto now = std::chrono::steady_clock::now(); + auto us = std::chrono::duration_cast(now - t_run_enter).count(); + std::fprintf(stderr, "[node-vmm boot-time] %s @ %lld us\n", name, (long long)us); + }; + trace_phase("run_enter"); + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + if (argc < 1) { + throw std::runtime_error("runVm requires a config object"); + } + std::string kernel_path = GetString(env, argv[0], "kernelPath"); + std::string rootfs_path = GetString(env, argv[0], "rootfsPath"); + std::string overlay_path = GetString(env, argv[0], "overlayPath"); + std::string cmdline = GetString(env, argv[0], "cmdline"); + uint32_t mem_mib = GetUint32(env, argv[0], "memMiB", 256); + uint32_t cpus = GetUint32(env, argv[0], "cpus", 1); + uint32_t timeout_ms = GetUint32(env, argv[0], "timeoutMs", 60000); + uint32_t console_limit = GetUint32(env, argv[0], "consoleLimit", 1024 * 1024); + bool interactive = GetBool(env, argv[0], "interactive", false); + std::string tap_name = GetString(env, argv[0], "netTapName"); + bool slirp_enabled = GetBool(env, argv[0], "netSlirpEnabled", false); + std::vector attached_disks = GetAttachedDisks(env, argv[0]); +#ifdef NODE_VMM_HAVE_LIBSLIRP + std::vector slirp_host_fwds; + { + napi_value fwds_value; + bool has_fwds = false; + napi_has_named_property(env, argv[0], "netSlirpHostFwds", &has_fwds); + if (has_fwds) { + napi_get_named_property(env, argv[0], "netSlirpHostFwds", &fwds_value); + bool is_array = false; + napi_is_array(env, fwds_value, &is_array); + if (is_array) { + uint32_t length = 0; + napi_get_array_length(env, fwds_value, &length); + slirp_host_fwds.reserve(length); + for (uint32_t i = 0; i < length; i++) { + napi_value entry; + napi_get_element(env, fwds_value, i, &entry); + Slirp::HostFwd fwd{}; + fwd.udp = GetBool(env, entry, "udp", false); + // hostAddr is "127.0.0.1" / "0.0.0.0" / "..." -> map to uint32 host order. + std::string host_addr = GetString(env, entry, "hostAddr"); + if (host_addr.empty() || host_addr == "0.0.0.0") { + fwd.host_ip = 0; // INADDR_ANY (slirp listens on all host ifaces) + } else { + in_addr addr{}; + if (inet_pton(AF_INET, host_addr.c_str(), &addr) == 1) { + fwd.host_ip = ntohl(addr.s_addr); + } + } + fwd.host_port = static_cast(GetUint32(env, entry, "hostPort", 0)); + fwd.guest_port = static_cast(GetUint32(env, entry, "guestPort", 0)); + if (fwd.host_port != 0 && fwd.guest_port != 0) { + slirp_host_fwds.push_back(fwd); + } + } + } + } + } +#endif + RunControl control = GetRunControl(env, argv[0]); + control.set_state(kControlStateStarting); + Check(attached_disks.size() < kMaxIoApicPins, "too many attached disks"); + uint32_t disk_count = static_cast(attached_disks.size() + 1); + uint32_t net_index = disk_count; + uint32_t rng_index = disk_count + 1; + CheckVirtioMmioDeviceCount(rng_index + 1); + + Check(!kernel_path.empty(), "kernelPath is required"); + Check(!rootfs_path.empty(), "rootfsPath is required"); + Check(!cmdline.empty(), "cmdline is required"); + Check(mem_mib > 0, "memMiB must be greater than zero"); + Check(cpus >= 1 && cpus <= kMaxVcpus, "cpus must be between 1 and 64"); + Check(tap_name.empty(), + "Windows WHP backend currently supports --net none and --net slirp only"); +#ifndef NODE_VMM_HAVE_LIBSLIRP + Check(!slirp_enabled, + "Windows WHP slirp networking requires libslirp; rebuild with vcpkg libslirp installed (see docs/windows.md)"); +#endif + Check(cmdline.size() + 1 <= kKernelCmdlineMax, "kernel cmdline is too long"); + + uint64_t rootfs_bytes = FileSizeBytes(rootfs_path); + bool rootfs_backed = rootfs_bytes > 0; + + WhpApi api(true); + HyperVState hyperv; + hyperv.tsc_frequency_hz = QueryWhpFrequency( + api, + WHvCapabilityCodeProcessorClockFrequency, + kFallbackTscFrequencyHz, + &WHV_CAPABILITY::ProcessorClockFrequency); + hyperv.apic_frequency_hz = QueryWhpFrequency( + api, + WHvCapabilityCodeInterruptClockFrequency, + kFallbackApicFrequencyHz, + &WHV_CAPABILITY::InterruptClockFrequency); + trace_phase("api_loaded"); + Partition partition(api); + trace_phase("partition_created"); + if (rootfs_backed) { + WHV_EXTENDED_VM_EXITS exits{}; + exits.X64CpuidExit = 1; + exits.X64MsrExit = 1; + CheckHr( + api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeExtendedVmExits, + &exits, + sizeof(exits)), + "WHvSetPartitionProperty(ExtendedVmExits)"); + } + if (rootfs_backed) { + WHV_X64_LOCAL_APIC_EMULATION_MODE apic_property = WHvX64LocalApicEmulationModeX2Apic; + CheckHr( + api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeLocalApicEmulationMode, + &apic_property, + sizeof(apic_property)), + "WHvSetPartitionProperty(LocalApicEmulationMode)"); + } + // Hyper-V synthetic processor features. Mirrors qemu/target/i386/whpx/ + // whpx-all.c:2313-2341 -- without this the guest sees no enlightenments + // (HypervisorPresent CPUID bit clear, no SynIC, no synthetic timers, + // no AccessVpRunTimeReg). Linux's "tsc=reliable" fallback works either + // way but the kernel logs benign noise like "TSC doesn't count with P0 + // frequency" because we never advertise the canonical Hyper-V interface. + // Best-effort: older Windows builds reject some banks, so we try the + // full set first, then back off to the minimal "just say HypervisorPresent" + // bank if the call fails. We swallow non-success here -- a partition + // without enlightenments still boots, just with worse performance hints. + if (rootfs_backed) { + WHV_PARTITION_PROPERTY synth{}; + synth.SyntheticProcessorFeaturesBanks.BanksCount = 1; + // Bit layout per WinHvPlatformDefs.h SyntheticProcessorFeatures1: + // bit 0 HypervisorPresent + // bit 1 Hv1 + // bit 2 AccessVpRunTimeReg + // bit 3 AccessPartitionReferenceCounter + // bit 4 AccessSynicRegs + // bit 5 AccessSyntheticTimerRegs + // bit 6 AccessIntrCtrlRegs + // bit 7 AccessHypercallMsrs + // bit 8 AccessVpIndex + // bit 19 TbFlushHypercalls + // bit 28 SignalEvents + synth.SyntheticProcessorFeaturesBanks.AsUINT64[0] = + (1ULL << 0) | (1ULL << 1) | (1ULL << 2) | (1ULL << 3) | + (1ULL << 7) | (1ULL << 8) | (1ULL << 19); + HRESULT hr = api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeSyntheticProcessorFeaturesBanks, + &synth, + sizeof(synth)); + if (FAILED(hr)) { + // Older Windows: try the absolute minimum (HypervisorPresent + Hv1). + synth.SyntheticProcessorFeaturesBanks.AsUINT64[0] = (1ULL << 0) | (1ULL << 1); + (void)api.set_partition_property( + partition.handle, + WHvPartitionPropertyCodeSyntheticProcessorFeaturesBanks, + &synth, + sizeof(synth)); + } + } + WHV_PARTITION_PROPERTY property{}; + property.ProcessorCount = cpus; + CheckHr( + api.set_partition_property(partition.handle, WHvPartitionPropertyCodeProcessorCount, &property, sizeof(property)), + "WHvSetPartitionProperty(ProcessorCount)"); + CheckHr(api.setup_partition(partition.handle), "WHvSetupPartition"); + trace_phase("partition_setup"); + + uint64_t ram_bytes = uint64_t(mem_mib) * 1024ULL * 1024ULL; + Check(ram_bytes <= uint64_t(1) << 32, "invalid guest memory size"); + VirtualAllocMemory ram(static_cast(ram_bytes)); + uint8_t* bytes = ram.bytes(); + GuestMemory guest{bytes, ram_bytes}; + trace_phase("ram_allocated"); + KernelInfo kernel = LoadElfKernel(bytes, ram_bytes, kernel_path); + trace_phase("kernel_loaded"); + uint64_t rsdp_addr = + CreateAcpiTables(bytes, ram_bytes, slirp_enabled, static_cast(cpus), disk_count); + WriteBootParams(bytes, ram_bytes, cmdline); + WriteU64(guest.ptr(kBootParamsAddr + 0x70, 8), rsdp_addr); + WriteMpTable(bytes, ram_bytes, static_cast(cpus)); + trace_phase("acpi_built"); + + WHV_MAP_GPA_RANGE_FLAGS flags = static_cast(0x00000001 | 0x00000002 | 0x00000004); + CheckHr(api.map_gpa_range(partition.handle, ram.ptr, 0, ram_bytes, flags), "WHvMapGpaRange"); + trace_phase("ram_mapped"); + std::vector> vcpus; + vcpus.reserve(cpus); + for (uint32_t i = 0; i < cpus; i++) { + vcpus.push_back(std::make_unique(api, partition.handle, i)); + } + for (uint32_t i = 0; i < cpus; i++) { + // For rootfs-backed Linux guests with multiple CPUs, only the BSP boots + // straight into long mode at kernel.entry. APs are left parked in WHP's + // default reset state so the X2APIC INIT/SIPI handshake can launch them + // through the kernel's real-mode trampoline. + bool is_rootfs_ap = rootfs_backed && i != 0; + SetupWhpBootstrapVcpu( + api, partition.handle, i, bytes, kernel.entry, rootfs_backed, is_rootfs_ap); + } + + // Pin the Windows scheduler to 1ms resolution so std::this_thread::sleep_for + // and WaitForSingleObject use 1ms granularity instead of the default 15.6ms. + // Without this, the PIT timer thread can only deliver ~67 Hz at best, which + // is below the HZ=100 / HZ=250 cadence Linux assumes during boot. + struct HighResolutionTimerScope { + HighResolutionTimerScope() { timeBeginPeriod(1); } + ~HighResolutionTimerScope() { timeEndPeriod(1); } + } high_res_timer; + + std::mutex run_wake_mu; + std::condition_variable run_wake_cv; + auto wake_halted_vcpus = [&]() { + run_wake_cv.notify_all(); + }; + + IoApic ioapic(api, partition.handle); + Pic pic(api, partition.handle); + Pit pit; + Hpet hpet; + hpet.attach_irq_line([&](uint32_t ioapic_pin, bool level) { + if (ioapic_pin < 24) { + ioapic.set_irq(ioapic_pin, level); + wake_halted_vcpus(); + } + }); + Cmos cmos; + AcpiPmTimer pm_timer; + std::vector> blks; + blks.reserve(disk_count); + blks.push_back(std::make_unique( + VirtioMmioBase(0), + guest, + rootfs_path, + overlay_path, + false, + [&] { ioapic.request_irq(VirtioMmioIrq(0)); })); + for (uint32_t i = 0; i < attached_disks.size(); i++) { + const AttachedDiskConfig& disk = attached_disks[i]; + uint32_t index = i + 1; + blks.push_back(std::make_unique( + VirtioMmioBase(index), + guest, + disk.path, + std::string(), + disk.read_only, + [&, index] { ioapic.request_irq(VirtioMmioIrq(index)); })); + } + std::vector blk_ptrs; + blk_ptrs.reserve(blks.size()); + for (const auto& blk : blks) { + blk_ptrs.push_back(blk.get()); + } + VirtioRng rng(VirtioMmioBase(rng_index), guest, + [&, rng_index] { ioapic.request_irq(VirtioMmioIrq(rng_index)); }); + Uart uart(console_limit, interactive); + // Default IRQ4 raiser routes through the IOAPIC. Replaced below + // (after `pic` exists) with the IOAPIC-then-PIC fallback router. + uart.attach_irq_raiser([&](uint32_t irq) { ioapic.request_irq(irq); }); + if (boot_trace) { + uart.enable_rx_debug(); + ioapic.enable_debug(); + } + GuestExit guest_exit; + WhpEmulator emulator(api); + +#ifdef NODE_VMM_HAVE_LIBSLIRP + std::unique_ptr virtio_net; + std::unique_ptr slirp_runtime; + if (slirp_enabled && rootfs_backed) { + // Locally-administered MAC. Bit 1 of the first octet must be set so + // the OUI doesn't collide with a registered manufacturer. + std::array mac{0x52, 0x54, 0x00, 0x12, 0x34, 0x56}; + virtio_net = std::make_unique( + ioapic, guest, VirtioMmioBase(net_index), VirtioMmioIrq(net_index), mac); + slirp_runtime = std::make_unique(virtio_net.get(), std::move(slirp_host_fwds)); + virtio_net->attach_slirp(slirp_runtime.get()); + } +#else + (void)slirp_enabled; + (void)net_index; +#endif + + std::vector emulator_contexts; + emulator_contexts.reserve(cpus); + for (uint32_t i = 0; i < cpus; i++) { + EmulatorContext ctx{}; + ctx.api = &api; + ctx.partition = partition.handle; + ctx.vp_index = i; + ctx.mem = guest; + ctx.uart = &uart; + ctx.guest_exit = &guest_exit; + ctx.pic = &pic; + ctx.pit = &pit; + ctx.hpet = &hpet; + ctx.pm_timer = &pm_timer; + ctx.ioapic = &ioapic; + ctx.blks = blk_ptrs; + ctx.rng = &rng; + ctx.rng_base = VirtioMmioBase(rng_index); + ctx.cmos = &cmos; +#ifdef NODE_VMM_HAVE_LIBSLIRP + ctx.net = virtio_net.get(); +#endif + emulator_contexts.push_back(ctx); + } + + // Per-vCPU IRQ delivery state. Only vCPU0 receives ExtInts in the current + // single-CPU rootfs path; the others get an entry for completeness so the + // run loop can index by cpu_index without bounds checks. + std::vector> vcpu_irq; + vcpu_irq.reserve(cpus); + for (uint32_t i = 0; i < cpus; i++) { + auto state = std::make_unique(); + state->index = i; + vcpu_irq.push_back(std::move(state)); + } + + std::mutex device_mu; + std::mutex result_mu; + std::atomic vm_done{false}; + std::atomic watchdog_done{false}; + std::atomic watchdog_timeout{false}; + std::atomic timer_done{false}; + std::atomic runs{0}; + std::atomic paused_count{0}; + std::atomic halted_count{0}; + std::atomic timeout_extension_ms{0}; + auto start = std::chrono::steady_clock::now(); + std::string final_exit_reason = "host-stop"; + std::string error_message; + uint32_t final_exit_code = WHvRunVpExitReasonCanceled; + auto cancel_all = [&]() { + for (uint32_t i = 0; i < cpus; i++) { + (void)api.cancel_run_vp(partition.handle, i, 0); + } + }; + // WHvRequestInterrupt is enough for fixed IOAPIC interrupts. The waker + // only nudges vCPU loops that are parked outside WHvRunVirtualProcessor + // after a HLT exit; it deliberately does not cancel every VP. + ioapic.attach_waker(wake_halted_vcpus); + auto finish = [&](const std::string& reason, uint32_t code) { + bool expected = false; + if (vm_done.compare_exchange_strong(expected, true)) { + { + std::lock_guard lock(result_mu); + final_exit_reason = reason; + final_exit_code = code; + } + if (boot_trace) { + auto now = std::chrono::steady_clock::now(); + auto us = std::chrono::duration_cast(now - t_run_enter).count(); + std::fprintf(stderr, "[node-vmm boot-time] finish:%s @ %lld us\n", reason.c_str(), (long long)us); + } + cancel_all(); + wake_halted_vcpus(); + } + }; + auto record_error = [&](const std::string& message) { + std::string detail = message; + if (detail.empty()) { + detail = watchdog_timeout.load() + ? "VM timed out after " + std::to_string(timeout_ms) + "ms" + : "unknown WHP vCPU runner error"; + } + { + std::lock_guard lock(result_mu); + if (error_message.empty()) { + error_message = detail; + } + } + finish("host-error", WHvRunVpExitReasonCanceled); + }; + + // Interactive stdin pump: when the user passed --interactive, switch the + // host console to virtual-terminal/raw mode so that keystrokes flow + // through verbatim and pipe them into the guest UART RX queue. Mirrors + // the POSIX path in native/kvm/backend.cc:3074-3130 - same Uart:: + // enqueue_rx, same Ctrl-C trap routed to the guest instead of killing + // the host VM. + std::atomic input_done{false}; + HANDLE stdin_handle = interactive ? GetStdHandle(STD_INPUT_HANDLE) : nullptr; + DWORD original_in_mode = 0; + DWORD original_out_mode = 0; + bool captured_in_mode = false; + bool captured_out_mode = false; + HANDLE stdout_handle = interactive ? GetStdHandle(STD_OUTPUT_HANDLE) : nullptr; + if (interactive && stdin_handle != INVALID_HANDLE_VALUE && stdin_handle != nullptr) { + if (GetConsoleMode(stdin_handle, &original_in_mode)) { + captured_in_mode = true; + DWORD raw_in = + (original_in_mode & + ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)) | + ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_WINDOW_INPUT | ENABLE_EXTENDED_FLAGS; + SetConsoleMode(stdin_handle, raw_in); + } + } + if (interactive && stdout_handle != INVALID_HANDLE_VALUE && stdout_handle != nullptr) { + if (GetConsoleMode(stdout_handle, &original_out_mode)) { + captured_out_mode = true; + SetConsoleMode(stdout_handle, + original_out_mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING); + } + } + auto restore_console = [&]() { + if (interactive && stdin_handle != nullptr && stdin_handle != INVALID_HANDLE_VALUE && captured_in_mode) { + SetConsoleMode(stdin_handle, original_in_mode); + } + if (interactive && stdout_handle != nullptr && stdout_handle != INVALID_HANDLE_VALUE && captured_out_mode) { + SetConsoleMode(stdout_handle, original_out_mode); + } + }; + + // Decide once whether stdin is a real console so the Ctrl handler only + // attaches for terminals. Input bytes always flow through ReadFile: with + // ENABLE_VIRTUAL_TERMINAL_INPUT set, Windows emits escape sequences for + // arrows/function keys instead of KEY_EVENT records with UnicodeChar == 0. + bool stdin_is_console = false; + if (interactive && stdin_handle != INVALID_HANDLE_VALUE && stdin_handle != nullptr) { + DWORD probe_mode = 0; + stdin_is_console = GetConsoleMode(stdin_handle, &probe_mode) != 0; + } + // Per agent #1 finding: when the input thread enqueues bytes into the + // UART RX queue, it must (a) take device_mu so its IRQ raise is + // serialized with vCPU-side device accesses (otherwise update_interrupt + // races with the guest reading IIR/LSR), and (b) call cancel_all() to + // kick the BSP vCPU out of HLT/wait so it actually traps to read the + // freshly delivered IRQ4. Without (b), bytes sit in the UART RX FIFO, IRQ4 fires + // through ioapic.request_irq -> WHvRequestInterrupt, but if the vCPU is + // halted in WHvRunVirtualProcessor at HLT it doesn't take the interrupt + // until the next external wake -- which never comes from a foreground + // shell that's already idle. Mirrors native/kvm/backend.cc:3074-3130. + auto deliver_uart_input = [&](const uint8_t* buf, size_t n) { + size_t delivered = 0; + while (delivered < n && !input_done.load()) { + size_t accepted = 0; + { + std::lock_guard lock(device_mu); + accepted = uart.enqueue_rx(buf + delivered, n - delivered); + } + if (accepted == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + cancel_all(); + wake_halted_vcpus(); + continue; + } + delivered += accepted; + if (boot_trace) { + std::fprintf(stderr, "[node-vmm input] enqueue_rx %zu/%zu bytes -> cancel_all\n", delivered, n); + } + cancel_all(); + wake_halted_vcpus(); + } + }; + + constexpr size_t kHostInputQueueLimit = 1024 * 1024; + constexpr size_t kHostInputPumpChunk = 256; + std::mutex host_input_mu; + std::condition_variable host_input_cv; + std::deque host_input_queue; + auto queue_host_input = [&](const uint8_t* buf, size_t n, bool front = false) { + if (buf == nullptr || n == 0) { + return; + } + std::unique_lock lock(host_input_mu); + if (front) { + while (host_input_queue.size() + n > kHostInputQueueLimit && !host_input_queue.empty()) { + host_input_queue.pop_back(); + } + for (size_t i = n; i > 0; i--) { + host_input_queue.push_front(buf[i - 1]); + } + } else { + host_input_cv.wait(lock, [&]() { + return input_done.load() || host_input_queue.size() + n <= kHostInputQueueLimit; + }); + if (input_done.load()) { + return; + } + for (size_t i = 0; i < n; i++) { + host_input_queue.push_back(buf[i]); + } + } + lock.unlock(); + host_input_cv.notify_all(); + }; + node_vmm::whp::ScopedConsoleCtrlHandler console_ctrl_handler(interactive && stdin_is_console, [&]() { + const uint8_t intr = 0x03; + queue_host_input(&intr, 1, true); + }); + // Wire COM1 IRQ4 routing now that ioapic, pic, and vcpu_irq all exist. + // Mirrors the IRQ0/PIT pattern at native/whp/backend.cc:4451: try the + // IOAPIC if its RTE is unmasked; otherwise fall back to legacy 8259A + // ExtInt + cancel_all() so the BSP wakes from HLT and notices the + // pending interrupt. KVM's KVM_IRQ_LINE picks the same way internally. + uart.attach_irq_raiser([&](uint32_t irq) { + if (ioapic.irq_unmasked(irq)) { + ioapic.request_irq(irq); + return; + } + if (!pic.is_initialized() || !pic.irq_unmasked(static_cast(irq))) { + return; + } + WhpVcpuIrqState& bsp = *vcpu_irq[0]; + bsp.ext_int_vector.store(pic.vector_for_irq(irq)); + bsp.ext_int_pending.store(true); + cancel_all(); + wake_halted_vcpus(); + }); + std::thread input_thread; + std::thread input_pump_thread; + if (interactive && stdin_handle != INVALID_HANDLE_VALUE && stdin_handle != nullptr) { + input_pump_thread = std::thread([&]() { + std::vector chunk; + chunk.reserve(kHostInputPumpChunk); + for (;;) { + chunk.clear(); + { + std::unique_lock lock(host_input_mu); + host_input_cv.wait(lock, [&]() { return input_done.load() || !host_input_queue.empty(); }); + if (input_done.load()) { + break; + } + size_t take = std::min(kHostInputPumpChunk, host_input_queue.size()); + for (size_t i = 0; i < take; i++) { + chunk.push_back(host_input_queue.front()); + host_input_queue.pop_front(); + } + } + host_input_cv.notify_all(); + if (!chunk.empty()) { + deliver_uart_input(chunk.data(), chunk.size()); + } + } + }); + input_thread = std::thread([&]() { + auto deliver_console_bytes = [&](const uint8_t* data, size_t len) { + std::vector normalized; + normalized.reserve(len); + for (size_t i = 0; i < len; i++) { + if (data[i] == '\r') { + if (i + 1 < len && data[i + 1] == '\n') { + normalized.push_back('\n'); + i++; + } else { + normalized.push_back('\r'); + } + continue; + } + normalized.push_back(data[i]); + } + if (!normalized.empty()) { + queue_host_input(normalized.data(), normalized.size()); + } + }; + if (stdin_is_console) { + uint8_t buf[4096]; + while (!input_done.load()) { + DWORD n = 0; + BOOL ok = ReadFile(stdin_handle, buf, sizeof(buf), &n, nullptr); + if (!ok || n == 0) { + if (input_done.load()) { + break; + } + if (boot_trace) { + std::fprintf(stderr, "[node-vmm input] ReadFile done ok=%d n=%lu err=%lu console=%d\n", + ok, n, ok ? 0 : GetLastError(), 1); + } + break; + } + if (boot_trace) { + std::fprintf(stderr, "[node-vmm input] read %lu bytes console=1\n", n); + } + deliver_console_bytes(buf, static_cast(n)); + } + } else { + uint8_t byte = 0; + while (!input_done.load()) { + DWORD n = 0; + BOOL ok = ReadFile(stdin_handle, &byte, 1, &n, nullptr); + if (!ok || n == 0) { + if (input_done.load()) { + break; + } + if (boot_trace) { + std::fprintf(stderr, "[node-vmm input] ReadFile done ok=%d n=%lu err=%lu console=0\n", + ok, n, ok ? 0 : GetLastError()); + } + break; + } + if (byte == '\r') { + continue; + } + queue_host_input(&byte, 1); + } + } + }); + } + auto stop_input = [&]() { + input_done = true; + if (interactive && stdin_handle != INVALID_HANDLE_VALUE && stdin_handle != nullptr) { + CancelIoEx(stdin_handle, nullptr); + } + if (input_thread.joinable()) { + CancelSynchronousIo(input_thread.native_handle()); + } + if (input_thread.joinable()) { + input_thread.join(); + } + host_input_cv.notify_all(); + if (input_pump_thread.joinable()) { + input_pump_thread.join(); + } + restore_console(); + }; + + std::thread watchdog([&]() { + while (!watchdog_done.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + int32_t command = control.command(); + if (command == kControlStop) { + control.set_state(kControlStateStopping); + finish("host-stop", WHvRunVpExitReasonCanceled); + break; + } + if (command == kControlPause) { + cancel_all(); + wake_halted_vcpus(); + continue; + } + auto deadline = + start + std::chrono::milliseconds(timeout_ms) + std::chrono::milliseconds(timeout_extension_ms.load()); + if (timeout_ms > 0 && std::chrono::steady_clock::now() > deadline) { + watchdog_timeout = true; + cancel_all(); + wake_halted_vcpus(); + break; + } + } + }); + auto stop_watchdog = [&]() { + watchdog_done = true; + if (watchdog.joinable()) { + watchdog.join(); + } + }; + auto deliver_timer_irq = [&](WhpVcpuIrqState& bsp, uint32_t ioapic_pin, uint8_t pic_irq, bool level, const char* source) { + if (ioapic_pin < 24 && ioapic.irq_unmasked(ioapic_pin)) { + if (level) { + ioapic.set_irq(ioapic_pin, true); + } else { + ioapic.request_irq(ioapic_pin); + } + return; + } + if (pic_irq < 8 && pic.is_initialized() && pic.irq_unmasked(pic_irq)) { + bsp.ext_int_vector.store(pic.vector_for_irq(pic_irq)); + bsp.ext_int_pending.store(true); + if (boot_trace) { + static std::atomic timer_extints{0}; + uint64_t n = ++timer_extints; + if (n <= 40 || n % 100 == 0) { + std::fprintf(stderr, "[node-vmm %s] extint irq%u vector=0x%02x tick=%llu\n", + source, + pic_irq, + pic.vector_for_irq(pic_irq) & 0xFF, + (unsigned long long)n); + } + } + cancel_all(); + wake_halted_vcpus(); + } + }; + // PIT timer thread: ~1 kHz polling cadence so HZ=100 / HZ=250 Linux kernels + // see steady jiffies progress. Routing depends on the kernel boot phase: + // * Before setup_IO_APIC, IRQ0 is delivered through the legacy 8259A + // PIC and the kernel expects an ExtInt event - we deliver it via + // WHvRegisterPendingEvent.ExtIntEvent gated by the QEMU-style + // InterruptWindow dance in TryDeliverPendingExtInt. + // * Once setup_IO_APIC unmasks IRQ0 in the IOAPIC redirection entry, + // the kernel routes through the LAPIC and expects a fixed-vector + // interrupt; we deliver via WHvRequestInterrupt (IoApic::request_irq) + // so Hyper-V's virtual LAPIC handles vector dispatch and EOI. + std::thread timer_thread([&]() { + if (!rootfs_backed) { + return; + } + WhpVcpuIrqState& bsp = *vcpu_irq[0]; + while (!timer_done.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (vm_done.load() || control.command() != kControlRun) { + continue; + } + std::lock_guard lock(device_mu); + for (const auto& irq : hpet.poll_expired()) { + deliver_timer_irq(bsp, irq.ioapic_pin, irq.pic_irq, irq.level, "hpet"); + } + if (!hpet.legacy_mode() && pit.poll_irq0()) { + // Two delivery paths: + // * IOAPIC unmasked PIT pin: kernel set up the IRQ handler and + // wants the timer through the LAPIC; route via WHvRequestInterrupt + // using whatever vector the kernel programmed in the RTE. + // PC-compatible IOAPIC wiring maps ISA IRQ0 to GSI 2, just like + // QEMU's ioapic_set_irq() and the MADT Type 2 override above. + // * IOAPIC masked: kernel hasn't programmed the IOAPIC for the PIT, + // fall back to legacy ExtInt with the PIC's vector. The kernel + // still depends on those early ticks for scheduler jiffies before + // the final IOAPIC route is ready. + deliver_timer_irq(bsp, kPitIoApicPin, 0, false, "pit"); + } + } + }); + auto stop_timer = [&]() { + timer_done = true; + if (timer_thread.joinable()) { + timer_thread.join(); + } + }; + auto make_result = [&]() { + control.set_state(kControlStateExited); + stop_input(); + stop_watchdog(); + stop_timer(); + if (watchdog_timeout.load() && error_message.empty()) { + throw std::runtime_error("VM timed out after " + std::to_string(timeout_ms) + "ms"); + } + if (!error_message.empty()) { + throw std::runtime_error(error_message); + } + std::string reason; + uint32_t code = 0; + { + std::lock_guard lock(result_mu); + reason = final_exit_reason; + code = final_exit_code; + } + uint64_t total_runs = runs.load(); + napi_value out = MakeObject(env); + SetString(env, out, "exitReason", reason); + SetUint32(env, out, "exitReasonCode", code); + SetUint32(env, out, "runs", total_runs > UINT32_MAX ? UINT32_MAX : static_cast(total_runs)); + SetString(env, out, "console", uart.console()); + return out; + }; + + bool trace_whp = WhpTraceEnabled(); + auto vcpu_loop = [&](uint32_t cpu_index) { + bool locally_halted = false; + WHV_RUN_VP_EXIT_CONTEXT last_exit_context{}; + bool have_last_exit_context = false; + for (;;) { + if (vm_done.load()) { + return; + } + int32_t command = control.command(); + if (command == kControlStop) { + control.set_state(kControlStateStopping); + finish("host-stop", WHvRunVpExitReasonCanceled); + return; + } + if (command == kControlPause) { + auto paused_at = std::chrono::steady_clock::now(); + if (paused_count.fetch_add(1) + 1 == cpus) { + control.set_state(kControlStatePaused); + } + while (control.command() == kControlPause && !vm_done.load() && !watchdog_timeout.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + paused_count.fetch_sub(1); + if (timeout_ms > 0 && cpu_index == 0) { + auto paused_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - paused_at) + .count(); + timeout_extension_ms.fetch_add(paused_ms); + } + if (control.command() == kControlStop) { + control.set_state(kControlStateStopping); + finish("host-stop", WHvRunVpExitReasonCanceled); + return; + } + if (!vm_done.load()) { + control.set_state(kControlStateRunning); + } + } + if (locally_halted) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + continue; + } + + { + std::lock_guard lock(device_mu); + if (rootfs_backed && cpu_index == 0) { + // Pre-run delivery follows whpx_vcpu_pre_run with kernel-irqchip + // active (qemu/target/i386/whpx/whpx-all.c:1595-1642): + // attempt the inject when the CPU is in a known-deliverable + // state; otherwise arm a one-shot DeliverabilityNotifications + // window so the next exit lets us inject. + (void)TryDeliverPendingExtInt(api, partition.handle, *vcpu_irq[0]); + } + ioapic.drain_pending(); + } + WHV_RUN_VP_EXIT_CONTEXT exit_context{}; + HRESULT hr = api.run_vp(partition.handle, cpu_index, &exit_context, sizeof(exit_context)); + if (FAILED(hr)) { + if (watchdog_timeout.load()) { + std::string detail = have_last_exit_context ? " last " + DescribeWhpExit(last_exit_context) : ""; + throw std::runtime_error("VM timed out after " + std::to_string(timeout_ms) + "ms" + detail); + } + CheckHr(hr, "WHvRunVirtualProcessor"); + } + uint64_t run_no = runs.fetch_add(1) + 1; + last_exit_context = exit_context; + have_last_exit_context = true; + // Refresh per-vCPU interruptibility from the exit context, mirroring + // whpx_vcpu_post_run (qemu/target/i386/whpx/whpx-all.c:1658-1679). + UpdateVcpuFromExit(*vcpu_irq[cpu_index], exit_context); + if (trace_whp && run_no <= 65536) { + std::fprintf( + stderr, + "[node-vmm whp] cpu=%u run=%llu %s\n", + cpu_index, + static_cast(run_no), + DescribeWhpExit(exit_context).c_str()); + } + if (watchdog_timeout.load()) { + throw std::runtime_error( + "VM timed out after " + std::to_string(timeout_ms) + "ms last " + DescribeWhpExit(exit_context)); + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64Halt) { + if (rootfs_backed) { + // Mirror whpx_handle_halt (qemu/target/i386/whpx/whpx-all.c:1498): + // a HLT with an interrupt already pending and IF=1 should not + // park the vCPU - inject right now and continue. + bool wait_for_interrupt = false; + { + std::lock_guard lock(device_mu); + WhpVcpuIrqState& vcpu = *vcpu_irq[cpu_index]; + // A HLT exit is a stable boundary where the guest is waiting for + // the next external interrupt. If the PIT thread posts IRQ0 just + // after this exit, the following pre-run pass must be allowed to + // inject it instead of waiting forever for an InterruptWindow exit. + vcpu.ready_for_pic_interrupt = vcpu.interruptable && vcpu.interrupt_flag; + if (boot_trace && cpu_index == 0) { + static std::atomic hlt_count{0}; + uint64_t h = ++hlt_count; + if (h <= 20 || h % 100 == 0) { + std::fprintf(stderr, "[node-vmm hlt] cpu0 ready_for_pic_interrupt=%d if=%d shadow_ok=%d pending=%d count=%llu\n", + (int)vcpu.ready_for_pic_interrupt, + (int)vcpu.interrupt_flag, + (int)vcpu.interruptable, + (int)vcpu.ext_int_pending.load(), + (unsigned long long)h); + } + } + if (vcpu.ext_int_pending.load()) { + TryDeliverPendingExtInt(api, partition.handle, vcpu); + } else { + wait_for_interrupt = true; + } + } + if (wait_for_interrupt) { + std::unique_lock wait_lock(run_wake_mu); + run_wake_cv.wait_for(wait_lock, std::chrono::milliseconds(50)); + } + continue; + } + locally_halted = true; + if (halted_count.fetch_add(1) + 1 == cpus) { + finish("hlt", exit_context.ExitReason); + } + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64IoPortAccess) { + bool guest_requested = false; + bool halted_console = false; + { + std::lock_guard lock(device_mu); + WHV_X64_IO_PORT_ACCESS_CONTEXT io_context = + PrepareWhpIoContext(api, partition.handle, cpu_index, guest, exit_context); + WHV_EMULATOR_STATUS status{}; + HRESULT emu_hr = api.emulator_try_io( + emulator.handle, + &emulator_contexts[cpu_index], + &exit_context.VpContext, + &io_context, + &status); + if (FAILED(emu_hr) || !status.EmulationSuccessful) { + HandleWhpIo( + api, + partition.handle, + cpu_index, + exit_context.VpContext.Rip, + io_context, + uart, + guest_exit, + &cmos, + &pm_timer); + } + guest_requested = guest_exit.requested; + halted_console = uart.contains("reboot: System halted") || uart.contains("Restarting system"); + } + if (guest_requested) { + finish("guest-exit", exit_context.ExitReason); + return; + } + if (halted_console) { + finish("halted-console", WHvRunVpExitReasonX64Halt); + return; + } + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonMemoryAccess) { + WHV_EMULATOR_STATUS status{}; + { + std::lock_guard lock(device_mu); + CheckHr( + api.emulator_try_mmio( + emulator.handle, + &emulator_contexts[cpu_index], + &exit_context.VpContext, + &exit_context.MemoryAccess, + &status), + "WHvEmulatorTryMmioEmulation"); + } + if (!status.EmulationSuccessful) { + throw std::runtime_error( + "WHP MMIO emulation failed with status " + std::to_string(status.AsUINT32) + + " gpa=0x" + std::to_string(exit_context.MemoryAccess.Gpa)); + } + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64ApicEoi) { + // QEMU whpx-all.c handles this by broadcasting the EOI to its IOAPIC + // model. Without this case WHP can surface a perfectly normal EOI as + // an unknown VM-exit and we would stop a healthy guest. + std::lock_guard lock(device_mu); + ioapic.eoi(exit_context.ApicEoi.InterruptVector); + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64InterruptWindow) { + // QEMU just flags ready and clears the registered window; the next + // pre-run call will inject (whpx-all.c:1864-1868). The IOAPIC drain + // covers fixed interrupts that the guest may finally take. + std::lock_guard lock(device_mu); + WhpVcpuIrqState& vcpu = *vcpu_irq[cpu_index]; + vcpu.ready_for_pic_interrupt = vcpu.interruptable && vcpu.interrupt_flag; + vcpu.window_registered = false; + if (vcpu.ext_int_pending.load()) { + TryDeliverPendingExtInt(api, partition.handle, vcpu); + } + ioapic.drain_pending(); + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64Cpuid) { + HandleWhpCpuid(api, partition.handle, cpu_index, exit_context, cpus); + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonX64MsrAccess) { + HandleWhpMsr(api, partition.handle, cpu_index, exit_context, hyperv); + continue; + } + if (exit_context.ExitReason == WHvRunVpExitReasonCanceled) { + if (boot_trace && cpu_index == 0) { + static std::atomic cancel_count{0}; + uint64_t c = ++cancel_count; + if (c <= 30 || c % 100 == 0) { + std::fprintf(stderr, "[node-vmm vcpu0] Canceled exit #%llu (rip=0x%llx)\n", + (unsigned long long)c, + (unsigned long long)exit_context.VpContext.Rip); + } + } + if (watchdog_timeout.load()) { + throw std::runtime_error("VM timed out after " + std::to_string(timeout_ms) + "ms"); + } + if (vm_done.load()) { + return; + } + continue; + } + finish(WhpExitReason(exit_context.ExitReason), exit_context.ExitReason); + return; + } + }; + + std::vector ap_threads; + ap_threads.reserve(cpus > 0 ? cpus - 1 : 0); + trace_phase("threads_about_to_start"); + try { + control.set_state(kControlStateRunning); + for (uint32_t i = 1; i < cpus; i++) { + ap_threads.emplace_back([&, i]() { + try { + vcpu_loop(i); + } catch (const std::exception& err) { + record_error(err.what()); + } catch (...) { + record_error("unknown WHP vCPU runner error"); + } + }); + } + try { + vcpu_loop(0); + } catch (const std::exception& err) { + record_error(err.what()); + } catch (...) { + record_error("unknown WHP vCPU runner error"); + } + trace_phase("vcpu0_returned"); + finish("host-stop", WHvRunVpExitReasonCanceled); + for (auto& thread : ap_threads) { + if (thread.joinable()) { + thread.join(); + } + } + return make_result(); + } catch (...) { + finish("host-error", WHvRunVpExitReasonCanceled); + for (auto& thread : ap_threads) { + if (thread.joinable()) { + thread.join(); + } + } + stop_input(); + stop_watchdog(); + stop_timer(); + control.set_state(kControlStateExited); + throw; + } + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// ===================================================================== +// SECTION: NAPI module exports +// ===================================================================== +// Returns {cols, rows} from the actual Windows console buffer. Used by the +// JS side to populate the guest TTY size cmdline arg. Direct Win32 query is +// more reliable than `process.stdout.columns`, which is undefined when +// stdout is piped (e.g. spawn from a parent Node) and falls back to the +// 80x24 default that breaks apk progress bars (issue: lines wrap because +// the guest thinks it has 80 cols when the host has 200+). +// Smoke-test entry point for native/whp_elf_loader.cc. Loads the ELF file +// at config.path into a temporary 256 MiB heap buffer and returns +// {entry, kernelEnd} on success, or throws via Throw on validation +// failures. Used by the unit tests in test/native.test.ts to exercise: +// - rejection of truncated headers +// - rejection of wrong magic / class / endianness / machine +// - successful parsing of a hand-built minimal ELF64 vmlinux +napi_value WhpElfLoaderSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + if (argc < 1) { + throw std::runtime_error("whpElfLoaderSmoke requires a config object"); + } + std::string path = GetString(env, argv[0], "path"); + Check(!path.empty(), "path is required"); + constexpr size_t kBufBytes = 256ULL * 1024ULL * 1024ULL; + std::vector mem(kBufBytes, 0); + KernelInfo info_result = LoadElfKernel(mem.data(), kBufBytes, path); + napi_value out = MakeObject(env); + napi_value entry; + napi_create_bigint_uint64(env, info_result.entry, &entry); + napi_set_named_property(env, out, "entry", entry); + napi_value kernel_end; + napi_create_bigint_uint64(env, info_result.kernel_end, &kernel_end); + napi_set_named_property(env, out, "kernelEnd", kernel_end); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp_boot_params.cc. Builds boot_params + e820 + +// MP table into a fresh 16 MiB scratch buffer at the canonical layout +// addresses, then returns a Buffer view + a few key fields so the JS +// side can verify: +// * boot signature 0xAA55 at offset 0x1FE inside boot_params +// * kernel signature "HdrS" at offset 0x202 +// * e820 entry count at 0x1E8 == 4 +// * cmd_line_ptr at 0x228 == kCmdlineAddr +// * MP floating-pointer signature "_MP_" near top of low RAM +napi_value WhpBootParamsSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + std::string cmdline = "console=ttyS0 root=/dev/vda"; + int cpus = 2; + if (argc >= 1) { + napi_valuetype t; + napi_typeof(env, argv[0], &t); + if (t == napi_object) { + std::string user_cmd = GetString(env, argv[0], "cmdline"); + if (!user_cmd.empty()) cmdline = user_cmd; + cpus = static_cast(GetUint32(env, argv[0], "cpus", 2)); + } + } + constexpr size_t kBufBytes = 16ULL * 1024ULL * 1024ULL; + std::vector mem(kBufBytes, 0); + WriteBootParams(mem.data(), kBufBytes, cmdline); + node_vmm::whp::WriteMpTable(mem.data(), kBufBytes, cpus, kPitIoApicPin); + napi_value out = MakeObject(env); + auto put32 = [&](const char* name, uint32_t value) { + SetUint32(env, out, name, value); + }; + auto read16 = [&](size_t off) -> uint32_t { + return static_cast(mem[off]) | (static_cast(mem[off + 1]) << 8); + }; + auto read32 = [&](size_t off) -> uint32_t { + return static_cast(mem[off]) + | (static_cast(mem[off + 1]) << 8) + | (static_cast(mem[off + 2]) << 16) + | (static_cast(mem[off + 3]) << 24); + }; + put32("e820_entries", mem[0x7000 + 0x1E8]); + put32("vid_mode", read16(0x7000 + 0x1FA)); + put32("boot_sig", read16(0x7000 + 0x1FE)); + put32("kernel_sig", read32(0x7000 + 0x202)); + put32("type_of_loader", mem[0x7000 + 0x210]); + put32("loadflags", mem[0x7000 + 0x211]); + put32("cmd_line_ptr", read32(0x7000 + 0x228)); + put32("cmd_line_size", read32(0x7000 + 0x238)); + put32("e820_0_addr_lo", read32(0x7000 + 0x2D0)); + put32("e820_0_size_lo", read32(0x7000 + 0x2D0 + 8)); + put32("e820_0_type", read32(0x7000 + 0x2D0 + 16)); + // MP floating pointer is placed near the top of low RAM (under 0xA0000). + // Scan 0x9F800..0xA0000 for the "_MP_" signature. + int mp_off = -1; + for (size_t off = 0x9F800; off < 0xA0000; off += 16) { + if (off + 4 <= kBufBytes && mem[off] == '_' && mem[off + 1] == 'M' && mem[off + 2] == 'P' && mem[off + 3] == '_') { + mp_off = static_cast(off); + break; + } + } + put32("mp_signature_offset", mp_off >= 0 ? static_cast(mp_off) : 0u); + SetBool(env, out, "mp_signature_found", mp_off >= 0); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp_page_tables.cc. Builds a 4 GiB identity-map +// page table at offset 0x9000 of a fresh 64 MiB scratch buffer (large +// enough to hold the PML4 + PDPT + 4 PDs = 6 pages = 24 KiB starting at +// 0x9000, so the PD3 entry at 0xE000 + 0xFF8 = 0xEFF8 still fits). Returns +// the table-base + the first few entries so the JS side can verify: +// * PML4[0] points to the PDPT (next page) with flags 0x07 +// * PDPT[0..3] each point to the right PD with flags 0x07 +// * PD0[0] is identity-mapped at phys=0 with huge-page flags 0x83 +// * PD3[511] is mapped at phys=0xFFE00000 (top of 4 GiB) with flags 0x83 +napi_value WhpPageTablesSmoke(napi_env env, napi_callback_info /*info*/) { + try { + constexpr size_t kBufBytes = 64ULL * 1024ULL * 1024ULL; + constexpr uint64_t kBase = 0x9000; + std::vector mem(kBufBytes, 0); + BuildPageTables(mem.data(), kBase); + auto read64 = [&mem](uint64_t addr) -> uint64_t { + uint64_t v = 0; + for (int i = 0; i < 8; i++) { + v |= uint64_t(mem[addr + i]) << (8 * i); + } + return v; + }; + napi_value out = MakeObject(env); + auto put = [&](const char* name, uint64_t value) { + napi_value v; + napi_create_bigint_uint64(env, value, &v); + napi_set_named_property(env, out, name, v); + }; + put("base", kBase); + put("pml4_0", read64(kBase)); + put("pdpt_0", read64(kBase + 0x1000)); + put("pdpt_1", read64(kBase + 0x1000 + 8)); + put("pdpt_2", read64(kBase + 0x1000 + 16)); + put("pdpt_3", read64(kBase + 0x1000 + 24)); + put("pd0_0", read64(kBase + 0x2000)); + put("pd0_1", read64(kBase + 0x2000 + 8)); + put("pd3_511", read64(kBase + 0x5000 + 511 * 8)); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp/irq.cc. Drives every branch of the IRQ +// delivery state machine without touching WHP: +// * UpdateVcpuFromExit reflects RFLAGS.IF / InterruptShadow / +// InterruptionPending from a fabricated WHV_RUN_VP_EXIT_CONTEXT. +// * EvaluateInjectDecision returns the matching enum +// (kNoPending / kInject / kArmWindow) for every can_inject combination. +// * Idempotence check: setting window_registered=true and toggling +// ext_int_pending leaves window_registered alone (no double-arm). +// +// Inputs (config object): +// rflags : uint32 RFLAGS value (only bit 9 / IF matters) +// interruptShadow : bool (1 → blocked by STI/MOV-SS shadow) +// interruptionPending: bool (1 → vCPU is mid-event-injection) +// readyForPic : bool (set by InterruptWindow exit handler) +// extIntPending : bool (set by timer/UART thread) +// extIntVector : uint32 (low 8 bits used) +// windowRegistered : bool (initial state for idempotence test) +// +// Outputs: +// { interruptable, interruptFlag, interruptionPending, readyForPic, +// decision: 'noPending' | 'inject' | 'armWindow', windowRegistered } +napi_value WhpIrqStateSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + Check(argc >= 1, "whpIrqStateSmoke requires a config object"); + + uint32_t rflags = GetUint32(env, argv[0], "rflags", 0x202u); + bool interrupt_shadow = GetBool(env, argv[0], "interruptShadow", false); + bool interruption_pending = GetBool(env, argv[0], "interruptionPending", false); + bool ready_for_pic = GetBool(env, argv[0], "readyForPic", false); + bool ext_int_pending = GetBool(env, argv[0], "extIntPending", false); + uint32_t ext_int_vector = GetUint32(env, argv[0], "extIntVector", 0x20u); + bool window_registered = GetBool(env, argv[0], "windowRegistered", false); + + WhpVcpuIrqState vcpu; + vcpu.index = 0; + vcpu.window_registered = window_registered; + vcpu.ext_int_pending.store(ext_int_pending); + vcpu.ext_int_vector.store(ext_int_vector); + vcpu.ready_for_pic_interrupt = ready_for_pic; + + WHV_RUN_VP_EXIT_CONTEXT ctx{}; + ctx.VpContext.Rflags = rflags; + ctx.VpContext.ExecutionState.InterruptShadow = interrupt_shadow ? 1 : 0; + ctx.VpContext.ExecutionState.InterruptionPending = interruption_pending ? 1 : 0; + UpdateVcpuFromExit(vcpu, ctx); + + node_vmm::whp::InjectDecision decision = node_vmm::whp::EvaluateInjectDecision(vcpu); + const char* decision_str = "noPending"; + if (decision == node_vmm::whp::InjectDecision::kInject) { + decision_str = "inject"; + } else if (decision == node_vmm::whp::InjectDecision::kArmWindow) { + decision_str = "armWindow"; + } + + napi_value out = MakeObject(env); + SetBool(env, out, "interruptable", vcpu.interruptable); + SetBool(env, out, "interruptFlag", vcpu.interrupt_flag); + SetBool(env, out, "interruptionPending", vcpu.interruption_pending); + SetBool(env, out, "readyForPic", vcpu.ready_for_pic_interrupt); + SetBool(env, out, "windowRegistered", vcpu.window_registered); + napi_value dec_v; + napi_create_string_utf8(env, decision_str, NAPI_AUTO_LENGTH, &dec_v); + napi_set_named_property(env, out, "decision", dec_v); + SetUint32(env, out, "extIntVector", vcpu.ext_int_vector.load()); + SetBool(env, out, "extIntPending", vcpu.ext_int_pending.load()); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp/devices/pic.cc. Drives the master 8259 PIC +// through Linux's init_8259A reprogramming sequence (ICW1 → ICW2 → ICW3 → +// ICW4) and returns the post-init state so the JS side can assert: +// * is_initialized() flips true once ICW2 (vector base) is written +// * vector_for_irq(0) reflects the new vector base (e.g. 0x30, not 0x20) +// * mask reads through port 0x21 reflect the OCW1 byte we wrote +// +// The Pic only calls into WHP from request_irq, which we don't exercise +// here — the smoke is purely a state-machine drive. We still need a +// WhpApi instance (Pic holds it by reference); `WhpApi(false)` skips any +// WHP DLL probe so the test runs on machines without Hyper-V too. +napi_value WhpPicSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + + uint32_t vector_base = 0x30; + uint32_t mask_after_init = 0xFB; // unmask IRQ2 (slave) like Linux does + if (argc >= 1) { + napi_valuetype t; + napi_typeof(env, argv[0], &t); + if (t == napi_object) { + vector_base = GetUint32(env, argv[0], "vectorBase", 0x30); + mask_after_init = GetUint32(env, argv[0], "maskAfterInit", 0xFB); + } + } + + WhpApi api(false); + Pic pic(api, nullptr); + + napi_value out = MakeObject(env); + SetBool(env, out, "initializedBeforeIcw", pic.is_initialized()); + SetUint32(env, out, "vectorForIrq0Before", pic.vector_for_irq(0)); + + // ICW1: 0x11 = ICW1_INIT | ICW1_ICW4_NEEDED. Master command port is 0x20. + pic.write_port(0x20, 0x11); + // ICW2 = vector base. Master data port is 0x21. + pic.write_port(0x21, static_cast(vector_base & 0xFF)); + // ICW3 = cascade pin map (slave on IRQ2 → bit 2 = 0x04). + pic.write_port(0x21, 0x04); + // ICW4 = 0x01 (8086 mode). + pic.write_port(0x21, 0x01); + // OCW1: write the mask. + pic.write_port(0x21, static_cast(mask_after_init & 0xFF)); + + SetBool(env, out, "initializedAfterIcw", pic.is_initialized()); + SetUint32(env, out, "vectorForIrq0After", pic.vector_for_irq(0)); + SetUint32(env, out, "vectorForIrq3After", pic.vector_for_irq(3)); + SetUint32(env, out, "maskRead", pic.read_port(0x21)); + SetBool(env, out, "irq0Unmasked", pic.irq_unmasked(0)); + SetBool(env, out, "irq2Unmasked", pic.irq_unmasked(2)); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp/devices/pit.cc. Walks channel 2 through the +// PC speaker / TSC-calibration path: gate it via set_channel2_gate(true), +// program a tiny reload value via the lobyte/hibyte access mode (mirroring +// the kernel's pit_hpet_ptimer_calibrate_cpu sequence), wait long enough +// that elapsed_time × kPitHz exceeds the divisor, then read back the OUT +// pin state. Returns the gate state and OUT-pin truth values for both the +// pre-wait and post-wait reads. +napi_value WhpPitSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + + // 0x000A = 10 PIT-clock ticks ≈ 8.4 µs. Sleeping 5 ms leaves us with + // ~5957 ticks against the divisor 10 → guaranteed OUT high. + uint32_t reload = 0x000A; + uint32_t wait_ms = 5; + if (argc >= 1) { + napi_valuetype t; + napi_typeof(env, argv[0], &t); + if (t == napi_object) { + reload = GetUint32(env, argv[0], "reload", reload); + wait_ms = GetUint32(env, argv[0], "waitMs", wait_ms); + } + } + + Pit pit; + napi_value out = MakeObject(env); + + // Before gating: OUT must be low. + SetBool(env, out, "channel2OutBeforeGate", pit.channel2_out_high()); + SetBool(env, out, "channel2GatedBeforeGate", pit.channel2_gated()); + + // Mode 0 (interrupt-on-terminal-count), access=3 (lobyte/hibyte). + // Command byte: SC=10 (channel 2) | RW=11 (lo+hi) | M=000 (mode 0) | BCD=0 + // = 0xB0. + pit.write_port(0x43, 0xB0); + // Now gate the channel — sets start = now. + pit.set_channel2_gate(true); + // Reload low + high byte through port 0x42. + pit.write_port(0x42, static_cast(reload & 0xFF)); + pit.write_port(0x42, static_cast((reload >> 8) & 0xFF)); + + SetBool(env, out, "channel2GatedAfterGate", pit.channel2_gated()); + + Sleep(wait_ms); + + SetBool(env, out, "channel2OutAfterWait", pit.channel2_out_high()); + + // Ungate and verify OUT drops back low. + pit.set_channel2_gate(false); + SetBool(env, out, "channel2OutAfterUngate", pit.channel2_out_high()); + + SetUint32(env, out, "reload", reload); + SetUint32(env, out, "waitMs", wait_ms); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp/devices/uart.cc — exercises the CRLF +// normalization in `write_stdout` end-to-end without spinning up a +// partition. The test uses `echo_stdout=false` so we don't actually +// touch the host console; instead we drive `emit_bytes` (the same +// internal path the paravirt port 0x600 uses) with mixed CRLF/LF input +// and verify the captured `console_` buffer matches. +// +// Why this exists: PR-1 fixed the apk progress-bar redraw bug by +// restoring CRLF normalization. PR-6 turned that fix into a module- +// level test so any future refactor of Uart::write_stdout can't lose +// it without a CI failure. +napi_value WhpUartCrlfSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + + std::string input = "a\nb\n"; + char initial_last_byte = 0; + if (argc >= 1) { + napi_valuetype t; + napi_typeof(env, argv[0], &t); + if (t == napi_object) { + std::string user_input = GetString(env, argv[0], "input"); + if (!user_input.empty()) { + input = user_input; + } + initial_last_byte = static_cast(GetUint32(env, argv[0], "lastByte", 0)); + } + } + + char last_byte = initial_last_byte; + std::string normalized = Uart::NormalizeCrlf(input, last_byte); + + napi_value out = MakeObject(env); + napi_value v; + napi_create_string_utf8(env, input.c_str(), input.size(), &v); + napi_set_named_property(env, out, "input", v); + napi_create_string_utf8(env, normalized.c_str(), normalized.size(), &v); + napi_set_named_property(env, out, "normalized", v); + SetUint32(env, out, "lastByte", static_cast(static_cast(last_byte))); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +// Smoke-test for native/whp/virtio/blk.cc — exercises the MMIO read +// path against the device-id / version / vendor / magic registers +// without spinning up a partition. Creates a 512-byte scratch file as +// the rootfs so the constructor's CreateFileA succeeds. +// +// Contract: virtio-mmio v2 magic 0x74726976 ("virt"), version 2, +// device id 2 (block), vendor "QEMU" (0x554D4551). +// Register-level UART smoke for the QEMU-like 16550 behavior used by WHP +// interactive ttyS0. This deliberately stays below the VM layer so failures +// point at the device model: FIFO trigger levels, IIR priority, overrun +// reporting/clearing, and THRE/TEMT transitions. +// Smoke-test for native/whp/console_writer.h. Exercises the host-only +// normalization that translates BusyBox apk progress redraw frames from +// DEC save/restore cursor form into a ConPTY-safe single-line redraw. +napi_value WhpConsoleWriterSmoke(napi_env env, napi_callback_info info) { + try { + size_t argc = 1; + napi_value argv[1]; + napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); + + std::string input = "\x1b""7 33% #### \x1b""8\x1b[0K\r"; + if (argc >= 1) { + napi_valuetype t; + napi_typeof(env, argv[0], &t); + if (t == napi_object) { + std::string user_input = GetString(env, argv[0], "input"); + if (!user_input.empty()) { + input = user_input; + } + } + } + + std::string normalized = node_vmm::whp::ConsoleWriter::NormalizeHostTerminalBytesForTest(input); + + napi_value out = MakeObject(env); + SetString(env, out, "input", input); + SetString(env, out, "normalized", normalized); + SetBool(env, out, "containsDecSaveRestore", + normalized.find("\x1b""7") != std::string::npos || + normalized.find("\x1b""8") != std::string::npos); + SetBool(env, out, "hidesCursor", normalized.find("\x1b[?25l") != std::string::npos); + SetBool(env, out, "showsCursor", normalized.find("\x1b[?25h") != std::string::npos); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +napi_value WhpUartRegisterSmoke(napi_env env, napi_callback_info /*info*/) { + try { + Uart uart(4096, false); + uint32_t irq_count = 0; + uart.attach_irq_raiser([&](uint32_t irq) { + if (irq == 4) { + irq_count++; + } + }); + + uint8_t initial_lsr = uart.read(5); + uart.write(2, 0xC7); // FIFO enable, clear RX/TX, trigger level 14. + uart.write(1, 0x07); // RDI, THRI, RLSI. + + const uint8_t one = 'a'; + size_t accepted_one = uart.enqueue_rx(&one, 1); + uint8_t iir_after_one = uart.read(2); + + uint8_t fill[13]{}; + for (size_t i = 0; i < sizeof(fill); i++) { + fill[i] = static_cast('b' + i); + } + size_t accepted_fill = uart.enqueue_rx(fill, sizeof(fill)); + uint8_t iir_at_trigger = uart.read(2); + uint8_t first_rx = uart.read(0); + + uart.write(2, 0xC3); // Keep FIFO enabled/trigger 14 and clear RX. + std::vector overflow(4097); + for (size_t i = 0; i < overflow.size(); i++) { + overflow[i] = static_cast('A' + i); + } + size_t accepted_overflow = uart.enqueue_rx(overflow.data(), overflow.size()); + uint8_t overrun_lsr = uart.read(5); + uint8_t overrun_lsr_after_clear = uart.read(5); + + uart.write(1, 0x02); // THRI only, so TX-empty priority is observable. + uart.write(0, 'x'); + uint8_t tx_iir = uart.read(2); + uint8_t tx_lsr = uart.read(5); + + napi_value out = MakeObject(env); + SetUint32(env, out, "initialLsr", initial_lsr); + SetUint32(env, out, "acceptedOne", static_cast(accepted_one)); + SetUint32(env, out, "iirAfterOne", iir_after_one); + SetUint32(env, out, "acceptedFill", static_cast(accepted_fill)); + SetUint32(env, out, "iirAtTrigger", iir_at_trigger); + SetUint32(env, out, "firstRx", first_rx); + SetUint32(env, out, "acceptedOverflow", static_cast(accepted_overflow)); + SetUint32(env, out, "overrunLsr", overrun_lsr); + SetUint32(env, out, "overrunLsrAfterClear", overrun_lsr_after_clear); + SetUint32(env, out, "txIir", tx_iir); + SetUint32(env, out, "txLsr", tx_lsr); + SetUint32(env, out, "irqCount", irq_count); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +napi_value WhpVirtioBlkSmoke(napi_env env, napi_callback_info /*info*/) { + try { + char temp_path[MAX_PATH]; + DWORD got_temp = GetTempPathA(MAX_PATH, temp_path); + Check(got_temp > 0 && got_temp < MAX_PATH, "GetTempPathA failed"); + char temp_file[MAX_PATH]; + Check(GetTempFileNameA(temp_path, "vmm", 0, temp_file) != 0, "GetTempFileNameA failed"); + { + WinHandle h(CreateFileA(temp_file, GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + Check(h.valid(), "open temp rootfs failed"); + DWORD wrote = 0; + uint8_t zeros[512] = {}; + Check(WriteFile(h.get(), zeros, sizeof(zeros), &wrote, nullptr) != 0, "write temp rootfs failed"); + } + + constexpr size_t kRamBytes = 1ULL * 1024ULL * 1024ULL; + std::vector ram(kRamBytes, 0); + GuestMemory mem{ram.data(), ram.size()}; + + bool irq_called = false; + VirtioBlk blk(0xD0000000ULL, mem, std::string(temp_file), std::string(), false, + [&] { irq_called = true; }); + + napi_value out = MakeObject(env); + auto read_reg32 = [&](uint64_t reg_off) -> uint32_t { + uint8_t buf[4]{}; + blk.read_mmio(0xD0000000ULL + reg_off, buf, 4); + return static_cast(buf[0]) + | (static_cast(buf[1]) << 8) + | (static_cast(buf[2]) << 16) + | (static_cast(buf[3]) << 24); + }; + SetUint32(env, out, "magicValue", read_reg32(0x000)); + SetUint32(env, out, "version", read_reg32(0x004)); + SetUint32(env, out, "deviceId", read_reg32(0x008)); + SetUint32(env, out, "vendorId", read_reg32(0x00C)); + SetUint32(env, out, "queueNumMax", read_reg32(0x034)); + SetBool(env, out, "irqRaisedBeforeWrite", irq_called); + DeleteFileA(temp_file); + return out; + } catch (const std::exception& err) { + return Throw(env, err); + } +} + +napi_value WhpHostConsoleSize(napi_env env, napi_callback_info /*info*/) { + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + unsigned cols = 0, rows = 0; + if (out != INVALID_HANDLE_VALUE && out != nullptr) { + CONSOLE_SCREEN_BUFFER_INFO info{}; + if (GetConsoleScreenBufferInfo(out, &info) != 0) { + cols = static_cast(info.srWindow.Right - info.srWindow.Left + 1); + rows = static_cast(info.srWindow.Bottom - info.srWindow.Top + 1); + } + } + napi_value obj; + napi_create_object(env, &obj); + SetUint32(env, obj, "cols", cols); + SetUint32(env, obj, "rows", rows); + return obj; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor desc[] = { + {"probeKvm", nullptr, ProbeKvm, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"probeWhp", nullptr, ProbeWhp, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"smokeHlt", nullptr, SmokeHlt, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpSmokeHlt", nullptr, WhpSmokeHlt, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"uartSmoke", nullptr, UartSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"guestExitSmoke", nullptr, GuestExitSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"ramSnapshotSmoke", nullptr, RamSnapshotSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"dirtyRamSnapshotSmoke", nullptr, DirtyRamSnapshotSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpHostConsoleSize", nullptr, WhpHostConsoleSize, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpElfLoaderSmoke", nullptr, WhpElfLoaderSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpPageTablesSmoke", nullptr, WhpPageTablesSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpBootParamsSmoke", nullptr, WhpBootParamsSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpIrqStateSmoke", nullptr, WhpIrqStateSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpPicSmoke", nullptr, WhpPicSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpPitSmoke", nullptr, WhpPitSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpUartCrlfSmoke", nullptr, WhpUartCrlfSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpConsoleWriterSmoke", nullptr, WhpConsoleWriterSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpUartRegisterSmoke", nullptr, WhpUartRegisterSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"whpVirtioBlkSmoke", nullptr, WhpVirtioBlkSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"runVm", nullptr, RunVm, nullptr, nullptr, nullptr, napi_default, nullptr}, + }; + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) + +} // namespace diff --git a/native/whp/boot_params.cc b/native/whp/boot_params.cc new file mode 100644 index 0000000..d0eed7a --- /dev/null +++ b/native/whp/boot_params.cc @@ -0,0 +1,163 @@ +#include "boot_params.h" + +#include "../common/bytes.h" + +#include +#include +#include +#include + +namespace node_vmm::whp { + +using node_vmm::common::CheckRange; +using node_vmm::common::WriteU16; +using node_vmm::common::WriteU32; +using node_vmm::common::WriteU64; + +namespace { + +// Layout constants. Subsequent refactor PRs may lift these into a shared +// layout header once a third or fourth module needs to know them. +constexpr uint64_t kBootParamsAddr = 0x7000; +constexpr uint64_t kCmdlineAddr = 0x20000; +constexpr uint64_t kE820Offset = 0x2D0; +constexpr uint64_t kE820EntrySize = 20; +constexpr uint64_t kMaxE820Entries = 4; + +void PutE820(uint8_t* mem, uint64_t off, uint64_t addr, uint64_t size, uint32_t type) { + WriteU64(mem + off, addr); + WriteU64(mem + off + 8, size); + WriteU32(mem + off + 16, type); +} + +uint8_t Checksum(const std::vector& data, size_t start, size_t len) { + uint8_t sum = 0; + for (size_t i = 0; i < len; i++) { + sum = static_cast(sum + data[start + i]); + } + return static_cast(~sum + 1); +} + +} // namespace + +void WriteBootParams(uint8_t* mem, uint64_t mem_size, const std::string& cmdline) { + if (cmdline.size() > std::numeric_limits::max()) { + throw std::runtime_error("kernel cmdline is too large"); + } + const uint64_t cmdline_len = static_cast(cmdline.size()) + 1; + CheckRange(mem_size, kCmdlineAddr, cmdline_len, "kernel cmdline"); + CheckRange(mem_size, kBootParamsAddr + 0x1E8, 1, "boot params e820 count"); + CheckRange(mem_size, kBootParamsAddr + 0x1FA, 2, "boot params video mode"); + CheckRange(mem_size, kBootParamsAddr + 0x1FE, 2, "boot params setup signature"); + CheckRange(mem_size, kBootParamsAddr + 0x202, 4, "boot params header signature"); + CheckRange(mem_size, kBootParamsAddr + 0x210, 2, "boot params loader flags"); + CheckRange(mem_size, kBootParamsAddr + 0x228, 4, "boot params cmdline pointer"); + CheckRange(mem_size, kBootParamsAddr + 0x238, 4, "boot params cmdline length"); + CheckRange( + mem_size, + kBootParamsAddr + kE820Offset, + kE820EntrySize * kMaxE820Entries, + "boot params e820 table"); + std::memcpy(mem + kCmdlineAddr, cmdline.c_str(), cmdline.size() + 1); + mem[kBootParamsAddr + 0x1E8] = 4; + PutE820(mem, kBootParamsAddr + kE820Offset, 0x00000000, 0x0009FC00, 1); + PutE820(mem, kBootParamsAddr + kE820Offset + kE820EntrySize, 0x0009FC00, 0x00040400, 2); + PutE820(mem, kBootParamsAddr + kE820Offset + (2 * kE820EntrySize), 0x000E0000, 0x00020000, 2); + if (mem_size > 0x00100000) { + PutE820(mem, kBootParamsAddr + kE820Offset + (3 * kE820EntrySize), 0x00100000, mem_size - 0x00100000, 1); + } + WriteU16(mem + kBootParamsAddr + 0x1FA, 0xFFFF); + WriteU16(mem + kBootParamsAddr + 0x1FE, 0xAA55); + WriteU32(mem + kBootParamsAddr + 0x202, 0x53726448); + mem[kBootParamsAddr + 0x210] = 0xFF; + mem[kBootParamsAddr + 0x211] |= 0x01; + WriteU32(mem + kBootParamsAddr + 0x228, static_cast(kCmdlineAddr)); + WriteU32(mem + kBootParamsAddr + 0x238, static_cast(cmdline.size())); +} + +void WriteMpTable(uint8_t* mem, uint64_t mem_size, int cpus, uint32_t pit_io_apic_pin) { + constexpr uint64_t base_ram_end = 0xA0000; + constexpr uint32_t apic_default_base = 0xFEE00000; + constexpr uint32_t io_apic_base = 0xFEC00000; + constexpr uint8_t apic_version = 0x14; + constexpr int max_legacy_gsi = 23; + int size = 16 + 44 + (20 * cpus) + 8 + 8 + (8 * (max_legacy_gsi + 1)) + (8 * 2); + uint64_t start = (base_ram_end - static_cast(size)) & ~0xFULL; + CheckRange(mem_size, start, static_cast(size), "MP table"); + std::vector buf(static_cast(size)); + uint32_t table_addr = static_cast(start + 16); + uint8_t io_apic_id = static_cast(cpus + 1); + + std::memcpy(buf.data(), "_MP_", 4); + WriteU32(buf.data() + 4, table_addr); + buf[8] = 1; + buf[9] = 4; + buf[10] = Checksum(buf, 0, 16); + + std::vector entries; + uint16_t entry_count = 0; + for (int cpu = 0; cpu < cpus; cpu++) { + uint8_t entry[20]{}; + entry[0] = 0; + entry[1] = static_cast(cpu); + entry[2] = apic_version; + entry[3] = 0x01 | (cpu == 0 ? 0x02 : 0); + WriteU32(entry + 4, 0x600); + WriteU32(entry + 8, 0x200 | 0x001); + entries.insert(entries.end(), entry, entry + sizeof(entry)); + entry_count++; + } + uint8_t bus[8]{}; + bus[0] = 1; + std::memcpy(bus + 2, "ISA ", 6); + entries.insert(entries.end(), bus, bus + sizeof(bus)); + entry_count++; + + uint8_t ioapic[8]{}; + ioapic[0] = 2; + ioapic[1] = io_apic_id; + ioapic[2] = apic_version; + ioapic[3] = 1; + WriteU32(ioapic + 4, io_apic_base); + entries.insert(entries.end(), ioapic, ioapic + sizeof(ioapic)); + entry_count++; + + for (int irq = 0; irq <= max_legacy_gsi; irq++) { + uint8_t entry[8]{}; + entry[0] = 3; + entry[1] = 0; + entry[4] = 0; + entry[5] = static_cast(irq); + entry[6] = io_apic_id; + entry[7] = static_cast(irq == 0 ? pit_io_apic_pin : static_cast(irq)); + entries.insert(entries.end(), entry, entry + sizeof(entry)); + entry_count++; + } + uint8_t extint[8]{}; + extint[0] = 4; + extint[1] = 3; + entries.insert(entries.end(), extint, extint + sizeof(extint)); + entry_count++; + + uint8_t nmi[8]{}; + nmi[0] = 4; + nmi[1] = 1; + nmi[6] = 0xFF; + nmi[7] = 1; + entries.insert(entries.end(), nmi, nmi + sizeof(nmi)); + entry_count++; + + uint8_t* header = buf.data() + 16; + std::memcpy(header, "PCMP", 4); + WriteU16(header + 4, static_cast(44 + entries.size())); + header[6] = 4; + std::memcpy(header + 8, "FC ", 8); + std::memcpy(header + 16, "000000000000", 12); + WriteU16(header + 34, entry_count); + WriteU32(header + 36, apic_default_base); + std::memcpy(buf.data() + 16 + 44, entries.data(), entries.size()); + header[7] = Checksum(buf, 16, 44 + entries.size()); + std::memcpy(mem + start, buf.data(), buf.size()); +} + +} // namespace node_vmm::whp diff --git a/native/whp/boot_params.h b/native/whp/boot_params.h new file mode 100644 index 0000000..9e0d948 --- /dev/null +++ b/native/whp/boot_params.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +namespace node_vmm::whp { + +// Writes the Linux x86 boot protocol "zero page" (boot_params) at the +// canonical kBootParamsAddr (0x7000) of guest memory plus an e820 memory +// map covering low-RAM, the legacy 9FC00..A0000 reserved hole, the +// E0000..100000 reserved region, and main RAM above 1 MiB. Also copies +// the kernel command line to kCmdlineAddr (0x20000) and points the +// boot_params.cmd_line_ptr field at it. Mirrors qemu/hw/i386/x86-common.c. +// +// Layout written into boot_params: +// +0x1E8 e820_entries (4) +// +0x1FA vid_mode (0xFFFF, "ask BIOS") +// +0x1FE boot signature (0xAA55) +// +0x202 kernel_signature ("HdrS") +// +0x210 type_of_loader (0xFF == "unknown loader, vmlinux ELF entry") +// +0x211 loadflags |= LOADED_HIGH (bit 0) +// +0x228 cmd_line_ptr (kCmdlineAddr) +// +0x238 cmd_line_size (cmdline.size()) +// +0x2D0 e820 table (4 entries x 20 bytes) +void WriteBootParams(uint8_t* mem, uint64_t mem_size, const std::string& cmdline); + +// Writes the Intel MultiProcessor Specification configuration table near +// the top of low RAM (under the legacy 0xA0000 boundary). Linux still +// scans this region during early SMP bring-up if MADT is missing or +// disagrees. Entries: +// * one Processor entry per CPU (BSP gets bit 1 set in flags) +// * one Bus entry (ISA) +// * one IO APIC entry +// * one IO Interrupt entry per legacy GSI 0..max_legacy_gsi (24 by +// default), with IRQ0 routed to the override pin (`pit_io_apic_pin`, +// normally 2 to match ACPI MADT's Interrupt Source Override) +// * one ExtINT and one NMI Local Interrupt entry +void WriteMpTable(uint8_t* mem, uint64_t mem_size, int cpus, uint32_t pit_io_apic_pin); + +} // namespace node_vmm::whp diff --git a/native/whp/console_writer.h b/native/whp/console_writer.h new file mode 100644 index 0000000..925b99d --- /dev/null +++ b/native/whp/console_writer.h @@ -0,0 +1,536 @@ +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace node_vmm::whp { + +class ConsoleWriter { + public: + explicit ConsoleWriter(bool enabled) : enabled_(enabled) { + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD mode = 0; + output_is_console_ = enabled_ && out != INVALID_HANDLE_VALUE && out != nullptr && GetConsoleMode(out, &mode) != 0; + if (output_is_console_) { + old_output_cp_ = GetConsoleOutputCP(); + if (old_output_cp_ != CP_UTF8 && SetConsoleOutputCP(CP_UTF8) != 0) { + changed_output_cp_ = true; + } + } + if (enabled_) { + writer_ = std::thread([this]() { writer_loop(); }); + } + } + + ~ConsoleWriter() { stop(); } + + ConsoleWriter(const ConsoleWriter&) = delete; + ConsoleWriter& operator=(const ConsoleWriter&) = delete; + + void write(const std::string& bytes) { + if (bytes.empty()) { + return; + } + std::unique_lock lock(mu_); + cv_.wait(lock, [&]() { + return stopping_ || !enabled_ || pending_.size() < kMaxPendingBytes; + }); + if (!enabled_ || stopping_) { + return; + } + if (pending_.size() + bytes.size() > kMaxPendingBytes) { + flush_locked(lock); + if (!enabled_ || stopping_) { + return; + } + } + pending_.append(bytes); + lock.unlock(); + cv_.notify_one(); + } + + bool backed_up() { + std::lock_guard lock(mu_); + return pending_.size() >= kBackpressureBytes; + } + + size_t pending_bytes() { + std::lock_guard lock(mu_); + return pending_.size(); + } + + void stop() { + { + std::lock_guard lock(mu_); + if (!enabled_ && !writer_.joinable()) { + return; + } + stopping_ = true; + } + cv_.notify_all(); + if (writer_.joinable()) { + writer_.join(); + } + std::lock_guard lock(mu_); + if (changed_output_cp_) { + SetConsoleOutputCP(old_output_cp_); + changed_output_cp_ = false; + } + enabled_ = false; + } + + void flush_pending() { + std::unique_lock lock(mu_); + flush_locked(lock); + } + + std::string cursor_position_report() { + flush_pending(); + std::lock_guard file_lock(file_mu_); + CONSOLE_SCREEN_BUFFER_INFO info{}; + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + if (out != INVALID_HANDLE_VALUE && out != nullptr && GetConsoleScreenBufferInfo(out, &info) != 0) { + int row = static_cast(info.dwCursorPosition.Y - info.srWindow.Top + 1); + int col = static_cast(info.dwCursorPosition.X - info.srWindow.Left + 1); + if (row < 1) row = 1; + if (col < 1) col = 1; + char response[32]; + std::snprintf(response, sizeof(response), "\x1b[%d;%dR", row, col); + return response; + } + return "\x1b[1;1R"; + } + + bool can_passthrough_terminal_query() const { + return output_is_console_; + } + + static std::string NormalizeHostTerminalBytesForTest(const std::string& bytes) { + bool dec_restore_pending_clear = false; + bool swallow_dec_clear = false; + bool swallow_dec_clear_cr = false; + return normalize_host_terminal_bytes_impl( + bytes.data(), + bytes.size(), + dec_restore_pending_clear, + swallow_dec_clear, + swallow_dec_clear_cr); + } + + private: + enum class ParseState { + Normal, + Esc, + Csi, + Osc, + }; + + static constexpr size_t kFlushThreshold = 16 * 1024; + static constexpr size_t kBackpressureBytes = 64 * 1024; + static constexpr size_t kMaxPendingBytes = 256 * 1024; + static constexpr auto kCoalesceDelay = std::chrono::milliseconds(6); + static constexpr auto kRedrawFrameDelay = std::chrono::milliseconds(100); + + static bool is_csi_final(unsigned char byte) { + return byte >= 0x40 && byte <= 0x7E; + } + + static size_t terminal_safe_prefix(const std::string& bytes) { + ParseState state = ParseState::Normal; + bool osc_esc = false; + size_t safe = 0; + + for (size_t i = 0; i < bytes.size(); i++) { + unsigned char byte = static_cast(bytes[i]); + switch (state) { + case ParseState::Normal: + if (byte == 0x1B) { + state = ParseState::Esc; + } else if (byte == '\n' || byte == '\a') { + safe = i + 1; + } + break; + case ParseState::Esc: + if (byte == '[') { + state = ParseState::Csi; + } else if (byte == ']') { + state = ParseState::Osc; + osc_esc = false; + } else { + state = ParseState::Normal; + safe = i + 1; + } + break; + case ParseState::Csi: + if (is_csi_final(byte)) { + state = ParseState::Normal; + safe = i + 1; + } + break; + case ParseState::Osc: + if (byte == '\a') { + state = ParseState::Normal; + safe = i + 1; + osc_esc = false; + } else if (osc_esc && byte == '\\') { + state = ParseState::Normal; + safe = i + 1; + osc_esc = false; + } else { + osc_esc = byte == 0x1B; + } + break; + } + } + + return safe; + } + + static bool has_open_dec_redraw_frame(const std::string& bytes) { + for (size_t i = 0; i + 1 < bytes.size(); i++) { + if (static_cast(bytes[i]) != 0x1B) { + continue; + } + if (bytes[i + 1] == '7') { + size_t restore = i + 2; + while (restore + 1 < bytes.size()) { + if (static_cast(bytes[restore]) == 0x1B && bytes[restore + 1] == '8') { + break; + } + restore++; + } + if (restore + 1 >= bytes.size()) { + return true; + } + size_t after_restore = restore + 2; + if (after_restore >= bytes.size()) { + return true; + } + if (static_cast(bytes[after_restore]) != 0x1B) { + i = restore + 1; + continue; + } + if (after_restore + 1 >= bytes.size()) { + return true; + } + if (bytes[after_restore + 1] != '[') { + i = restore + 1; + continue; + } + size_t csi_end = after_restore + 2; + while (csi_end < bytes.size() && !is_csi_final(static_cast(bytes[csi_end]))) { + csi_end++; + } + if (csi_end >= bytes.size()) { + return true; + } + std::string params(bytes.data() + after_restore + 2, csi_end - after_restore - 2); + if (bytes[csi_end] == 'K' && (params.empty() || params == "0")) { + return false; + } + i = restore + 1; + } + } + return false; + } + + static bool has_control_byte(const char* data, size_t start, size_t end) { + for (size_t i = start; i < end; i++) { + unsigned char byte = static_cast(data[i]); + if (byte < 0x20 || byte == 0x7F) { + return true; + } + } + return false; + } + + static size_t trim_right_spaces(const char* data, size_t start, size_t end) { + while (end > start && (data[end - 1] == ' ' || data[end - 1] == '\t')) { + end--; + } + return end; + } + + static bool looks_like_progress_payload(const char* data, size_t start, size_t end) { + while (start < end && data[start] == ' ') { + start++; + } + size_t digits = 0; + while (start < end && data[start] >= '0' && data[start] <= '9' && digits < 3) { + start++; + digits++; + } + return digits > 0 && start < end && data[start] == '%'; + } + + static bool parse_csi_final( + const char* data, + size_t size, + size_t start, + size_t* end, + std::string* params, + char* final_byte) { + if (start + 2 > size || static_cast(data[start]) != 0x1B || data[start + 1] != '[') { + return false; + } + size_t pos = start + 2; + while (pos < size && !is_csi_final(static_cast(data[pos]))) { + pos++; + } + if (pos >= size) { + return false; + } + if (params) { + params->assign(data + start + 2, pos - start - 2); + } + if (final_byte) { + *final_byte = data[pos]; + } + *end = pos + 1; + return true; + } + + static std::string normalize_host_terminal_bytes_impl( + const char* data, + size_t size, + bool& dec_restore_pending_clear, + bool& swallow_dec_clear, + bool& swallow_dec_clear_cr) { + std::string out; + out.reserve(size + 32); + for (size_t i = 0; i < size; i++) { + if (swallow_dec_clear_cr) { + swallow_dec_clear_cr = false; + if (data[i] == '\r') { + continue; + } + } + if (static_cast(data[i]) == 0x1B && i + 1 < size) { + if (data[i + 1] == '7') { + size_t restore = i + 2; + while (restore + 1 < size) { + if (static_cast(data[restore]) == 0x1B && data[restore + 1] == '8') { + break; + } + restore++; + } + if (restore + 1 < size) { + size_t csi_end = restore + 2; + std::string params; + char final_byte = '\0'; + bool has_clear = parse_csi_final(data, size, csi_end, &csi_end, ¶ms, &final_byte) && + final_byte == 'K' && + (params.empty() || params == "0"); + bool simple_payload = !has_control_byte(data, i + 2, restore); + bool progress_payload = looks_like_progress_payload(data, i + 2, restore); + if ((has_clear || progress_payload) && simple_payload) { + size_t payload_end = trim_right_spaces(data, i + 2, restore); + size_t next = restore + 2; + if (has_clear) { + next = csi_end; + if (next < size && data[next] == '\r') { + next++; + } + } else { + swallow_dec_clear = true; + swallow_dec_clear_cr = false; + } + // BusyBox apk redraws progress as: + // ESC 7 + padded line + ESC 8 + CSI 0 K + CR + // + // ConPTY/VS Code can leave the visible cursor/artifact at the + // padded right edge of that frame. Since the frame is just a + // single-line redraw, translate it to the terminal primitive + // QEMU's stdio path effectively relies on: carriage-return, + // clear the current line, print the payload, and park the + // cursor back at column 1. We trim the padding because the + // clear-line already removes stale bytes. + out.append("\x1b[?25l\r\x1b[2K", 11); + out.append(data + i + 2, payload_end - (i + 2)); + out.append("\r\x1b[?25h", 7); + dec_restore_pending_clear = false; + i = next - 1; + continue; + } + } + } + if (data[i + 1] == '8') { + dec_restore_pending_clear = true; + out.push_back(data[i]); + out.push_back(data[i + 1]); + i++; + continue; + } + if (data[i + 1] == '[') { + size_t end = 0; + std::string params; + char final_byte = '\0'; + if (parse_csi_final(data, size, i, &end, ¶ms, &final_byte)) { + if (swallow_dec_clear && final_byte == 'K' && (params.empty() || params == "0")) { + swallow_dec_clear = false; + swallow_dec_clear_cr = true; + i = end - 1; + continue; + } + swallow_dec_clear = false; + out.append(data + i, end - i); + if (dec_restore_pending_clear && final_byte == 'K' && (params.empty() || params == "0")) { + out.push_back('\r'); + dec_restore_pending_clear = false; + } else if (final_byte != 'K') { + dec_restore_pending_clear = false; + } + i = end - 1; + continue; + } + } + } else if (data[i] != ' ' && data[i] != '\t') { + dec_restore_pending_clear = false; + swallow_dec_clear = false; + } + out.push_back(data[i]); + } + return out; + } + + std::string normalize_host_terminal_bytes(const char* data, size_t size) { + return normalize_host_terminal_bytes_impl( + data, + size, + dec_restore_pending_clear_, + swallow_dec_clear_, + swallow_dec_clear_cr_); + } + + void flush_locked(std::unique_lock& lock) { + std::string bytes; + bytes.swap(pending_); + lock.unlock(); + write_file(bytes.data(), bytes.size()); + lock.lock(); + cv_.notify_all(); + } + + void writer_loop() { + for (;;) { + std::string bytes; + { + std::unique_lock lock(mu_); + cv_.wait(lock, [&]() { return stopping_ || !pending_.empty(); }); + if (pending_.empty() && stopping_) { + break; + } + auto now = std::chrono::steady_clock::now(); + auto deadline = now + kCoalesceDelay; + auto redraw_deadline = now + kRedrawFrameDelay; + size_t observed_pending_size = pending_.size(); + size_t safe = terminal_safe_prefix(pending_); + bool open_redraw = has_open_dec_redraw_frame(pending_); + while (!stopping_ && pending_.size() < kFlushThreshold && (safe == 0 || open_redraw)) { + const auto wait_deadline = open_redraw ? redraw_deadline : deadline; + if (cv_.wait_until(lock, wait_deadline) == std::cv_status::timeout) { + if (open_redraw && !stopping_ && pending_.size() < kFlushThreshold) { + cv_.wait(lock, [&]() { + return stopping_ || + pending_.size() != observed_pending_size || + pending_.size() >= kFlushThreshold; + }); + if (pending_.size() != observed_pending_size) { + observed_pending_size = pending_.size(); + now = std::chrono::steady_clock::now(); + deadline = now + kCoalesceDelay; + redraw_deadline = now + kRedrawFrameDelay; + } + safe = terminal_safe_prefix(pending_); + open_redraw = has_open_dec_redraw_frame(pending_); + continue; + } + break; + } + if (pending_.size() != observed_pending_size) { + observed_pending_size = pending_.size(); + now = std::chrono::steady_clock::now(); + deadline = now + kCoalesceDelay; + redraw_deadline = now + kRedrawFrameDelay; + } + safe = terminal_safe_prefix(pending_); + open_redraw = has_open_dec_redraw_frame(pending_); + } + if (safe == 0 || open_redraw || pending_.size() >= kFlushThreshold || stopping_) { + safe = pending_.size(); + } + if (safe == pending_.size()) { + bytes.swap(pending_); + } else { + bytes.assign(pending_.data(), safe); + pending_.erase(0, safe); + } + cv_.notify_all(); + } + write_file(bytes.data(), bytes.size()); + } + } + + void write_file(const char* data, size_t size) { + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + if (out == INVALID_HANDLE_VALUE || out == nullptr || data == nullptr || size == 0) { + return; + } + std::lock_guard lock(file_mu_); + // Mirrors QEMU's chardev/char-win-stdio.c win_stdio_chr_write loop: + // ALWAYS loop until either every byte is consumed or WriteFile actually + // fails. Our previous version broke on `written == 0` (success but zero + // bytes), which silently dropped the tail of the buffer in some VT-mode + // race conditions. The console can return success with zero bytes when + // the VT engine is mid-parse on an ANSI sequence; QEMU's behavior of + // re-trying eventually drains the buffer. + std::string normalized = normalize_host_terminal_bytes(data, size); + data = normalized.data(); + size = normalized.size(); + size_t remaining = size; + while (remaining > 0) { + DWORD chunk = static_cast(std::min(remaining, 64 * 1024)); + DWORD written = 0; + if (WriteFile(out, data, chunk, &written, nullptr) == 0) { + break; // hard failure: bail (no recovery) + } + if (written == 0) { + // Zero-byte success means the console transiently rejected our + // write. Yield once so the VT processor can drain, then retry. + std::this_thread::yield(); + continue; + } + data += written; + remaining -= written; + } + } + + bool enabled_{false}; + bool output_is_console_{false}; + bool changed_output_cp_{false}; + bool stopping_{false}; + UINT old_output_cp_{0}; + std::mutex mu_; + std::condition_variable cv_; + std::string pending_; + std::mutex file_mu_; + std::thread writer_; + bool dec_restore_pending_clear_{false}; + bool swallow_dec_clear_{false}; + bool swallow_dec_clear_cr_{false}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/acpi_pm_timer.cc b/native/whp/devices/acpi_pm_timer.cc new file mode 100644 index 0000000..be517e5 --- /dev/null +++ b/native/whp/devices/acpi_pm_timer.cc @@ -0,0 +1,24 @@ +#include "acpi_pm_timer.h" + +namespace node_vmm::whp { + +uint32_t AcpiPmTimer::read(uint16_t port, uint8_t size) const { + if (port < kPort || port >= kPort + 4 || size == 0 || size > 4) { + return 0; + } + uint8_t shift = static_cast((port - kPort) * 8); + return static_cast(counter() >> shift); +} + +uint32_t AcpiPmTimer::counter() const { + auto elapsed = std::chrono::duration_cast( + Clock::now() - started_at_) + .count(); + if (elapsed <= 0) { + return 0; + } + uint64_t ticks = (static_cast(elapsed) * kPmTimerHz) / 1000000000ULL; + return static_cast(ticks); +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/acpi_pm_timer.h b/native/whp/devices/acpi_pm_timer.h new file mode 100644 index 0000000..46e2c53 --- /dev/null +++ b/native/whp/devices/acpi_pm_timer.h @@ -0,0 +1,35 @@ +#pragma once + +// 24/32-bit ACPI Power-Management timer at I/O port 0x408 (kAcpiPmTimerPort +// in backend.cc). Free-running counter at 3.579545 MHz (the canonical PM +// timer frequency). The Linux acpi_pm clocksource reads this counter to +// derive deltas; our value just has to advance monotonically at roughly +// the right rate. No state to write; the timer is read-only by design. + +#include +#include + +namespace node_vmm::whp { + +class AcpiPmTimer { + public: + // Canonical ACPI PM timer I/O port. Mirrors kAcpiPmTimerPort in the FADT + // produced by acpi.cc; other modules can use this constant directly to + // avoid stringly-typing the port number. + static constexpr uint16_t kPort = 0x408; + + // Reads `size` bytes from `port`, where port ∈ [kPort, kPort+4). Returns + // the partial counter value shifted to align with the byte offset within + // the 32-bit register. Out-of-range reads return 0. + uint32_t read(uint16_t port, uint8_t size) const; + + private: + using Clock = std::chrono::steady_clock; + static constexpr uint64_t kPmTimerHz = 3579545; + + uint32_t counter() const; + + Clock::time_point started_at_{Clock::now()}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/cmos.cc b/native/whp/devices/cmos.cc new file mode 100644 index 0000000..7734a76 --- /dev/null +++ b/native/whp/devices/cmos.cc @@ -0,0 +1,62 @@ +#include "cmos.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +namespace node_vmm::whp { + +Cmos::Cmos() { + regs_[0x0A] = 0x26; // 32.768 kHz divider, 1024 Hz periodic, UIP=0. + regs_[0x0B] = 0x02; // 24-hour mode, BCD numeric format. + regs_[0x0D] = 0x80; // VRT (battery valid). + regs_[0x0F] = 0x00; // Shutdown status: clean. + refresh_time(); +} + +uint8_t Cmos::read_port(uint16_t port) { + if (port == 0x71) { + refresh_time(); + return regs_[selected_ & 0x7F]; + } + return 0xFF; +} + +void Cmos::write_port(uint16_t port, uint8_t value) { + if (port == 0x70) { + selected_ = value; + return; + } + if (port == 0x71) { + uint8_t reg = selected_ & 0x7F; + // Only allow writes to status/shutdown registers; date fields are + // intentionally read-only so the guest can't set our virtual clock. + if (reg == 0x0A || reg == 0x0B || reg == 0x0C || reg == 0x0F) { + regs_[reg] = value; + } + } +} + +uint8_t Cmos::to_bcd(uint16_t value) { + return static_cast(((value / 10) << 4) | (value % 10)); +} + +uint8_t Cmos::encode_time_value(uint16_t value) const { + return (regs_[0x0B] & 0x04) != 0 ? static_cast(value) : to_bcd(value); +} + +void Cmos::refresh_time() { + SYSTEMTIME now{}; + GetSystemTime(&now); + regs_[0x00] = encode_time_value(now.wSecond); + regs_[0x02] = encode_time_value(now.wMinute); + regs_[0x04] = encode_time_value(now.wHour); + regs_[0x06] = encode_time_value(now.wDayOfWeek + 1); // RTC: Sunday=1. + regs_[0x07] = encode_time_value(now.wDay); + regs_[0x08] = encode_time_value(now.wMonth); + regs_[0x09] = encode_time_value(now.wYear % 100); + regs_[0x32] = encode_time_value(now.wYear / 100); +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/cmos.h b/native/whp/devices/cmos.h new file mode 100644 index 0000000..cbca874 --- /dev/null +++ b/native/whp/devices/cmos.h @@ -0,0 +1,33 @@ +#pragma once + +// Minimal CMOS/RTC at I/O ports 0x70 (index) and 0x71 (data). Linux probes +// status register A for the UIP (update-in-progress) bit during boot, and +// the rtc-cmos driver later reads dates and the shutdown-status register; +// without a CMOS responder, those reads return 0xFF on real hardware-style +// I/O and can mislead the kernel. We answer with stable defaults that look +// like a valid battery-backed clock in UTC, 24-hour mode, no UIP, clean +// shutdown. Date fields refresh from the host's UTC clock on every read so +// hwclock(8) reports something sensible. + +#include + +namespace node_vmm::whp { + +class Cmos { + public: + Cmos(); + + uint8_t read_port(uint16_t port); + void write_port(uint16_t port, uint8_t value); + + private: + static uint8_t to_bcd(uint16_t value); + + uint8_t encode_time_value(uint16_t value) const; + void refresh_time(); + + uint8_t selected_{0}; + uint8_t regs_[0x80]{}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/hpet.cc b/native/whp/devices/hpet.cc new file mode 100644 index 0000000..546a4a7 --- /dev/null +++ b/native/whp/devices/hpet.cc @@ -0,0 +1,296 @@ +#include "hpet.h" + +#include +#include + +namespace node_vmm::whp { + +using node_vmm::common::DepositBits; +using node_vmm::common::ReadLe; +using node_vmm::common::WriteLe; + +Hpet::Hpet() { + for (auto& timer : timers_) { + timer.cmp = UINT64_MAX; + timer.cmp64 = UINT64_MAX; + timer.config = kTimerPeriodicCap | kTimerSizeCap | (uint64_t(kIntCap) << kTimerIntRouteCapShift); + } +} + +void Hpet::read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + std::memset(data, 0, len); + if (!valid_access(addr, len)) { + return; + } + uint64_t off = addr - kBase; + uint32_t shift = static_cast((off & 4) * 8); + uint64_t value = read_reg(off & ~uint64_t(4)); + WriteLe(data, len, value >> shift); +} + +void Hpet::write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + if (!valid_access(addr, len)) { + return; + } + uint64_t off = addr - kBase; + uint32_t shift = static_cast((off & 4) * 8); + uint32_t bits = std::min(len * 8, 64 - shift); + write_reg(off & ~uint64_t(4), shift, bits, ReadLe(data, len)); +} + +void Hpet::attach_irq_line(std::function irq_line) { + irq_line_ = std::move(irq_line); +} + +std::vector Hpet::poll_expired() { + std::vector irqs; + if ((config_ & kCfgEnable) == 0) { + return irqs; + } + uint64_t now = counter(); + for (size_t i = 0; i < timers_.size(); i++) { + Timer& timer = timers_[i]; + if (!timer.armed || (timer.config & kTimerEnable) == 0) { + continue; + } + if (!counter_reached(now, timer.cmp64)) { + continue; + } + Irq irq = route_for_timer(i); + if (timer.config & kTimerTypeLevel) { + isr_ |= uint64_t(1) << i; + asserted_irqs_[i] = irq; + } + irqs.push_back(irq); + if ((timer.config & kTimerPeriodic) != 0 && timer.period != 0) { + do { + timer.cmp64 += timer.period; + } while (counter_reached(now, timer.cmp64)); + timer.cmp = (timer.config & kTimer32Bit) ? uint32_t(timer.cmp64) : timer.cmp64; + } else { + timer.armed = false; + } + } + return irqs; +} + +bool Hpet::legacy_mode() const { return (config_ & kCfgLegacy) != 0; } + +bool Hpet::valid_access(uint64_t addr, uint32_t len) { + return len != 0 && len <= 8 && addr >= kBase && addr < kBase + kSize && + len <= kBase + kSize - addr; +} + +bool Hpet::counter_reached(uint64_t now, uint64_t target) { + return static_cast(target - now) <= 0; +} + +uint64_t Hpet::counter() const { + if ((config_ & kCfgEnable) == 0) { + return counter_base_; + } + auto elapsed = std::chrono::duration_cast( + Clock::now() - counter_started_at_) + .count(); + uint64_t ticks = elapsed <= 0 ? 0 : static_cast(elapsed) / kClockPeriodNs; + return counter_base_ + ticks; +} + +uint64_t Hpet::read_reg(uint64_t off) const { + if (off <= 0xFF) { + switch (off) { + case 0x000: + return kCapabilities; + case 0x010: + return config_; + case 0x020: + return isr_; + case 0x0F0: + return counter(); + default: + return 0; + } + } + if (off < 0x100) { + return 0; + } + size_t timer_id = static_cast((off - 0x100) / 0x20); + if (timer_id >= timers_.size()) { + return 0; + } + const Timer& timer = timers_[timer_id]; + switch (off & 0x18) { + case 0x00: + return timer.config; + case 0x08: + return timer.cmp; + case 0x10: + return 0; + default: + return 0; + } +} + +void Hpet::write_reg(uint64_t off, uint32_t shift, uint32_t bits, uint64_t value) { + if (off <= 0xFF) { + switch (off) { + case 0x010: + write_config(shift, bits, value); + break; + case 0x020: { + uint64_t clear = DepositBits(0, shift, bits, value); + uint64_t cleared = isr_ & clear; + isr_ &= ~clear; + deassert_cleared_irqs(cleared); + break; + } + case 0x0F0: + if ((config_ & kCfgEnable) == 0) { + counter_base_ = DepositBits(counter_base_, shift, bits, value); + } + break; + default: + break; + } + return; + } + if (off < 0x100) { + return; + } + size_t timer_id = static_cast((off - 0x100) / 0x20); + if (timer_id >= timers_.size()) { + return; + } + switch (off & 0x18) { + case 0x00: + write_timer_config(timer_id, shift, bits, value); + break; + case 0x08: + write_timer_cmp(timer_id, shift, bits, value); + break; + case 0x10: + // FSB/MSI routing is intentionally not exposed (FSB cap stays clear), + // so Linux should keep routing through the IOAPIC. + break; + default: + break; + } +} + +void Hpet::write_config(uint32_t shift, uint32_t bits, uint64_t value) { + uint64_t old = config_; + uint64_t next = DepositBits(old, shift, bits, value); + next = (next & kCfgWriteMask) | (old & ~kCfgWriteMask); + bool was_enabled = (old & kCfgEnable) != 0; + bool will_enable = (next & kCfgEnable) != 0; + if (was_enabled && !will_enable) { + counter_base_ = counter(); + } + config_ = next; + if (!was_enabled && will_enable) { + counter_started_at_ = Clock::now(); + } + if (will_enable) { + reprogram_timers(); + } else { + for (auto& timer : timers_) { + timer.armed = false; + } + uint64_t cleared = isr_; + isr_ = 0; + deassert_cleared_irqs(cleared); + } +} + +void Hpet::write_timer_config(size_t timer_id, uint32_t shift, uint32_t bits, uint64_t value) { + Timer& timer = timers_[timer_id]; + uint64_t old = timer.config; + uint64_t next = DepositBits(old, shift, bits, value); + next = (next & kTimerCfgWriteMask) | (old & ~kTimerCfgWriteMask); + bool clear_level_irq = + (old & kTimerTypeLevel) != 0 && + ((next & kTimerTypeLevel) == 0 || (next & kTimerEnable) == 0); + if (clear_level_irq) { + uint64_t clear = uint64_t(1) << timer_id; + uint64_t cleared = isr_ & clear; + isr_ &= ~clear; + deassert_cleared_irqs(cleared); + } + timer.config = next; + if (timer.config & kTimer32Bit) { + timer.cmp = uint32_t(timer.cmp); + timer.period = uint32_t(timer.period); + } + set_timer(timer_id); +} + +void Hpet::write_timer_cmp(size_t timer_id, uint32_t shift, uint32_t bits, uint64_t value) { + Timer& timer = timers_[timer_id]; + if (timer.config & kTimer32Bit) { + if (shift != 0) { + return; + } + bits = 64; + value = uint32_t(value); + } + if ((timer.config & kTimerPeriodic) == 0 || (timer.config & kTimerSetVal) != 0) { + timer.cmp = DepositBits(timer.cmp, shift, bits, value); + } + if (timer.config & kTimerPeriodic) { + timer.period = DepositBits(timer.period, shift, bits, value); + } + timer.config &= ~kTimerSetVal; + set_timer(timer_id); +} + +void Hpet::reprogram_timers() { + for (size_t i = 0; i < timers_.size(); i++) { + set_timer(i); + } +} + +void Hpet::set_timer(size_t timer_id) { + Timer& timer = timers_[timer_id]; + timer.armed = false; + if ((config_ & kCfgEnable) == 0 || (timer.config & kTimerEnable) == 0) { + return; + } + uint64_t now = counter(); + if (timer.config & kTimer32Bit) { + timer.cmp64 = (now & ~0xFFFFFFFFULL) | uint32_t(timer.cmp); + if (static_cast(timer.cmp64 - now) < 0) { + timer.cmp64 += 0x100000000ULL; + } + } else { + timer.cmp64 = timer.cmp; + } + timer.armed = true; +} + +Hpet::Irq Hpet::route_for_timer(size_t timer_id) const { + bool level = (timers_[timer_id].config & kTimerTypeLevel) != 0; + if (timer_id <= 1 && (config_ & kCfgLegacy) != 0) { + if (timer_id == 0) { + return Irq{kTimer0IoApicPin, 0, level}; + } + return Irq{8, 8, level}; + } + uint32_t route = static_cast( + (timers_[timer_id].config & kTimerIntRouteMask) >> kTimerIntRouteShift); + return Irq{route, 0xFF, level}; +} + +void Hpet::deassert_cleared_irqs(uint64_t cleared) { + for (size_t i = 0; i < timers_.size(); i++) { + if ((cleared & (uint64_t(1) << i)) == 0) { + continue; + } + Irq irq = asserted_irqs_[i].value_or(route_for_timer(i)); + asserted_irqs_[i].reset(); + if (irq_line_ && irq.level && irq.ioapic_pin < 24) { + irq_line_(irq.ioapic_pin, false); + } + } +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/hpet.h b/native/whp/devices/hpet.h new file mode 100644 index 0000000..28533ef --- /dev/null +++ b/native/whp/devices/hpet.h @@ -0,0 +1,113 @@ +#pragma once + +// Minimal HPET (High Precision Event Timer) at MMIO 0xFED00000. Provides +// the registers and timers Linux's hpet_clocksource_register / hpet_enable +// actually exercises: +// +// * Capabilities register (0x000), Configuration (0x010), ISR (0x020), +// Main Counter (0x0F0). +// * 3 timers (the minimum the spec allows). Each supports periodic mode, +// 32-bit / 64-bit operation, level/edge selection, and a programmable +// IOAPIC route. FSB/MSI is intentionally not exposed (FSB cap stays +// clear) so Linux always routes through the IOAPIC. +// * Legacy mode: when bit 1 of CFG is set, timer 0 routes to IOAPIC pin +// `kTimer0IoApicPin` (2 — same as the PIT) and timer 1 to pin 8. +// +// `attach_irq_line` is the up-call back into the dispatcher: a single +// `(uint32_t pin, bool level)` callback that level-de-asserts when the +// guest writes the matching ISR bit (qemu/hw/timer/hpet.c semantics). + +#include "../../common/bytes.h" + +#include +#include +#include +#include +#include +#include + +namespace node_vmm::whp { + +class Hpet { + public: + struct Irq { + uint32_t ioapic_pin{0}; + uint8_t pic_irq{0}; + bool level{false}; + }; + + // MMIO base + length. Exported for the dispatcher and ACPI table builders. + static constexpr uint64_t kBase = 0xFED00000ULL; + static constexpr uint64_t kSize = 0x400ULL; + + Hpet(); + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len); + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len); + + void attach_irq_line(std::function irq_line); + + std::vector poll_expired(); + + bool legacy_mode() const; + + private: + using Clock = std::chrono::steady_clock; + static constexpr uint64_t kClockPeriodNs = 10; + static constexpr uint64_t kClockPeriodFs = kClockPeriodNs * 1000000ULL; + static constexpr size_t kNumTimers = 3; + static constexpr uint32_t kTimer0IoApicPin = 2; + static constexpr uint32_t kIntCap = 1U << kTimer0IoApicPin; + static constexpr uint64_t kCapabilities = + 0x8086A001ULL | + ((uint64_t(kNumTimers) - 1) << 8) | + (kClockPeriodFs << 32); + + static constexpr uint64_t kCfgEnable = 0x001; + static constexpr uint64_t kCfgLegacy = 0x002; + static constexpr uint64_t kCfgWriteMask = 0x003; + + static constexpr uint64_t kTimerTypeLevel = 0x002; + static constexpr uint64_t kTimerEnable = 0x004; + static constexpr uint64_t kTimerPeriodic = 0x008; + static constexpr uint64_t kTimerPeriodicCap = 0x010; + static constexpr uint64_t kTimerSizeCap = 0x020; + static constexpr uint64_t kTimerSetVal = 0x040; + static constexpr uint64_t kTimer32Bit = 0x100; + static constexpr uint64_t kTimerIntRouteMask = 0x3E00; + static constexpr uint64_t kTimerCfgWriteMask = 0x7F4E; + static constexpr uint32_t kTimerIntRouteShift = 9; + static constexpr uint32_t kTimerIntRouteCapShift = 32; + + struct Timer { + uint64_t config{0}; + uint64_t cmp{UINT64_MAX}; + uint64_t cmp64{UINT64_MAX}; + uint64_t period{0}; + bool armed{false}; + }; + + static bool valid_access(uint64_t addr, uint32_t len); + static bool counter_reached(uint64_t now, uint64_t target); + + uint64_t counter() const; + uint64_t read_reg(uint64_t off) const; + void write_reg(uint64_t off, uint32_t shift, uint32_t bits, uint64_t value); + void write_config(uint32_t shift, uint32_t bits, uint64_t value); + void write_timer_config(size_t timer_id, uint32_t shift, uint32_t bits, uint64_t value); + void write_timer_cmp(size_t timer_id, uint32_t shift, uint32_t bits, uint64_t value); + void reprogram_timers(); + void set_timer(size_t timer_id); + Irq route_for_timer(size_t timer_id) const; + void deassert_cleared_irqs(uint64_t cleared); + + std::array timers_{}; + std::array, kNumTimers> asserted_irqs_{}; + uint64_t config_{0}; + uint64_t isr_{0}; + uint64_t counter_base_{0}; + Clock::time_point counter_started_at_{Clock::now()}; + std::function irq_line_; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/pic.cc b/native/whp/devices/pic.cc new file mode 100644 index 0000000..be51d78 --- /dev/null +++ b/native/whp/devices/pic.cc @@ -0,0 +1,102 @@ +#include "pic.h" + +#include "../irq.h" + +#include +#include + +namespace node_vmm::whp { + +Pic::Pic(WhpApi& api, WHV_PARTITION_HANDLE partition) + : api_(api), partition_(partition) {} + +uint8_t Pic::read_port(uint16_t port) const { + if (port == 0x21) { + return master_.mask; + } + if (port == 0xA1) { + return slave_.mask; + } + return 0; +} + +void Pic::write_port(uint16_t port, uint8_t value) { + switch (port) { + case 0x20: + write_command(master_, value); + break; + case 0x21: + write_data(master_, value); + break; + case 0xA0: + write_command(slave_, value); + break; + case 0xA1: + write_data(slave_, value); + break; + default: + break; + } +} + +bool Pic::request_irq(uint8_t irq) { + if (irq < 8) { + if ((master_.mask & (uint8_t(1) << irq)) != 0) { + return false; + } + return RequestFixedInterrupt(api_, partition_, master_.vector + irq); + } + return false; +} + +bool Pic::is_initialized() const { return master_.vector != 0x20; } + +bool Pic::irq_unmasked(uint8_t irq) const { + return irq < 8 && (master_.mask & (uint8_t(1) << irq)) == 0; +} + +uint32_t Pic::vector_for_irq(uint8_t irq) const { + if (irq < 8) { + return master_.vector + irq; + } + return 0x20 + irq; +} + +void Pic::write_command(Controller& controller, uint8_t value) { + if ((value & 0x10) != 0) { + controller.init_step = 2; + controller.mask = 0xFF; + } + // EOI and other OCW commands are acknowledged by this minimal PIC. +} + +void Pic::write_data(Controller& controller, uint8_t value) { + if (controller.init_step == 2) { + controller.vector = value; + controller.init_step = 3; + char env_check[8] = {0}; + GetEnvironmentVariableA("NODE_VMM_BOOT_TIME", env_check, sizeof(env_check)); + if (env_check[0] == '1') { + std::fprintf(stderr, "[node-vmm pic] %s vector base programmed to 0x%02x\n", + &controller == &master_ ? "master" : "slave", value); + } + return; + } + if (controller.init_step == 3) { + controller.init_step = 4; + return; + } + if (controller.init_step == 4) { + controller.init_step = 0; + return; + } + controller.mask = value; + char env_check[8] = {0}; + GetEnvironmentVariableA("NODE_VMM_BOOT_TIME", env_check, sizeof(env_check)); + if (env_check[0] == '1') { + std::fprintf(stderr, "[node-vmm pic] %s mask=0x%02x\n", + &controller == &master_ ? "master" : "slave", value); + } +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/pic.h b/native/whp/devices/pic.h new file mode 100644 index 0000000..7da805a --- /dev/null +++ b/native/whp/devices/pic.h @@ -0,0 +1,54 @@ +#pragma once + +// Minimal 8259A PIC pair (master + slave). Mirrors qemu/hw/intc/i8259.c +// just enough to satisfy Linux's init_8259A: ICW1-4 reprogramming sequence +// with mask + vector base, EOI/OCW2 acknowledged silently, and an explicit +// `is_initialized()` predicate the dispatcher uses to gate ExtInt delivery +// until the kernel has actually remapped the vector base off 0x20. +// +// `request_irq` short-circuits the IOAPIC and goes through +// WHvRequestInterrupt (irq.h::RequestFixedInterrupt) on the master line. +// Slave-line IRQs (8..15) are not routed through this path. + +#include "../api.h" + +#include + +namespace node_vmm::whp { + +class Pic { + public: + Pic(WhpApi& api, WHV_PARTITION_HANDLE partition); + + uint8_t read_port(uint16_t port) const; + void write_port(uint16_t port, uint8_t value); + + bool request_irq(uint8_t irq); + + // True once the kernel has written ICW2 (vector base) to the PIC. The + // master starts with vector base 0x20 (BIOS reset value); Linux remaps + // it to 0x30 in init_8259A, so a non-0x20 value confirms the kernel has + // taken ownership and ExtInts are safe to deliver. + bool is_initialized() const; + + bool irq_unmasked(uint8_t irq) const; + + uint32_t vector_for_irq(uint8_t irq) const; + + private: + struct Controller { + uint8_t vector{0x20}; + uint8_t mask{0xFF}; + uint8_t init_step{0}; + }; + + void write_command(Controller& controller, uint8_t value); + void write_data(Controller& controller, uint8_t value); + + WhpApi& api_; + WHV_PARTITION_HANDLE partition_{nullptr}; + Controller master_{0x20, 0xFE, 0}; + Controller slave_{0x28, 0xFF, 0}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/pit.cc b/native/whp/devices/pit.cc new file mode 100644 index 0000000..a28760d --- /dev/null +++ b/native/whp/devices/pit.cc @@ -0,0 +1,182 @@ +#include "pit.h" + +#include + +namespace node_vmm::whp { + +Pit::Pit() { + auto now = Clock::now(); + for (auto& channel : channels_) { + channel.start = now; + } + schedule_next_irq(now); +} + +uint8_t Pit::read_port(uint16_t port) { + if (port < 0x40 || port > 0x43) { + return 0; + } + if (port == 0x43) { + return 0; + } + return read_channel(channels_[port - 0x40]); +} + +void Pit::write_port(uint16_t port, uint8_t value) { + if (port == 0x43) { + write_command(value); + return; + } + if (port >= 0x40 && port <= 0x42) { + write_channel(static_cast(port - 0x40), value); + } +} + +bool Pit::poll_irq0() { + auto now = Clock::now(); + Channel& channel = channels_[0]; + if (!channel.irq_enabled || now < channel.next_irq) { + return false; + } + auto interval = irq_interval(channel); + do { + channel.next_irq += interval; + } while (now >= channel.next_irq); + return true; +} + +bool Pit::channel2_out_high() { + if (!channel2_gated_) { + return false; + } + const Channel& channel = channels_[2]; + auto elapsed = std::chrono::duration_cast( + Clock::now() - channel.start) + .count(); + if (elapsed <= 0) { + return false; + } + uint64_t ticks = (static_cast(elapsed) * kPitHz) / 1000000000ULL; + return ticks >= divisor(channel); +} + +void Pit::set_channel2_gate(bool gated) { + if (gated && !channel2_gated_) { + channels_[2].start = Clock::now(); + } + channel2_gated_ = gated; +} + +bool Pit::channel2_gated() const { return channel2_gated_; } + +uint32_t Pit::divisor(const Channel& channel) { + return channel.reload == 0 ? 65536U : channel.reload; +} + +std::chrono::nanoseconds Pit::irq_interval(const Channel& channel) { + uint64_t div = divisor(channel); + uint64_t nanos = std::max((div * 1000000000ULL) / kPitHz, 1000000ULL); + return std::chrono::nanoseconds(nanos); +} + +uint16_t Pit::current_count(const Channel& channel) const { + // Mirrors QEMU's pit_get_count (qemu/hw/timer/i8254.c:53-76): for modes + // 0/1/4/5 the counter is `(count - d) & 0xffff`; for the rate generator + // and square-wave modes (2/3) it wraps around the count value. + auto elapsed = std::chrono::duration_cast( + Clock::now() - channel.start) + .count(); + uint64_t ticks = elapsed <= 0 + ? 0 + : (static_cast(elapsed) * kPitHz) / 1000000000ULL; + uint32_t count = divisor(channel); + uint32_t counter; + if (channel.mode == 2 || channel.mode == 3) { + counter = count - static_cast(ticks % count); + } else { + counter = (count - static_cast(ticks)) & 0xffff; + } + return static_cast(counter & 0xFFFF); +} + +void Pit::schedule_next_irq(Clock::time_point now) { + channels_[0].next_irq = now + irq_interval(channels_[0]); +} + +void Pit::reset_channel_timer(size_t index) { + auto now = Clock::now(); + channels_[index].start = now; + if (index == 0) { + channels_[index].irq_enabled = true; + schedule_next_irq(now); + } +} + +void Pit::write_command(uint8_t value) { + if ((value & 0xC0) == 0xC0) { + return; + } + size_t index = (value >> 6) & 0x03; + if (index >= channels_.size()) { + return; + } + Channel& channel = channels_[index]; + uint8_t access = (value >> 4) & 0x03; + if (access == 0) { + channel.latch = current_count(channel); + channel.latch_valid = true; + channel.read_phase = 0; + return; + } + channel.access = access; + channel.mode = (value >> 1) & 0x07; + if (channel.mode > 5) { + channel.mode -= 4; + } + channel.write_phase = 0; + channel.read_phase = 0; + channel.latch_valid = false; +} + +void Pit::write_channel(size_t index, uint8_t value) { + Channel& channel = channels_[index]; + if (channel.access == 1) { + channel.reload = static_cast((channel.reload & 0xFF00) | value); + reset_channel_timer(index); + return; + } + if (channel.access == 2) { + channel.reload = static_cast((channel.reload & 0x00FF) | (uint16_t(value) << 8)); + reset_channel_timer(index); + return; + } + if (channel.write_phase == 0) { + channel.reload = static_cast((channel.reload & 0xFF00) | value); + channel.write_phase = 1; + return; + } + channel.reload = static_cast((channel.reload & 0x00FF) | (uint16_t(value) << 8)); + channel.write_phase = 0; + reset_channel_timer(index); +} + +uint8_t Pit::read_channel(Channel& channel) { + uint16_t value = channel.latch_valid ? channel.latch : current_count(channel); + if (channel.access == 1) { + channel.latch_valid = false; + return static_cast(value & 0xFF); + } + if (channel.access == 2) { + channel.latch_valid = false; + return static_cast((value >> 8) & 0xFF); + } + if (channel.read_phase == 0) { + channel.read_phase = 1; + return static_cast(value & 0xFF); + } + channel.read_phase = 0; + channel.latch_valid = false; + return static_cast((value >> 8) & 0xFF); +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/pit.h b/native/whp/devices/pit.h new file mode 100644 index 0000000..f7c117c --- /dev/null +++ b/native/whp/devices/pit.h @@ -0,0 +1,71 @@ +#pragma once + +// Minimal Intel 8254 Programmable Interval Timer. Mirrors the channels + +// modes that Linux's pit_hpet_ptimer_calibrate_cpu actually exercises: +// +// * Channel 0: rate-generator at the kernel-programmed reload, used to +// drive timer IRQ0 (kPitIoApicPin in backend.cc) once HPET legacy mode +// and IOAPIC routing kick in. poll_irq0() returns true when the +// scheduler thread should fire IRQ0 (idempotent against the next-irq +// timestamp; coalesces missed ticks). +// * Channel 2: gated by port 0x61 bit 0; OUT pin reflected via port 0x61 +// bit 5 so the kernel's TSC calibration loop converges. We approximate +// mode 0 (interrupt-on-terminal-count) using elapsed wall time. +// +// The class holds no resources beyond std::array; safe to +// stack-allocate per-VM. No locks: the dispatcher serializes access via +// device_mu in backend.cc. + +#include +#include +#include + +namespace node_vmm::whp { + +class Pit { + public: + Pit(); + + uint8_t read_port(uint16_t port); + void write_port(uint16_t port, uint8_t value); + + bool poll_irq0(); + + // True when channel 2's OUT line is asserted, used by the kernel through + // port 0x61 bit 5 for TSC/PIT calibration loops. + bool channel2_out_high(); + + void set_channel2_gate(bool gated); + bool channel2_gated() const; + + private: + using Clock = std::chrono::steady_clock; + static constexpr uint64_t kPitHz = 1193182; + + struct Channel { + uint16_t reload{0}; + uint16_t latch{0}; + bool latch_valid{false}; + uint8_t access{3}; + uint8_t mode{3}; + uint8_t write_phase{0}; + uint8_t read_phase{0}; + Clock::time_point start{}; + Clock::time_point next_irq{}; + bool irq_enabled{true}; + }; + + static uint32_t divisor(const Channel& channel); + static std::chrono::nanoseconds irq_interval(const Channel& channel); + uint16_t current_count(const Channel& channel) const; + void schedule_next_irq(Clock::time_point now); + void reset_channel_timer(size_t index); + void write_command(uint8_t value); + void write_channel(size_t index, uint8_t value); + uint8_t read_channel(Channel& channel); + + std::array channels_{}; + bool channel2_gated_{false}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/devices/uart.cc b/native/whp/devices/uart.cc new file mode 100644 index 0000000..6b36c02 --- /dev/null +++ b/native/whp/devices/uart.cc @@ -0,0 +1,529 @@ +#include "uart.h" + +#include +#include +#include +#include +#include +#include + +namespace node_vmm::whp { +namespace { + +bool EqualsAsciiNoCase(const char* left, const char* right) { + if (left == nullptr || right == nullptr) { + return false; + } + while (*left != '\0' && *right != '\0') { + unsigned char a = static_cast(*left++); + unsigned char b = static_cast(*right++); + if (std::tolower(a) != std::tolower(b)) { + return false; + } + } + return *left == '\0' && *right == '\0'; +} + +} // namespace + +Uart::Uart(size_t limit, bool echo_stdout) + : limit_(limit), echo_stdout_(echo_stdout), stdout_writer_(echo_stdout) { + set_terminal_query_mode_from_env(); +} + +void Uart::attach_irq_raiser(std::function raiser) { + irq_raiser_ = std::move(raiser); +} + +void Uart::enable_rx_debug() { rx_dbg_ = true; } + +uint8_t Uart::read(uint16_t offset) { + std::lock_guard lock(mu_); + bool dlab = (lcr_ & 0x80) != 0; + switch (offset) { + case 0: + if (dlab) { + return dll_; + } + { + uint8_t value = 0; + if (fifo_enabled_locked()) { + if (!rx_fifo_.empty()) { + value = rx_fifo_.front(); + rx_fifo_.pop_front(); + } + timeout_ipending_ = false; + drain_rx_staging_locked(); + if (rx_fifo_.empty()) { + lsr_ = static_cast(lsr_ & ~kLsrDr); + } + } else { + if (lsr_ & kLsrDr) { + value = rbr_; + lsr_ = static_cast(lsr_ & ~kLsrDr); + } + timeout_ipending_ = false; + drain_rx_staging_locked(); + } + update_interrupt_locked(); + return value; + } + case 1: + return dlab ? dlh_ : ier_; + case 2: + { + uint8_t value = static_cast(iir_fifo_bits_locked() | iir_); + if ((iir_ & 0x0F) == 0x02) { + thr_ipending_ = false; + update_interrupt_locked(); + } + return value; + } + case 3: + return lcr_; + case 4: + return mcr_; + case 5: + { + uint8_t result = lsr_; + static std::atomic lsr_reads{0}; + uint64_t n = ++lsr_reads; + size_t rx_size = fifo_enabled_locked() + ? rx_fifo_.size() + : ((lsr_ & kLsrDr) ? 1 : 0); + if (rx_dbg_ && (n <= 25 || n % 50 == 0 || rx_size != 0)) { + std::fprintf(stderr, "[node-vmm uart] LSR read #%llu = 0x%02x rx_size=%zu\n", + static_cast(n), result, rx_size); + } + if (lsr_ & (kLsrOe | kLsrPe | kLsrFe | kLsrBi)) { + lsr_ = static_cast(lsr_ & ~(kLsrOe | kLsrPe | kLsrFe | kLsrBi)); + update_interrupt_locked(); + } + return result; + } + case 6: + { + uint8_t value = current_msr_locked(); + msr_ &= 0xF0; + update_interrupt_locked(); + return value; + } + case 7: + return scr_; + default: + return 0; + } +} + +void Uart::write(uint16_t offset, uint8_t value) { + switch (offset) { + case 0: + { + bool dlab = false; + { + std::lock_guard lock(mu_); + dlab = (lcr_ & 0x80) != 0; + if (dlab) { + dll_ = value; + } + } + if (!dlab) { + handle_tx_byte(value); + } + break; + } + case 1: + { + std::lock_guard lock(mu_); + if (lcr_ & 0x80) { + dlh_ = value; + } else { + uint8_t old_ier = ier_; + if (rx_dbg_) { + std::fprintf(stderr, "[node-vmm uart] IER write old=0x%02x new=0x%02x\n", ier_, value); + } + ier_ = value & 0x0F; + if (((old_ier ^ ier_) & kIerThri) != 0) { + thr_ipending_ = ((ier_ & kIerThri) != 0) && ((lsr_ & kLsrThre) != 0); + } + update_interrupt_locked(); + } + break; + } + case 2: + { + std::lock_guard lock(mu_); + if (rx_dbg_) { + std::fprintf(stderr, "[node-vmm uart] FCR write 0x%02x\n", value); + } + fcr_ = static_cast(value & (kFcrEnable | kFcrTriggerMask)); + if ((value & kFcrClearRx) != 0) { + clear_rx_locked(); + } else { + drain_rx_staging_locked(); + } + if ((value & kFcrClearTx) != 0) { + clear_tx_locked(); + } + update_interrupt_locked(); + break; + } + case 3: + { + std::lock_guard lock(mu_); + uint8_t old_lcr = lcr_; + lcr_ = value; + if ((value & 0x40) != 0 && (old_lcr & 0x40) == 0) { + lsr_ = static_cast(lsr_ | kLsrBi | kLsrDr); + rbr_ = 0; + } + update_interrupt_locked(); + break; + } + case 4: + if (rx_dbg_) { + std::fprintf(stderr, "[node-vmm uart] MCR write 0x%02x (out2=%d)\n", value, (value >> 3) & 1); + } + { + std::lock_guard lock(mu_); + uint8_t old_msr = current_msr_locked(); + mcr_ = value & 0x1F; + update_msr_delta_locked(old_msr, current_msr_locked()); + update_interrupt_locked(); + } + break; + case 7: + { + std::lock_guard lock(mu_); + scr_ = value; + } + break; + default: + break; + } +} + +std::string Uart::console() const { + std::lock_guard lock(mu_); + return console_; +} + +bool Uart::contains(const std::string& needle) const { + std::lock_guard lock(mu_); + if (needle == "reboot: System halted") { + return halted_seen_; + } + if (needle == "Restarting system") { + return restarting_seen_; + } + return console_.find(needle) != std::string::npos; +} + +void Uart::emit_bytes(const uint8_t* data, size_t len) { + std::lock_guard lock(mu_); + emit_tx_locked(std::string(reinterpret_cast(data), len)); +} + +size_t Uart::enqueue_rx(const uint8_t* data, size_t len) { + std::lock_guard lock(mu_); + return enqueue_rx_locked(reinterpret_cast(data), len); +} + +void Uart::emit_tx_locked(const std::string& bytes) { + if (bytes.empty()) { + return; + } + track_console_markers(bytes); + size_t available = limit_ > console_.size() ? limit_ - console_.size() : 0; + if (available > 0) { + console_.append(bytes.data(), std::min(available, bytes.size())); + } + if (echo_stdout_) { + write_stdout(bytes); + } +} + +// Windows console treats a bare LF as "move down, keep column" when the +// guest line discipline has not converted LF to CRLF. Normalize bare LF +// here, but preserve CRLF that the guest already sent. The previous +// unconditional CR insertion fixed one progress-bar symptom while creating +// doubled carriage returns that confused ConPTY/VS Code redraw. +std::string Uart::NormalizeCrlf(const std::string& bytes, char& last_byte) { + std::string normalized; + normalized.reserve(bytes.size() * 2); + for (char byte : bytes) { + if (byte == '\n' && last_byte != '\r') { + normalized.push_back('\r'); + } + normalized.push_back(byte); + last_byte = byte; + } + return normalized; +} + +void Uart::write_stdout(const std::string& bytes) { + if (bytes.empty()) { + return; + } + std::string normalized = NormalizeCrlf(bytes, last_stdout_byte_); + stdout_writer_.write(normalized); +} + +size_t Uart::enqueue_rx_locked(const char* data, size_t len, bool front) { + if (data == nullptr || len == 0) { + return 0; + } + + auto queued = [&]() -> size_t { + size_t n = rx_staging_.size(); + if (fifo_enabled_locked()) { + n += rx_fifo_.size(); + } else if (lsr_ & kLsrDr) { + n += 1; + } + return n; + }; + + size_t accepted = 0; + if (front) { + for (size_t i = len; i > 0 && queued() < kRxCapacity; i--) { + rx_staging_.push_front(static_cast(data[i - 1])); + accepted++; + } + } else { + for (size_t i = 0; i < len && queued() < kRxCapacity; i++) { + rx_staging_.push_back(static_cast(data[i])); + accepted++; + } + } + if (accepted < len) { + lsr_ = static_cast(lsr_ | kLsrOe); + } + drain_rx_staging_locked(); + update_interrupt_locked(); + return accepted; +} + +void Uart::handle_tx_byte(uint8_t value) { + static const std::string query = "\x1b[6n"; + std::lock_guard lock(mu_); + + thr_ = value; + lsr_ = static_cast(lsr_ & ~(kLsrThre | kLsrTemt)); + + if (mcr_ & 0x10) { + transmit_byte_locked(value); + update_interrupt_locked(); + return; + } + + if (!terminal_query_.empty() || value == 0x1B) { + terminal_query_.push_back(static_cast(value)); + if (query.rfind(terminal_query_, 0) == 0) { + if (terminal_query_ == query) { + if (dsr_passthrough_ && stdout_writer_.can_passthrough_terminal_query()) { + emit_tx_locked(terminal_query_); + } else { + std::string response = stdout_writer_.cursor_position_report(); + enqueue_rx_locked(response.data(), response.size(), false); + } + terminal_query_.clear(); + } + lsr_ = static_cast(lsr_ | kLsrThre | kLsrTemt); + thr_ipending_ = true; + update_interrupt_locked(); + return; + } + emit_tx_locked(terminal_query_); + terminal_query_.clear(); + lsr_ = static_cast(lsr_ | kLsrThre | kLsrTemt); + thr_ipending_ = true; + update_interrupt_locked(); + return; + } + + transmit_byte_locked(value); + update_interrupt_locked(); +} + +bool Uart::fifo_enabled_locked() const { + return (fcr_ & kFcrEnable) != 0; +} + +size_t Uart::rx_trigger_level_locked() const { + switch (fcr_ & kFcrTriggerMask) { + case 0x00: + return 1; + case 0x40: + return 4; + case 0x80: + return 8; + case 0xC0: + return 14; + default: + return 1; + } +} + +void Uart::drain_rx_staging_locked() { + if (fifo_enabled_locked()) { + while (!rx_staging_.empty() && rx_fifo_.size() < kFifoSize) { + rx_fifo_.push_back(rx_staging_.front()); + rx_staging_.pop_front(); + } + if (rx_fifo_.empty()) { + lsr_ = static_cast(lsr_ & ~kLsrDr); + timeout_ipending_ = false; + } else { + lsr_ = static_cast(lsr_ | kLsrDr); + timeout_ipending_ = rx_fifo_.size() < rx_trigger_level_locked(); + } + return; + } + + if ((lsr_ & kLsrDr) == 0 && !rx_staging_.empty()) { + rbr_ = rx_staging_.front(); + rx_staging_.pop_front(); + lsr_ = static_cast(lsr_ | kLsrDr); + } + timeout_ipending_ = false; +} + +bool Uart::rx_ready_for_interrupt_locked() const { + if (fifo_enabled_locked()) { + return !rx_fifo_.empty() && rx_fifo_.size() >= rx_trigger_level_locked(); + } + return (lsr_ & kLsrDr) != 0; +} + +uint8_t Uart::iir_fifo_bits_locked() const { + return fifo_enabled_locked() ? 0xC0 : 0x00; +} + +void Uart::clear_rx_locked() { + rx_fifo_.clear(); + rx_staging_.clear(); + rbr_ = 0; + timeout_ipending_ = false; + lsr_ = static_cast(lsr_ & ~(kLsrDr | kLsrOe | kLsrPe | kLsrFe | kLsrBi)); +} + +void Uart::clear_tx_locked() { + tx_fifo_.clear(); + thr_ = 0; + tsr_ = 0; + terminal_query_.clear(); + lsr_ = static_cast(lsr_ | kLsrThre | kLsrTemt); + thr_ipending_ = true; +} + +void Uart::transmit_byte_locked(uint8_t value) { + if (fifo_enabled_locked()) { + if (tx_fifo_.size() < kFifoSize) { + tx_fifo_.push_back(value); + } + while (!tx_fifo_.empty()) { + tsr_ = tx_fifo_.front(); + tx_fifo_.pop_front(); + if (mcr_ & 0x10) { + enqueue_rx_locked(reinterpret_cast(&tsr_), 1); + } else { + char byte = static_cast(tsr_); + emit_tx_locked(std::string(&byte, 1)); + } + } + } else { + tsr_ = value; + if (mcr_ & 0x10) { + enqueue_rx_locked(reinterpret_cast(&tsr_), 1); + } else { + char byte = static_cast(tsr_); + emit_tx_locked(std::string(&byte, 1)); + } + } + lsr_ = static_cast(lsr_ | kLsrThre | kLsrTemt); + thr_ipending_ = true; +} + +void Uart::set_terminal_query_mode_from_env() { + const char* mode = std::getenv("NODE_VMM_WHP_DSR"); + dsr_passthrough_ = EqualsAsciiNoCase(mode, "passthrough"); +} + +size_t Uart::AdvanceMatch(const char* pattern, size_t pattern_len, size_t current, char byte) { + while (current > 0 && byte != pattern[current]) { + current--; + } + if (byte == pattern[current]) { + current++; + } + if (current == pattern_len) { + return pattern_len; + } + return current; +} + +void Uart::track_console_markers(const std::string& bytes) { + static constexpr char kHalted[] = "reboot: System halted"; + static constexpr char kRestarting[] = "Restarting system"; + for (char byte : bytes) { + if (!halted_seen_) { + halted_match_ = AdvanceMatch(kHalted, sizeof(kHalted) - 1, halted_match_, byte); + halted_seen_ = halted_match_ == sizeof(kHalted) - 1; + } + if (!restarting_seen_) { + restarting_match_ = AdvanceMatch(kRestarting, sizeof(kRestarting) - 1, restarting_match_, byte); + restarting_seen_ = restarting_match_ == sizeof(kRestarting) - 1; + } + } +} + +uint8_t Uart::current_msr_locked() const { + if (mcr_ & 0x10) { + uint8_t loop = msr_ & 0x0F; + if (mcr_ & 0x02) loop |= 0x10; // RTS -> CTS + if (mcr_ & 0x01) loop |= 0x20; // DTR -> DSR + if (mcr_ & 0x04) loop |= 0x40; // OUT1 -> RI + if (mcr_ & 0x08) loop |= 0x80; // OUT2 -> DCD + return loop; + } + return msr_; +} + +void Uart::update_msr_delta_locked(uint8_t old_msr, uint8_t new_msr) { + uint8_t old_status = old_msr & 0xF0; + uint8_t new_status = new_msr & 0xF0; + uint8_t delta = 0; + if ((old_status ^ new_status) & 0x10) delta |= 0x01; // CTS + if ((old_status ^ new_status) & 0x20) delta |= 0x02; // DSR + if ((old_status & 0x40) && !(new_status & 0x40)) delta |= 0x04; // RI trailing edge + if ((old_status ^ new_status) & 0x80) delta |= 0x08; // DCD + msr_ = static_cast((new_msr & 0xF0) | ((msr_ | delta) & 0x0F)); +} + +void Uart::update_interrupt_locked() { + bool raise = false; + if ((ier_ & kIerRlsi) && (lsr_ & (kLsrOe | kLsrPe | kLsrFe | kLsrBi))) { + iir_ = 0x06; + raise = true; + } else if ((ier_ & kIerRdi) && timeout_ipending_) { + iir_ = 0x0C; + raise = true; + } else if ((ier_ & kIerRdi) && rx_ready_for_interrupt_locked()) { + iir_ = 0x04; + raise = true; + } else if ((ier_ & kIerThri) && thr_ipending_ && (lsr_ & kLsrThre)) { + iir_ = 0x02; + raise = true; + } else if ((ier_ & kIerMsi) && (current_msr_locked() & 0x0F)) { + iir_ = 0x00; + raise = true; + } else { + iir_ = 0x01; + } + if (raise && irq_raiser_) { + irq_raiser_(4); + } +} + +} // namespace node_vmm::whp diff --git a/native/whp/devices/uart.h b/native/whp/devices/uart.h new file mode 100644 index 0000000..a23230a --- /dev/null +++ b/native/whp/devices/uart.h @@ -0,0 +1,149 @@ +#pragma once + +// 16550-style UART covering the registers Linux's serial8250 driver +// actually reads and writes during boot and runtime: THR/RBR, IER, +// IIR/FCR, LCR, MCR, LSR, MSR, SCR, plus the DLAB-gated divisor latch +// (DLL/DLH). +// +// Two host-facing IO paths: +// * write_stdout - TX bytes to host stdout via ConsoleWriter. +// * enqueue_rx - host stdin / paste buffer to the guest RX FIFO with +// overrun + IRQ4 dispatch. +// +// IoApic decoupling: instead of holding an IoApic* and a fallback +// irq_router_, we accept a single std::function at +// attach_irq_raiser. Caller binds the IOAPIC-then-PIC routing logic. +// Mirrors the virtio extraction pattern from PR-5a. + +#include "../console_writer.h" + +#include +#include +#include +#include +#include +#include + +namespace node_vmm::whp { + +class Uart { + public: + explicit Uart(size_t limit, bool echo_stdout = true); + + // Wires the UART's IRQ4 raise path. Caller decides whether to route + // through the IOAPIC, PIC, or both. Must be called before any vCPU + // starts; called only from RunVm after the IRQ controllers are + // constructed. Idempotent: replacing an existing raiser is supported. + void attach_irq_raiser(std::function raiser); + + // Enables verbose RX/IRQ debug output via stderr. Off by default; + // RunVm flips it on when NODE_VMM_BOOT_TIME=1. + void enable_rx_debug(); + + uint8_t read(uint16_t offset); + void write(uint16_t offset, uint8_t value); + + std::string console() const; + bool contains(const std::string& needle) const; + + // Direct TX byte injection used by the paravirt console port (0x600). + // Bypasses the LCR/THR-empty interrupt path; goes straight to the + // console buffer + host stdout. + void emit_bytes(const uint8_t* data, size_t len); + + // Push host bytes into the guest UART RX queue. Mirrors QEMU's + // serial_receive. Returns the count actually accepted; caller can retry + // any tail bytes instead of dropping them. + size_t enqueue_rx(const uint8_t* data, size_t len); + + // Pure CRLF normalizer used by write_stdout. Exposed for unit tests + // so we can assert the contract without spinning up a partition. + // last_byte is updated in-place to reflect the last byte appended, + // letting callers chain calls and preserve the no-double-CR property. + static std::string NormalizeCrlf(const std::string& bytes, char& last_byte); + + private: + static constexpr size_t kRxCapacity = 4096; + static constexpr size_t kFifoSize = 16; + + static constexpr uint8_t kIerRdi = 0x01; + static constexpr uint8_t kIerThri = 0x02; + static constexpr uint8_t kIerRlsi = 0x04; + static constexpr uint8_t kIerMsi = 0x08; + + static constexpr uint8_t kFcrEnable = 0x01; + static constexpr uint8_t kFcrClearRx = 0x02; + static constexpr uint8_t kFcrClearTx = 0x04; + static constexpr uint8_t kFcrTriggerMask = 0xC0; + + static constexpr uint8_t kLsrDr = 0x01; + static constexpr uint8_t kLsrOe = 0x02; + static constexpr uint8_t kLsrPe = 0x04; + static constexpr uint8_t kLsrFe = 0x08; + static constexpr uint8_t kLsrBi = 0x10; + static constexpr uint8_t kLsrThre = 0x20; + static constexpr uint8_t kLsrTemt = 0x40; + + // Forwarded from emit_bytes / handle_tx_byte. Must be called with mu_ + // held. + void emit_tx_locked(const std::string& bytes); + // CRLF-normalizing write to host stdout. Why this is here: see the + // long comment in the .cc - it keeps bare LF output sane on Windows. + void write_stdout(const std::string& bytes); + + size_t enqueue_rx_locked(const char* data, size_t len, bool front = false); + void handle_tx_byte(uint8_t value); + bool fifo_enabled_locked() const; + size_t rx_trigger_level_locked() const; + void drain_rx_staging_locked(); + bool rx_ready_for_interrupt_locked() const; + uint8_t iir_fifo_bits_locked() const; + void clear_rx_locked(); + void clear_tx_locked(); + void transmit_byte_locked(uint8_t value); + void set_terminal_query_mode_from_env(); + + static size_t AdvanceMatch(const char* pattern, size_t pattern_len, size_t current, char byte); + void track_console_markers(const std::string& bytes); + uint8_t current_msr_locked() const; + void update_msr_delta_locked(uint8_t old_msr, uint8_t new_msr); + void update_interrupt_locked(); + + mutable std::mutex mu_; + uint8_t ier_{0}; + uint8_t iir_{0x01}; + uint8_t fcr_{0}; + uint8_t lcr_{0}; + uint8_t mcr_{0x08}; + uint8_t msr_{0xB0}; + uint8_t lsr_{static_cast(kLsrThre | kLsrTemt)}; + uint8_t scr_{0}; + uint8_t dll_{0x0C}; + uint8_t dlh_{0}; + uint8_t rbr_{0}; + uint8_t thr_{0}; + uint8_t tsr_{0}; + std::deque rx_fifo_; + std::deque rx_staging_; + std::deque tx_fifo_; + bool thr_ipending_{false}; + bool timeout_ipending_{false}; + size_t limit_{1024 * 1024}; + bool echo_stdout_{true}; + ConsoleWriter stdout_writer_; + std::string console_; + std::string terminal_query_; + // Tracks the last byte written to host stdout so we can synthesize CR + // before bare LF without doubling up when the guest already sent CRLF. + // See write_stdout() for the rationale. + char last_stdout_byte_{0}; + bool dsr_passthrough_{false}; + bool halted_seen_{false}; + bool restarting_seen_{false}; + size_t halted_match_{0}; + size_t restarting_match_{0}; + std::function irq_raiser_; + bool rx_dbg_{false}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/elf_loader.cc b/native/whp/elf_loader.cc new file mode 100644 index 0000000..5a402a5 --- /dev/null +++ b/native/whp/elf_loader.cc @@ -0,0 +1,123 @@ +#include "elf_loader.h" + +#include "../common/bytes.h" + +#include +#include +#include +#include +#include + +namespace node_vmm::whp { + +using node_vmm::common::Check; +using node_vmm::common::CheckedAdd; +using node_vmm::common::CheckedMul; +using node_vmm::common::CheckRange; +using node_vmm::common::WindowsErrorMessage; + +namespace { + +// ELF64 layout constants. Mirrors the values originally inlined into +// native/whp/backend.cc; kept TU-local so we don't grow whp_common.h yet. +constexpr unsigned char kElfMagic[] = {0x7f, 'E', 'L', 'F'}; +constexpr uint16_t kElfClass64 = 2; +constexpr uint16_t kElfDataLe = 1; +constexpr uint16_t kElfMachineX64 = 62; +constexpr uint32_t kElfPtLoad = 1; + +#pragma pack(push, 1) +struct Elf64Ehdr { + unsigned char ident[16]; + uint16_t type; + uint16_t machine; + uint32_t version; + uint64_t entry; + uint64_t phoff; + uint64_t shoff; + uint32_t flags; + uint16_t ehsize; + uint16_t phentsize; + uint16_t phnum; + uint16_t shentsize; + uint16_t shnum; + uint16_t shstrndx; +}; + +struct Elf64Phdr { + uint32_t type; + uint32_t flags; + uint64_t offset; + uint64_t vaddr; + uint64_t paddr; + uint64_t filesz; + uint64_t memsz; + uint64_t align; +}; +#pragma pack(pop) + +std::vector ReadWholeFile(const std::string& path) { + HANDLE file = CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + Check(file != INVALID_HANDLE_VALUE, "open " + path + " failed: " + WindowsErrorMessage(GetLastError())); + LARGE_INTEGER size{}; + if (!GetFileSizeEx(file, &size)) { + DWORD error = GetLastError(); + CloseHandle(file); + throw std::runtime_error("stat " + path + " failed: " + WindowsErrorMessage(error)); + } + Check(size.QuadPart >= 0 && size.QuadPart <= INT32_MAX, "file is too large: " + path); + std::vector data(static_cast(size.QuadPart)); + size_t done = 0; + while (done < data.size()) { + DWORD got = 0; + DWORD want = static_cast(std::min(data.size() - done, 1U << 20)); + if (!ReadFile(file, data.data() + done, want, &got, nullptr)) { + DWORD error = GetLastError(); + CloseHandle(file); + throw std::runtime_error("read " + path + " failed: " + WindowsErrorMessage(error)); + } + Check(got != 0, "short read " + path); + done += got; + } + CloseHandle(file); + return data; +} + +} // namespace + +KernelInfo LoadElfKernel(uint8_t* mem, uint64_t mem_size, const std::string& path) { + std::vector data = ReadWholeFile(path); + Check(data.size() >= sizeof(Elf64Ehdr), "kernel is too small"); + const auto* eh = reinterpret_cast(data.data()); + Check(std::memcmp(eh->ident, kElfMagic, sizeof(kElfMagic)) == 0, "kernel must be an ELF vmlinux for WHP v1"); + Check(eh->ident[4] == kElfClass64, "kernel must be ELF64"); + Check(eh->ident[5] == kElfDataLe, "kernel must be little-endian ELF"); + Check(eh->machine == kElfMachineX64, "kernel must be x86_64 ELF"); + Check(eh->phentsize == sizeof(Elf64Phdr), "kernel ELF program header size is unsupported"); + uint64_t ph_size = CheckedMul(uint64_t(eh->phnum), sizeof(Elf64Phdr), "kernel program header table"); + CheckRange(data.size(), eh->phoff, ph_size, "kernel program header table"); + + KernelInfo info{}; + info.entry = eh->entry; + for (uint16_t i = 0; i < eh->phnum; i++) { + uint64_t ph_off = CheckedAdd(eh->phoff, CheckedMul(uint64_t(i), sizeof(Elf64Phdr), "kernel program header offset"), + "kernel program header offset"); + const auto* ph = reinterpret_cast(data.data() + ph_off); + if (ph->type != kElfPtLoad) { + continue; + } + Check(ph->filesz <= ph->memsz, "kernel segment file size exceeds memory size"); + CheckRange(data.size(), ph->offset, ph->filesz, "kernel segment file range"); + CheckRange(mem_size, ph->paddr, ph->memsz, "kernel segment guest range"); + std::memcpy(mem + ph->paddr, data.data() + ph->offset, static_cast(ph->filesz)); + if (ph->memsz > ph->filesz) { + uint64_t zero_start = CheckedAdd(ph->paddr, ph->filesz, "kernel zero-fill range"); + std::memset(mem + zero_start, 0, static_cast(ph->memsz - ph->filesz)); + } + info.kernel_end = std::max(info.kernel_end, CheckedAdd(ph->paddr, ph->memsz, "kernel end")); + } + Check(info.entry != 0, "kernel ELF entrypoint is zero"); + return info; +} + +} // namespace node_vmm::whp diff --git a/native/whp/elf_loader.h b/native/whp/elf_loader.h new file mode 100644 index 0000000..dc9b4b1 --- /dev/null +++ b/native/whp/elf_loader.h @@ -0,0 +1,36 @@ +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include +#include + +namespace node_vmm::whp { + +// Result of loading an ELF64 kernel into guest physical memory. `entry` is +// the kernel ELF entry point (passed to the BSP's RIP); `kernel_end` is the +// highest paddr+memsz across all PT_LOAD segments (used by the boot params +// builder to size the kernel image region). +struct KernelInfo { + uint64_t entry{0}; + uint64_t kernel_end{0}; +}; + +// Loads a vmlinux-style ELF64 kernel from `path` into the guest physical +// memory region `[mem, mem+mem_size)`. Each PT_LOAD segment is copied to +// its `paddr`, with the BSS region zero-filled. Throws std::runtime_error +// on any of: file too small, bad ELF magic, wrong class/endianness/machine, +// out-of-range program header offsets, segments exceeding guest memory. +// +// The returned KernelInfo is consumed by the BSP bootstrap path +// (SetupWhpBootstrapVcpu) to set RIP and by the boot params writer to +// place the cmdline / boot args after the kernel image. +KernelInfo LoadElfKernel(uint8_t* mem, uint64_t mem_size, const std::string& path); + +} // namespace node_vmm::whp diff --git a/native/whp/guest_memory.h b/native/whp/guest_memory.h new file mode 100644 index 0000000..41ff80a --- /dev/null +++ b/native/whp/guest_memory.h @@ -0,0 +1,27 @@ +#pragma once + +// Thin wrapper around the contiguous host buffer backing guest RAM. Used +// by every virtio device + boot-params writer to dereference guest +// physical addresses with bounds checking. No ownership: the buffer is +// managed by the partition's VirtualAllocMemory in backend.cc; this +// struct just gives device code a typed view. + +#include "../common/bytes.h" + +#include + +namespace node_vmm::whp { + +struct GuestMemory { + uint8_t* data{nullptr}; + uint64_t len{0}; + + uint8_t* ptr(uint64_t offset, uint64_t size) const { + node_vmm::common::CheckRange(len, offset, size, "guest memory"); + return data + offset; + } + + uint64_t size() const { return len; } +}; + +} // namespace node_vmm::whp diff --git a/native/whp/irq.cc b/native/whp/irq.cc new file mode 100644 index 0000000..53220b1 --- /dev/null +++ b/native/whp/irq.cc @@ -0,0 +1,170 @@ +#include "irq.h" + +#include +#include + +namespace node_vmm::whp { + +bool RequestFixedInterrupt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + uint32_t vector) { + WHV_INTERRUPT_CONTROL control{}; + control.Type = WHvX64InterruptTypeFixed; + control.DestinationMode = WHvX64InterruptDestinationModePhysical; + control.TriggerMode = WHvX64InterruptTriggerModeEdge; + control.Destination = 0; + control.Vector = vector; + return SUCCEEDED(api.request_interrupt(partition, &control, sizeof(control))); +} + +bool ReadInterruptibility( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + InterruptibilitySnapshot* out) { + WHV_REGISTER_NAME names[3] = { + WHvX64RegisterRflags, + WHvRegisterInterruptState, + WHvRegisterPendingEvent, + }; + WHV_REGISTER_VALUE values[3]{}; + HRESULT hr = api.get_vp_registers(partition, vp_index, names, 3, values); + if (FAILED(hr)) { + return false; + } + out->if_set = (values[0].Reg64 & 0x200ULL) != 0; + out->shadow = values[1].InterruptState.InterruptShadow != 0; + out->event_pending = values[2].ExtIntEvent.EventPending != 0; + return true; +} + +HRESULT SetPendingExtInt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + uint32_t vector) { + WHV_REGISTER_NAME name = WHvRegisterPendingEvent; + WHV_REGISTER_VALUE value{}; + value.ExtIntEvent.EventPending = 1; + value.ExtIntEvent.EventType = WHvX64PendingEventExtInt; + value.ExtIntEvent.Vector = vector & 0xFF; + return api.set_vp_registers(partition, vp_index, &name, 1, &value); +} + +void ArmInterruptWindow( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu) { + if (vcpu.window_registered) { + return; + } + WHV_REGISTER_NAME name = WHvX64RegisterDeliverabilityNotifications; + WHV_REGISTER_VALUE value{}; + value.DeliverabilityNotifications.InterruptNotification = 1; + HRESULT hr = api.set_vp_registers(partition, vcpu.index, &name, 1, &value); + if (SUCCEEDED(hr)) { + vcpu.window_registered = true; + } +} + +void DisarmInterruptWindow( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu) { + if (!vcpu.window_registered) { + return; + } + WHV_REGISTER_NAME name = WHvX64RegisterDeliverabilityNotifications; + WHV_REGISTER_VALUE value{}; + value.DeliverabilityNotifications.InterruptNotification = 0; + (void)api.set_vp_registers(partition, vcpu.index, &name, 1, &value); + vcpu.window_registered = false; +} + +void UpdateVcpuFromExit( + WhpVcpuIrqState& vcpu, + const WHV_RUN_VP_EXIT_CONTEXT& exit_ctx) { + vcpu.interruption_pending = + exit_ctx.VpContext.ExecutionState.InterruptionPending != 0; + vcpu.interruptable = + exit_ctx.VpContext.ExecutionState.InterruptShadow == 0; + vcpu.interrupt_flag = (exit_ctx.VpContext.Rflags & 0x200ULL) != 0; +} + +void KickVcpuOutOfHlt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index) { + WHV_REGISTER_NAME name = WHvRegisterInternalActivityState; + WHV_REGISTER_VALUE value{}; + HRESULT hr = api.get_vp_registers(partition, vp_index, &name, 1, &value); + if (FAILED(hr)) { + return; + } + if (value.InternalActivity.HaltSuspend) { + value.InternalActivity.HaltSuspend = 0; + (void)api.set_vp_registers(partition, vp_index, &name, 1, &value); + } +} + +InjectDecision EvaluateInjectDecision(const WhpVcpuIrqState& vcpu) { + if (!vcpu.ext_int_pending.load()) { + return InjectDecision::kNoPending; + } + bool can_inject = + vcpu.ready_for_pic_interrupt && + vcpu.interruptable && + vcpu.interrupt_flag && + !vcpu.interruption_pending; + return can_inject ? InjectDecision::kInject : InjectDecision::kArmWindow; +} + +bool TryDeliverPendingExtInt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu) { + InjectDecision decision = EvaluateInjectDecision(vcpu); + if (decision == InjectDecision::kNoPending) { + return true; + } + bool can_inject = decision == InjectDecision::kInject; + char env_check[8] = {0}; + GetEnvironmentVariableA("NODE_VMM_BOOT_TIME", env_check, sizeof(env_check)); + bool boot_dbg = env_check[0] == '1'; + if (boot_dbg) { + static std::atomic tries{0}; + uint64_t n = ++tries; + if (n <= 40 || n % 100 == 0) { + std::fprintf(stderr, + "[node-vmm extint] try #%llu cpu=%u vector=0x%02x ready=%d if=%d shadow_ok=%d pending_intr=%d can=%d\n", + (unsigned long long)n, + vcpu.index, + vcpu.ext_int_vector.load() & 0xFF, + (int)vcpu.ready_for_pic_interrupt, + (int)vcpu.interrupt_flag, + (int)vcpu.interruptable, + (int)vcpu.interruption_pending, + (int)can_inject); + } + } + if (can_inject) { + uint32_t vector = vcpu.ext_int_vector.load(); + HRESULT hr = SetPendingExtInt(api, partition, vcpu.index, vector); + if (SUCCEEDED(hr)) { + if (boot_dbg) { + std::fprintf(stderr, "[node-vmm extint] injected vector=0x%02x cpu=%u\n", + vector & 0xFF, vcpu.index); + } + vcpu.ext_int_pending.store(false); + vcpu.ready_for_pic_interrupt = false; + KickVcpuOutOfHlt(api, partition, vcpu.index); + DisarmInterruptWindow(api, partition, vcpu); + return true; + } + } + ArmInterruptWindow(api, partition, vcpu); + return false; +} + +} // namespace node_vmm::whp diff --git a/native/whp/irq.h b/native/whp/irq.h new file mode 100644 index 0000000..5b3b6c8 --- /dev/null +++ b/native/whp/irq.h @@ -0,0 +1,134 @@ +#pragma once + +// Per-vCPU IRQ-delivery state machine. Mirrors QEMU's whpx-all.c +// pre/post-run dance for kernel-irqchip mode (lines 1525-1679): +// +// 1. Some thread (PIT, UART RX, virtio) raises an external interrupt +// by setting `vcpu.ext_int_pending = true` and writing the vector +// into `vcpu.ext_int_vector`. +// 2. The vCPU thread, before calling WHvRunVirtualProcessor, calls +// `TryDeliverPendingExtInt(api, partition, vcpu)`. If the guest is +// currently in a state to accept an external interrupt +// (interruptable + IF=1 + no event already pending + ready_for_pic_ +// interrupt was set by a previous InterruptWindow exit) the call +// injects WHvRegisterPendingEvent.ExtIntEvent and clears HaltSuspend. +// 3. If not deliverable now, ArmInterruptWindow asks WHP to fire an +// X64InterruptWindow exit on the next interruptable instruction, at +// which point the vCPU thread sets `ready_for_pic_interrupt = true` +// and tries again. +// 4. After every WHP exit the vCPU thread calls UpdateVcpuFromExit to +// refresh `interruptable / interrupt_flag / interruption_pending` +// from the exit context's VpContext snapshot. +// +// All public state is in `WhpVcpuIrqState`; the helper functions are +// stateless apart from what they read/write through the WhpApi handle. + +#include "api.h" + +#include + +namespace node_vmm::whp { + +struct WhpVcpuIrqState { + uint32_t index{0}; + // Refreshed every exit by UpdateVcpuFromExit(): + bool interruptable{true}; // !InterruptShadow (no STI/MOV-SS shadow) + bool interrupt_flag{true}; // RFLAGS.IF + bool interruption_pending{false};// VpContext.ExecutionState.InterruptionPending + // Set by the X64InterruptWindow exit handler, cleared by inject: + bool ready_for_pic_interrupt{false}; + // Tracks whether DeliverabilityNotifications.InterruptNotification=1 + // is currently armed; ArmInterruptWindow is idempotent against this. + bool window_registered{false}; + // Atomics: the producer side (timer thread, UART RX thread) writes + // these without holding device_mu; the consumer (vCPU thread) reads + // them under device_mu before calling TryDeliverPendingExtInt. + std::atomic ext_int_pending{false}; + std::atomic ext_int_vector{0}; +}; + +struct InterruptibilitySnapshot { + bool if_set{false}; + bool shadow{false}; + bool event_pending{false}; +}; + +// Reads RFLAGS.IF / WHvRegisterInterruptState / WHvRegisterPendingEvent +// in one batched WHvGetVirtualProcessorRegisters call. Returns false if +// the API call failed; *out is left untouched in that case. +bool ReadInterruptibility( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + InterruptibilitySnapshot* out); + +// Sets WHvRegisterPendingEvent to a pending ExtInt with `vector` (low +// 8 bits used). Caller is responsible for guaranteeing the guest is in +// a state to accept it (see TryDeliverPendingExtInt). +HRESULT SetPendingExtInt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index, + uint32_t vector); + +// Idempotent. Writes WHvX64RegisterDeliverabilityNotifications +// .InterruptNotification = 1 so WHP fires X64InterruptWindow on the next +// interruptable instruction. No-op if already registered. +void ArmInterruptWindow( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu); + +// Idempotent. Clears the deliverability notification armed by the call +// above. Safe to call from inject paths whether or not the window was +// previously armed. +void DisarmInterruptWindow( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu); + +// Refreshes vcpu.interruptable / interrupt_flag / interruption_pending +// from the WHP exit context. Called from the vCPU loop after every +// WHvRunVirtualProcessor return. +void UpdateVcpuFromExit( + WhpVcpuIrqState& vcpu, + const WHV_RUN_VP_EXIT_CONTEXT& exit_ctx); + +// Clears WHvRegisterInternalActivityState.HaltSuspend so the next +// WHvRunVirtualProcessor resumes guest execution past the HLT +// instruction. Mirrors qemu whpx_vcpu_kick_out_of_hlt (whpx-all.c:1515). +void KickVcpuOutOfHlt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + UINT32 vp_index); + +// Sends a fixed-vector interrupt to BSP via WHvRequestInterrupt. Used by +// Pic::request_irq and IoApic dispatch. Returns true on success. +bool RequestFixedInterrupt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + uint32_t vector); + +// What TryDeliverPendingExtInt would do given a state snapshot. Pure +// decision function exposed for unit tests; the real path uses the same +// logic but also performs WHP calls. +enum class InjectDecision { + kNoPending, // ext_int_pending == false → nothing to do + kInject, // guest is in a state to accept; will inject + disarm window + kArmWindow, // gated; will arm InterruptWindow notification and defer +}; + +// Pure function. Mirrors the if/else chain in TryDeliverPendingExtInt +// without touching WHP. +InjectDecision EvaluateInjectDecision(const WhpVcpuIrqState& vcpu); + +// Top-level inject path. If no ExtInt pending: returns true. If guest is +// deliverable: injects the event, clears pending state, kicks out of HLT, +// disarms the window, returns true. Otherwise: arms the window so we get +// notified on the next interruptable instruction, returns false. +bool TryDeliverPendingExtInt( + WhpApi& api, + WHV_PARTITION_HANDLE partition, + WhpVcpuIrqState& vcpu); + +} // namespace node_vmm::whp diff --git a/native/whp/page_tables.cc b/native/whp/page_tables.cc new file mode 100644 index 0000000..7871f2d --- /dev/null +++ b/native/whp/page_tables.cc @@ -0,0 +1,65 @@ +#include "page_tables.h" + +#include "../common/bytes.h" + +namespace node_vmm::whp { + +using node_vmm::common::WriteU64; + +void BuildPageTables(uint8_t* mem, uint64_t base) { + constexpr uint64_t page_size = 0x1000; + constexpr uint64_t flags = 0x07; // P | RW | US + constexpr uint64_t huge = 0x83; // P | RW | PS (huge page) + uint64_t pml4 = base; + uint64_t pdpt = base + page_size; + uint64_t pd = base + 2 * page_size; + WriteU64(mem + pml4, pdpt | flags); + for (uint64_t i = 0; i < 4; i++) { + uint64_t this_pd = pd + i * page_size; + WriteU64(mem + pdpt + i * 8, this_pd | flags); + for (uint64_t j = 0; j < 512; j++) { + uint64_t phys = (i * 512 + j) * 0x200000ULL; + WriteU64(mem + this_pd + j * 8, phys | huge); + } + } +} + +WHV_X64_SEGMENT_REGISTER Segment(uint16_t selector, uint16_t attributes) { + WHV_X64_SEGMENT_REGISTER segment{}; + segment.Base = 0; + segment.Limit = 0xffff; + segment.Selector = selector; + segment.Attributes = attributes; + return segment; +} + +WHV_X64_SEGMENT_REGISTER LongCodeSegment() { + WHV_X64_SEGMENT_REGISTER segment = Segment(0x08, 0); + segment.Limit = 0xFFFFF; + segment.SegmentType = 11; + segment.NonSystemSegment = 1; + segment.Present = 1; + segment.Long = 1; + segment.Granularity = 1; + return segment; +} + +WHV_X64_SEGMENT_REGISTER LongDataSegment() { + WHV_X64_SEGMENT_REGISTER segment = Segment(0x10, 0); + segment.Limit = 0xFFFFF; + segment.SegmentType = 3; + segment.NonSystemSegment = 1; + segment.Present = 1; + segment.Default = 1; + segment.Granularity = 1; + return segment; +} + +WHV_X64_TABLE_REGISTER Table(uint64_t base, uint16_t limit) { + WHV_X64_TABLE_REGISTER table{}; + table.Base = base; + table.Limit = limit; + return table; +} + +} // namespace node_vmm::whp diff --git a/native/whp/page_tables.h b/native/whp/page_tables.h new file mode 100644 index 0000000..ba10a93 --- /dev/null +++ b/native/whp/page_tables.h @@ -0,0 +1,45 @@ +#pragma once + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include + +namespace node_vmm::whp { + +// Builds a 4-level x86_64 identity-map page table starting at `base` in +// guest physical memory. Covers 4 GiB using 2 MiB huge pages, present + +// writable + huge (PD entry flags 0x83). Layout: +// base + 0x0000 PML4 (one entry -> PDPT) +// base + 0x1000 PDPT (4 entries -> 4 PDs) +// base + 0x2000 PD0 (512 huge-page entries, [0..1 GiB)) +// base + 0x3000 PD1 ([1..2 GiB)) +// base + 0x4000 PD2 ([2..3 GiB)) +// base + 0x5000 PD3 ([3..4 GiB)) +// Caller passes `kPageTableBase` (0x9000) as `base` and uses `base` as +// CR3 for the BSP. +void BuildPageTables(uint8_t* mem, uint64_t base); + +// Helpers used by SetupLongMode to populate WHV_X64_*_REGISTER values. +// `Segment(selector, attributes)` builds a generic segment descriptor with +// base=0/limit=0xffff (used for AP-startup real-mode segments and as the +// scaffolding for the long-mode helpers below). +WHV_X64_SEGMENT_REGISTER Segment(uint16_t selector, uint16_t attributes); + +// Long-mode CS (selector 0x08, type 11 = exec/read code, L=1). +WHV_X64_SEGMENT_REGISTER LongCodeSegment(); + +// Long-mode DS/ES/SS/FS/GS (selector 0x10, type 3 = read/write data, D=1). +WHV_X64_SEGMENT_REGISTER LongDataSegment(); + +// Builds a WHV_X64_TABLE_REGISTER (used for GDT/IDT registers) with the +// given base and limit. +WHV_X64_TABLE_REGISTER Table(uint64_t base, uint16_t limit); + +} // namespace node_vmm::whp diff --git a/native/whp/virtio/blk.cc b/native/whp/virtio/blk.cc new file mode 100644 index 0000000..c2bab87 --- /dev/null +++ b/native/whp/virtio/blk.cc @@ -0,0 +1,396 @@ +#include "blk.h" + +#include "desc.h" +#include "../../common/bytes.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#include +#include +#include + +namespace node_vmm::whp { + +using node_vmm::common::Check; +using node_vmm::common::CheckedAdd; +using node_vmm::common::CheckedMul; +using node_vmm::common::CheckRange; +using node_vmm::common::ReadU16; +using node_vmm::common::ReadU32; +using node_vmm::common::ReadU64; +using node_vmm::common::WindowsErrorMessage; +using node_vmm::common::WriteU16; +using node_vmm::common::WriteU32; +using node_vmm::common::WriteU64; + +namespace v = node_vmm::whp::virtio; + +VirtioBlk::VirtioBlk( + uint64_t mmio_base, + GuestMemory mem, + const std::string& path, + const std::string& overlay_path, + bool read_only, + std::function raise_irq) + : mmio_base_(mmio_base), mem_(mem), raise_irq_(std::move(raise_irq)), read_only_(read_only) { + bool overlay = !overlay_path.empty(); + Check(!(read_only_ && overlay), "read-only disk cannot use an overlay"); + DWORD access = (overlay || read_only_) ? GENERIC_READ : (GENERIC_READ | GENERIC_WRITE); + base_.reset(CreateFileA(path.c_str(), access, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)); + Check(base_.valid(), "open rootfs " + path + " failed: " + WindowsErrorMessage(GetLastError())); + LARGE_INTEGER size{}; + Check(GetFileSizeEx(base_.get(), &size) != 0, "stat rootfs " + path + " failed: " + WindowsErrorMessage(GetLastError())); + Check(size.QuadPart >= 0, "negative rootfs size: " + path); + disk_bytes_ = static_cast(size.QuadPart); + sectors_ = disk_bytes_ / 512; + if (overlay) { + Check(overlay_path != path, "overlayPath must not equal rootfsPath"); + overlay_.reset(CreateFileA( + overlay_path.c_str(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ, + nullptr, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + nullptr)); + Check(overlay_.valid(), "open overlay " + overlay_path + " failed: " + WindowsErrorMessage(GetLastError())); + MarkFileSparse(overlay_.get()); + TruncateFileTo(overlay_.get(), disk_bytes_); + dirty_sectors_.assign(static_cast((sectors_ + 7) / 8), 0); + } + queues_[0].size = v::kMaxQueueSize; +} + +void VirtioBlk::read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + std::memset(data, 0, len); + uint32_t off = static_cast(addr - mmio_base_); + if (off < 0x100) { + if (len != 4) { + return; + } + WriteU32(data, read_reg(off)); + return; + } + uint8_t cfg[16]{}; + WriteU64(cfg, sectors_); + WriteU32(cfg + 8, 0); + WriteU32(cfg + 12, 128); + uint32_t cfg_off = off - 0x100; + if (cfg_off < sizeof(cfg)) { + std::memcpy(data, cfg + cfg_off, std::min(len, sizeof(cfg) - cfg_off)); + } +} + +void VirtioBlk::write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + uint32_t off = static_cast(addr - mmio_base_); + if (off >= 0x100 || len != 4) { + return; + } + write_reg(off, ReadU32(data)); +} + +uint32_t VirtioBlk::read_reg(uint32_t off) { + switch (off) { + case v::kMmioMagicValue: + return v::kMagic; + case v::kMmioVersion: + return v::kVersion; + case v::kMmioDeviceId: + return 2; // virtio-blk + case v::kMmioVendorId: + return v::kVendor; + case v::kMmioDeviceFeatures: { + uint64_t features = v::kFVersion1 | (1ULL << 9); + if (read_only_) { + features |= 1ULL << 5; + } + return dev_features_sel_ == 1 ? uint32_t(features >> 32) : uint32_t(features); + } + case v::kMmioQueueNumMax: + return v::kMaxQueueSize; + case v::kMmioQueueReady: + return queues_[queue_sel_].ready ? 1 : 0; + case v::kMmioInterruptStatus: + return interrupt_status_; + case v::kMmioStatus: + return status_; + case v::kMmioConfigGeneration: + return 0; + default: + return 0; + } +} + +void VirtioBlk::write_reg(uint32_t off, uint32_t value) { + Queue& q = queues_[queue_sel_]; + switch (off) { + case v::kMmioDeviceFeaturesSel: + dev_features_sel_ = value; + break; + case v::kMmioDriverFeaturesSel: + drv_features_sel_ = value; + break; + case v::kMmioDriverFeatures: + if (drv_features_sel_ == 0) { + drv_features_ = (drv_features_ & ~0xFFFFFFFFULL) | value; + } else { + drv_features_ = (drv_features_ & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + } + break; + case v::kMmioQueueSel: + if (value < 8) { + queue_sel_ = value; + } + break; + case v::kMmioQueueNum: + if (value >= 1 && value <= v::kMaxQueueSize) { + q.size = value; + } + break; + case v::kMmioQueueReady: + q.ready = value != 0; + break; + case v::kMmioQueueNotify: + if (value < 8 && queues_[value].ready) { + handle_queue(queues_[value]); + interrupt_status_ |= v::kInterruptVring; + if (raise_irq_) { + raise_irq_(); + } + } + break; + case v::kMmioInterruptAck: + interrupt_status_ &= ~value; + break; + case v::kMmioStatus: + if (value == 0) { + reset(); + } else { + status_ = value; + } + break; + case v::kMmioQueueDescLow: + q.desc_addr = (q.desc_addr & ~0xFFFFFFFFULL) | value; + break; + case v::kMmioQueueDescHigh: + q.desc_addr = (q.desc_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + break; + case v::kMmioQueueDriverLow: + q.driver_addr = (q.driver_addr & ~0xFFFFFFFFULL) | value; + break; + case v::kMmioQueueDriverHigh: + q.driver_addr = (q.driver_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + break; + case v::kMmioQueueDeviceLow: + q.device_addr = (q.device_addr & ~0xFFFFFFFFULL) | value; + break; + case v::kMmioQueueDeviceHigh: + q.device_addr = (q.device_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + break; + default: + break; + } +} + +void VirtioBlk::reset() { + status_ = 0; + drv_features_ = 0; + dev_features_sel_ = 0; + drv_features_sel_ = 0; + queue_sel_ = 0; + interrupt_status_ = 0; + for (auto& q : queues_) { + q = Queue{}; + } +} + +// Walk a descriptor chain through the guest's virtq. +static v::DescChain walk_chain_inline(GuestMemory& mem, uint64_t desc_addr, uint32_t q_size, uint16_t head) { + v::DescChain chain; + std::array seen{}; + uint16_t idx = head; + for (;;) { + Check(idx < q_size, "virtio descriptor next out of bounds"); + Check(seen[idx] == 0, "virtio descriptor cycle"); + seen[idx] = 1; + Check(idx < q_size, "virtio descriptor index out of bounds"); + uint8_t* p = mem.ptr(desc_addr + uint64_t(idx) * 16, 16); + v::Desc d{ReadU64(p), ReadU32(p + 8), ReadU16(p + 12), ReadU16(p + 14)}; + Check((d.flags & v::kRingDescFIndirect) == 0, "virtio indirect descriptors are not supported"); + chain.push(d); + if ((d.flags & v::kRingDescFNext) == 0) { + break; + } + idx = d.next; + } + return chain; +} + +static void push_used_inline(GuestMemory& mem, uint64_t device_addr, uint32_t q_size, uint32_t id, uint32_t written) { + uint8_t* idxp = mem.ptr(device_addr + 2, 2); + uint16_t used = ReadU16(idxp); + uint64_t entry = device_addr + 4 + uint64_t(used % q_size) * 8; + WriteU32(mem.ptr(entry, 8), id); + WriteU32(mem.ptr(entry + 4, 4), written); + WriteU16(idxp, used + 1); +} + +void VirtioBlk::handle_queue(Queue& q) { + uint16_t avail_idx = ReadU16(mem_.ptr(q.driver_addr + 2, 2)); + while (q.last_avail != avail_idx) { + uint16_t head = ReadU16(mem_.ptr(q.driver_addr + 4 + uint64_t(q.last_avail % q.size) * 2, 2)); + q.last_avail++; + process_request(q, head); + } +} + +void VirtioBlk::process_request(Queue& q, uint16_t head) { + uint8_t status = 0; + uint32_t written = 0; + try { + v::DescChain chain = walk_chain_inline(mem_, q.desc_addr, q.size, head); + Check(chain.size >= 2, "virtio-blk descriptor chain too short"); + v::Desc header = chain[0]; + v::Desc status_desc = chain[chain.size - 1]; + Check(header.len >= 16, "virtio-blk header too short"); + Check((status_desc.flags & v::kRingDescFWrite) != 0 && status_desc.len >= 1, + "virtio-blk status descriptor invalid"); + uint8_t hdr[16]; + std::memcpy(hdr, mem_.ptr(header.addr, 16), sizeof(hdr)); + uint32_t type = ReadU32(hdr); + uint64_t sector = ReadU64(hdr + 8); + + if (type == 0) { + for (size_t i = 1; i + 1 < chain.size; i++) { + v::Desc d = chain[i]; + Check((d.flags & v::kRingDescFWrite) != 0, "virtio-blk read data descriptor must be writable"); + Check((d.len % 512) == 0, "virtio-blk read length must be sector-aligned"); + ReadDisk(sector, mem_.ptr(d.addr, d.len), d.len); + written += d.len; + sector += d.len / 512; + } + } else if (type == 1) { + for (size_t i = 1; i + 1 < chain.size; i++) { + v::Desc d = chain[i]; + Check((d.flags & v::kRingDescFWrite) == 0, "virtio-blk write data descriptor must be read-only"); + Check((d.len % 512) == 0, "virtio-blk write length must be sector-aligned"); + WriteDisk(sector, mem_.ptr(d.addr, d.len), d.len); + sector += d.len / 512; + } + } else if (type == 4) { + if (!read_only_) { + Check(FlushFileBuffers(write_handle()) != 0, + "virtio-blk flush failed: " + WindowsErrorMessage(GetLastError())); + } + } else if (type == 8) { + const char id[] = "node-vmm"; + for (size_t i = 1; i + 1 < chain.size; i++) { + v::Desc d = chain[i]; + uint32_t n = std::min(d.len, sizeof(id)); + std::memcpy(mem_.ptr(d.addr, n), id, n); + written += n; + break; + } + } else { + status = 2; + } + std::memcpy(mem_.ptr(status_desc.addr, 1), &status, 1); + push_used_inline(mem_, q.device_addr, q.size, head, written + 1); + } catch (...) { + status = 1; + try { + v::DescChain chain = walk_chain_inline(mem_, q.desc_addr, q.size, head); + if (!chain.empty()) { + v::Desc status_desc = chain[chain.size - 1]; + std::memcpy(mem_.ptr(status_desc.addr, 1), &status, 1); + } + } catch (...) { + } + push_used_inline(mem_, q.device_addr, q.size, head, written); + } +} + +bool VirtioBlk::has_overlay() const { return overlay_.valid(); } + +HANDLE VirtioBlk::write_handle() const { + return has_overlay() ? overlay_.get() : base_.get(); +} + +bool VirtioBlk::sector_dirty(uint64_t sector) const { + if (!has_overlay() || sector >= sectors_) { + return false; + } + return (dirty_sectors_[static_cast(sector / 8)] & (uint8_t(1) << (sector % 8))) != 0; +} + +void VirtioBlk::mark_dirty(uint64_t sector) { + if (!has_overlay() || sector >= sectors_) { + return; + } + dirty_sectors_[static_cast(sector / 8)] |= uint8_t(1) << (sector % 8); +} + +void VirtioBlk::mark_dirty_range(uint64_t byte_off, size_t len) { + if (!has_overlay() || len == 0) { + return; + } + uint64_t first = byte_off / 512; + uint64_t last = (byte_off + len - 1) / 512; + for (uint64_t sector = first; sector <= last; sector++) { + mark_dirty(sector); + } +} + +void VirtioBlk::prepare_partial_overlay_sectors(uint64_t byte_off, size_t len) { + if (!has_overlay() || len == 0) { + return; + } + CheckRange(disk_bytes_, byte_off, len, "virtio-blk overlay prepare"); + uint64_t end = CheckedAdd(byte_off, len, "virtio-blk overlay range"); + uint64_t first = byte_off / 512; + uint64_t last = (end - 1) / 512; + std::array sector_buf{}; + for (uint64_t sector = first; sector <= last; sector++) { + uint64_t sector_start = sector * 512; + bool covers_full_sector = byte_off <= sector_start && end >= sector_start + 512; + if (covers_full_sector || sector_dirty(sector)) { + continue; + } + PreadAll(base_.get(), sector_buf.data(), sector_buf.size(), sector_start); + PwriteAll(overlay_.get(), sector_buf.data(), sector_buf.size(), sector_start); + } +} + +void VirtioBlk::ReadDisk(uint64_t sector, uint8_t* dst, size_t len) { + uint64_t byte_off = CheckedMul(sector, 512, "virtio-blk read offset"); + CheckRange(disk_bytes_, byte_off, len, "virtio-blk read"); + if (!has_overlay()) { + PreadAll(base_.get(), dst, len, byte_off); + return; + } + size_t done = 0; + while (done < len) { + uint64_t current_byte = byte_off + done; + uint64_t current_sector = current_byte / 512; + size_t in_sector = static_cast(current_byte % 512); + size_t chunk = std::min(len - done, size_t(512) - in_sector); + HANDLE file = sector_dirty(current_sector) ? overlay_.get() : base_.get(); + PreadAll(file, dst + done, chunk, current_byte); + done += chunk; + } +} + +void VirtioBlk::WriteDisk(uint64_t sector, const uint8_t* src, size_t len) { + Check(!read_only_, "virtio-blk write to read-only disk"); + uint64_t byte_off = CheckedMul(sector, 512, "virtio-blk write offset"); + CheckRange(disk_bytes_, byte_off, len, "virtio-blk write"); + prepare_partial_overlay_sectors(byte_off, len); + PwriteAll(write_handle(), src, len, byte_off); + mark_dirty_range(byte_off, len); +} + +} // namespace node_vmm::whp diff --git a/native/whp/virtio/blk.h b/native/whp/virtio/blk.h new file mode 100644 index 0000000..bb21098 --- /dev/null +++ b/native/whp/virtio/blk.h @@ -0,0 +1,89 @@ +#pragma once + +// virtio-blk-mmio device. Backs Linux's virtio_blk driver: serves a +// rootfs.ext4 image via a single virtqueue, optionally with a +// copy-on-write overlay so the host filesystem stays clean. Mirrors +// qemu/hw/block/virtio-blk.c with the small subset Linux actually uses +// during boot: +// * VIRTIO_BLK_T_IN (read sectors) +// * VIRTIO_BLK_T_OUT (write sectors) +// * VIRTIO_BLK_T_FLUSH (FlushFileBuffers) +// * VIRTIO_BLK_T_GET_ID (used by udev for stable device naming) +// +// Decoupled from IoApic by accepting a `raise_irq` callback at +// construction. Same pattern as VirtioRng (PR-5a). + +#include "../guest_memory.h" +#include "../win_io.h" + +#include +#include +#include +#include + +namespace node_vmm::whp { + +class VirtioBlk { + public: + // mmio_base: device's MMIO base address (e.g. 0xD0000000). + // mem: guest RAM view used to dereference descriptor addresses. + // path: rootfs.ext4 to serve. + // overlay_path: optional copy-on-write overlay; empty disables. + // raise_irq: invoked when the device wants to signal an interrupt. + VirtioBlk( + uint64_t mmio_base, + GuestMemory mem, + const std::string& path, + const std::string& overlay_path, + bool read_only, + std::function raise_irq); + + uint64_t mmio_base() const { return mmio_base_; } + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len); + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len); + + private: + struct Queue { + uint32_t size{256}; + bool ready{false}; + uint16_t last_avail{0}; + uint64_t desc_addr{0}; + uint64_t driver_addr{0}; + uint64_t device_addr{0}; + }; + + uint32_t read_reg(uint32_t off); + void write_reg(uint32_t off, uint32_t value); + void reset(); + + void handle_queue(Queue& q); + void process_request(Queue& q, uint16_t head); + + bool has_overlay() const; + HANDLE write_handle() const; + bool sector_dirty(uint64_t sector) const; + void mark_dirty(uint64_t sector); + void mark_dirty_range(uint64_t byte_off, size_t len); + void prepare_partial_overlay_sectors(uint64_t byte_off, size_t len); + void ReadDisk(uint64_t sector, uint8_t* dst, size_t len); + void WriteDisk(uint64_t sector, const uint8_t* src, size_t len); + + uint64_t mmio_base_{0}; + GuestMemory mem_; + std::function raise_irq_; + WinHandle base_; + WinHandle overlay_; + std::vector dirty_sectors_; + uint64_t disk_bytes_{0}; + uint64_t sectors_{0}; + bool read_only_{false}; + uint32_t status_{0}; + uint64_t drv_features_{0}; + uint32_t dev_features_sel_{0}; + uint32_t drv_features_sel_{0}; + uint32_t queue_sel_{0}; + uint32_t interrupt_status_{0}; + Queue queues_[8]; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/virtio/desc.h b/native/whp/virtio/desc.h new file mode 100644 index 0000000..3988927 --- /dev/null +++ b/native/whp/virtio/desc.h @@ -0,0 +1,98 @@ +#pragma once + +// Header-only support for virtio-mmio v2 device implementations. Holds +// the descriptor struct, the constants from the spec +// (qemu/include/standard-headers/linux/virtio_mmio.h + virtio_ring.h) we +// actually use, and a tiny helper for descriptor-chain walking. +// +// All virtio devices in this codebase (blk, rng, net) share these +// constants. Centralizing them here means a spec bump only needs to land +// in one place. + +#include "../../common/bytes.h" + +#include +#include + +namespace node_vmm::whp::virtio { + +constexpr uint32_t kMagic = 0x74726976; // "virt" +constexpr uint32_t kVendor = 0x554D4551; // "QEMU" (matches host) +constexpr uint32_t kVersion = 2; +constexpr uint32_t kMaxQueueSize = 256; + +constexpr uint32_t kStatusAck = 0x01; +constexpr uint32_t kStatusDriver = 0x02; +constexpr uint32_t kStatusDriverOk = 0x04; +constexpr uint32_t kStatusFeaturesOk = 0x08; +constexpr uint32_t kStatusFailed = 0x80; + +constexpr uint32_t kRingDescFNext = 1; +constexpr uint32_t kRingDescFWrite = 2; +constexpr uint32_t kRingDescFIndirect = 4; +constexpr uint16_t kRingAvailFNoInterrupt = 1; + +constexpr uint64_t kFVersion1 = 1ULL << 32; +constexpr uint64_t kNetFMac = 1ULL << 5; +constexpr uint64_t kNetFStatus = 1ULL << 16; + +constexpr uint32_t kInterruptVring = 0x1; +constexpr uint32_t kInterruptConfig = 0x2; + +// virtio-mmio register offsets (from virtio_mmio.h). +constexpr uint16_t kMmioMagicValue = 0x000; +constexpr uint16_t kMmioVersion = 0x004; +constexpr uint16_t kMmioDeviceId = 0x008; +constexpr uint16_t kMmioVendorId = 0x00C; +constexpr uint16_t kMmioDeviceFeatures = 0x010; +constexpr uint16_t kMmioDeviceFeaturesSel = 0x014; +constexpr uint16_t kMmioDriverFeatures = 0x020; +constexpr uint16_t kMmioDriverFeaturesSel = 0x024; +constexpr uint16_t kMmioQueueSel = 0x030; +constexpr uint16_t kMmioQueueNumMax = 0x034; +constexpr uint16_t kMmioQueueNum = 0x038; +constexpr uint16_t kMmioQueueReady = 0x044; +constexpr uint16_t kMmioQueueNotify = 0x050; +constexpr uint16_t kMmioInterruptStatus = 0x060; +constexpr uint16_t kMmioInterruptAck = 0x064; +constexpr uint16_t kMmioStatus = 0x070; +constexpr uint16_t kMmioQueueDescLow = 0x080; +constexpr uint16_t kMmioQueueDescHigh = 0x084; +constexpr uint16_t kMmioQueueDriverLow = 0x090; +constexpr uint16_t kMmioQueueDriverHigh = 0x094; +constexpr uint16_t kMmioQueueDeviceLow = 0x0A0; +constexpr uint16_t kMmioQueueDeviceHigh = 0x0A4; +constexpr uint16_t kMmioShmSel = 0x0AC; +constexpr uint16_t kMmioShmLenLow = 0x0B0; +constexpr uint16_t kMmioShmLenHigh = 0x0B4; +constexpr uint16_t kMmioShmBaseLow = 0x0B8; +constexpr uint16_t kMmioShmBaseHigh = 0x0BC; +constexpr uint16_t kMmioConfigGeneration = 0x0FC; +constexpr uint16_t kMmioConfig = 0x100; + +// virtq descriptor layout in host memory: addr (8) | len (4) | flags (2) | next (2). +struct Desc { + uint64_t addr; + uint32_t len; + uint16_t flags; + uint16_t next; +}; + +// Walked descriptor chain. Bounded by kMaxQueueSize so we can stack- +// allocate without dynamic growth. push() throws on overflow rather +// than dropping descriptors. +struct DescChain { + std::array items{}; + size_t size{0}; + + void push(Desc desc) { + node_vmm::common::Check(size < items.size(), "virtio descriptor chain too long"); + items[size++] = desc; + } + + Desc& operator[](size_t index) { return items[index]; } + const Desc& operator[](size_t index) const { return items[index]; } + bool empty() const { return size == 0; } +}; + +} // namespace node_vmm::whp::virtio diff --git a/native/whp/virtio/rng.cc b/native/whp/virtio/rng.cc new file mode 100644 index 0000000..e208976 --- /dev/null +++ b/native/whp/virtio/rng.cc @@ -0,0 +1,247 @@ +#include "rng.h" + +#include "desc.h" +#include "../../common/bytes.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include + +#include +#include +#include + +namespace node_vmm::whp { + +using node_vmm::common::Check; +using node_vmm::common::ReadU16; +using node_vmm::common::ReadU32; +using node_vmm::common::ReadU64; +using node_vmm::common::WriteU16; +using node_vmm::common::WriteU32; + +namespace v = node_vmm::whp::virtio; + +VirtioRng::VirtioRng(uint64_t mmio_base, GuestMemory mem, std::function raise_irq) + : mmio_base_(mmio_base), mem_(mem), raise_irq_(std::move(raise_irq)) {} + +void VirtioRng::read_mmio(uint64_t addr, uint8_t* data, uint32_t len) { + std::memset(data, 0, len); + if (len != 4) { + return; + } + WriteU32(data, read_reg(static_cast(addr - mmio_base_))); +} + +void VirtioRng::write_mmio(uint64_t addr, const uint8_t* data, uint32_t len) { + if (len != 4) { + return; + } + write_reg(static_cast(addr - mmio_base_), ReadU32(data)); +} + +uint32_t VirtioRng::read_reg(uint32_t off) const { + switch (off) { + case v::kMmioMagicValue: + return v::kMagic; + case v::kMmioVersion: + return v::kVersion; + case v::kMmioDeviceId: + return 4; // virtio-rng + case v::kMmioVendorId: + return v::kVendor; + case v::kMmioDeviceFeatures: + return dev_features_sel_ == 1 ? static_cast(v::kFVersion1 >> 32) : 0; + case v::kMmioQueueNumMax: + return 64; + case v::kMmioQueueReady: + return queue_.ready ? 1 : 0; + case v::kMmioInterruptStatus: + return interrupt_status_; + case v::kMmioStatus: + return status_; + case v::kMmioConfigGeneration: + return 0; + default: + return 0; + } +} + +void VirtioRng::write_reg(uint32_t off, uint32_t value) { + switch (off) { + case v::kMmioDeviceFeaturesSel: + dev_features_sel_ = value; + return; + case v::kMmioDriverFeaturesSel: + drv_features_sel_ = value; + return; + case v::kMmioDriverFeatures: + if (drv_features_sel_ == 0) { + drv_features_ = (drv_features_ & 0xFFFFFFFF00000000ULL) | value; + } else { + drv_features_ = (drv_features_ & 0x00000000FFFFFFFFULL) | (uint64_t(value) << 32); + } + return; + case v::kMmioQueueSel: + queue_sel_ = value; + return; + case v::kMmioQueueNum: + if (queue_sel_ == 0 && value >= 1 && value <= 64) { + queue_.size = value; + } + return; + case v::kMmioQueueReady: + if (queue_sel_ == 0) { + queue_.ready = value != 0; + } + return; + case v::kMmioQueueNotify: + if (value == 0) { + handle_queue(); + } + return; + case v::kMmioInterruptAck: + interrupt_status_ &= ~value; + return; + case v::kMmioStatus: + if (value == 0) { + reset(); + } else { + status_ = value; + } + return; + case v::kMmioQueueDescLow: + queue_.desc_addr = (queue_.desc_addr & 0xFFFFFFFF00000000ULL) | value; + return; + case v::kMmioQueueDescHigh: + queue_.desc_addr = (queue_.desc_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + return; + case v::kMmioQueueDriverLow: + queue_.driver_addr = (queue_.driver_addr & 0xFFFFFFFF00000000ULL) | value; + return; + case v::kMmioQueueDriverHigh: + queue_.driver_addr = (queue_.driver_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + return; + case v::kMmioQueueDeviceLow: + queue_.device_addr = (queue_.device_addr & 0xFFFFFFFF00000000ULL) | value; + return; + case v::kMmioQueueDeviceHigh: + queue_.device_addr = (queue_.device_addr & 0xFFFFFFFFULL) | (uint64_t(value) << 32); + return; + default: + return; + } +} + +void VirtioRng::reset() { + status_ = 0; + drv_features_ = 0; + dev_features_sel_ = 0; + drv_features_sel_ = 0; + queue_sel_ = 0; + interrupt_status_ = 0; + queue_ = Queue{}; +} + +uint8_t* VirtioRng::ptr_or_null(uint64_t gpa, uint64_t len) const { + if (gpa > mem_.size() || len > mem_.size() - gpa) { + return nullptr; + } + return mem_.data + gpa; +} + +void VirtioRng::fill_random(uint8_t* dst, uint32_t len) { + while (len > 0) { + ULONG chunk = std::min(len, 1U << 20); + NTSTATUS status = BCryptGenRandom(nullptr, dst, chunk, BCRYPT_USE_SYSTEM_PREFERRED_RNG); + Check(status >= 0, "BCryptGenRandom failed"); + dst += chunk; + len -= chunk; + } +} + +uint32_t VirtioRng::service_chain(uint16_t head) { + uint32_t written = 0; + std::array seen{}; + uint16_t idx = head; + for (uint32_t hops = 0; hops < queue_.size; hops++) { + if (idx >= queue_.size || seen[idx] != 0) { + break; + } + seen[idx] = 1; + uint8_t* p = ptr_or_null(queue_.desc_addr + uint64_t(idx) * 16, 16); + if (p == nullptr) { + break; + } + v::Desc desc{ReadU64(p), ReadU32(p + 8), ReadU16(p + 12), ReadU16(p + 14)}; + if ((desc.flags & v::kRingDescFWrite) != 0 && desc.len > 0) { + uint8_t* dst = ptr_or_null(desc.addr, desc.len); + if (dst != nullptr) { + fill_random(dst, desc.len); + written += desc.len; + } + } + if ((desc.flags & v::kRingDescFNext) == 0) { + break; + } + idx = desc.next; + } + return written; +} + +void VirtioRng::push_used(uint16_t id, uint32_t written) { + uint8_t* idxp = ptr_or_null(queue_.device_addr + 2, 2); + if (idxp == nullptr || queue_.size == 0) { + return; + } + uint16_t used = ReadU16(idxp); + uint64_t entry = queue_.device_addr + 4 + uint64_t(used % queue_.size) * 8; + uint8_t* elem = ptr_or_null(entry, 8); + if (elem == nullptr) { + return; + } + WriteU32(elem, id); + WriteU32(elem + 4, written); + WriteU16(idxp, used + 1); +} + +void VirtioRng::handle_queue() { + if (!queue_.ready || queue_.size == 0 || queue_.desc_addr == 0 || + queue_.driver_addr == 0 || queue_.device_addr == 0) { + return; + } + uint8_t* avail_idx_ptr = ptr_or_null(queue_.driver_addr + 2, 2); + if (avail_idx_ptr == nullptr) { + return; + } + uint16_t avail_idx = ReadU16(avail_idx_ptr); + bool processed = false; + while (queue_.last_avail != avail_idx) { + uint64_t ring_addr = queue_.driver_addr + 4 + uint64_t(queue_.last_avail % queue_.size) * 2; + uint8_t* headp = ptr_or_null(ring_addr, 2); + if (headp == nullptr) { + break; + } + uint16_t head = ReadU16(headp); + queue_.last_avail++; + push_used(head, service_chain(head)); + processed = true; + } + if (!processed) { + return; + } + uint16_t flags = 0; + if (uint8_t* flagsp = ptr_or_null(queue_.driver_addr, 2)) { + flags = ReadU16(flagsp); + } + if ((flags & v::kRingAvailFNoInterrupt) == 0) { + interrupt_status_ |= v::kInterruptVring; + if (raise_irq_) { + raise_irq_(); + } + } +} + +} // namespace node_vmm::whp diff --git a/native/whp/virtio/rng.h b/native/whp/virtio/rng.h new file mode 100644 index 0000000..2154570 --- /dev/null +++ b/native/whp/virtio/rng.h @@ -0,0 +1,66 @@ +#pragma once + +// virtio-rng-mmio device. Backs Linux's hwrng-virtio driver: the guest +// posts an empty write-only buffer on virtqueue 0, we fill it with random +// bytes via BCryptGenRandom (Windows CNG), push the used entry, and raise +// an interrupt. +// +// Decoupled from IoApic by accepting a `raise_irq` callback at +// construction. Caller supplies the routing. + +#include "../guest_memory.h" + +#include +#include + +namespace node_vmm::whp { + +class VirtioRng { + public: + // mmio_base: device's MMIO base address (e.g. 0xD0000400). + // mem: guest RAM view used to dereference descriptor addresses. + // raise_irq: invoked when the device wants to signal an interrupt to + // the driver. Caller routes to the right IOAPIC pin / PIC line. + VirtioRng(uint64_t mmio_base, GuestMemory mem, std::function raise_irq); + + void read_mmio(uint64_t addr, uint8_t* data, uint32_t len); + void write_mmio(uint64_t addr, const uint8_t* data, uint32_t len); + + private: + struct Queue { + uint32_t size{64}; + bool ready{false}; + uint16_t last_avail{0}; + uint64_t desc_addr{0}; + uint64_t driver_addr{0}; + uint64_t device_addr{0}; + }; + + uint32_t read_reg(uint32_t off) const; + void write_reg(uint32_t off, uint32_t value); + void reset(); + + uint8_t* ptr_or_null(uint64_t gpa, uint64_t len) const; + void fill_random(uint8_t* dst, uint32_t len); + + // Walks the descriptor chain starting at `head`, fills any write-only + // descriptor with random bytes via fill_random, returns total bytes + // written. + uint32_t service_chain(uint16_t head); + + void push_used(uint16_t id, uint32_t written); + void handle_queue(); + + uint64_t mmio_base_{0}; + GuestMemory mem_; + std::function raise_irq_; + Queue queue_{}; + uint32_t status_{0}; + uint64_t drv_features_{0}; + uint32_t dev_features_sel_{0}; + uint32_t drv_features_sel_{0}; + uint32_t queue_sel_{0}; + uint32_t interrupt_status_{0}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/win_console_ctrl.cc b/native/whp/win_console_ctrl.cc new file mode 100644 index 0000000..d99e2f8 --- /dev/null +++ b/native/whp/win_console_ctrl.cc @@ -0,0 +1,58 @@ +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include + +#include "win_console_ctrl.h" + +#include + +namespace node_vmm::whp { +namespace { + +std::mutex g_console_ctrl_mu; +std::function g_console_ctrl_callback; + +BOOL WINAPI NodeVmmConsoleCtrlHandler(DWORD ctrl_type) { + if (ctrl_type != CTRL_C_EVENT && ctrl_type != CTRL_BREAK_EVENT) { + return FALSE; + } + std::function callback; + { + std::lock_guard lock(g_console_ctrl_mu); + callback = g_console_ctrl_callback; + } + if (!callback) { + return FALSE; + } + callback(); + return TRUE; +} + +} // namespace + +ScopedConsoleCtrlHandler::ScopedConsoleCtrlHandler(bool enabled, std::function callback) + : enabled_(enabled) { + if (!enabled_) { + return; + } + { + std::lock_guard lock(g_console_ctrl_mu); + g_console_ctrl_callback = std::move(callback); + } + SetConsoleCtrlHandler(NodeVmmConsoleCtrlHandler, TRUE); +} + +ScopedConsoleCtrlHandler::~ScopedConsoleCtrlHandler() { + if (!enabled_) { + return; + } + SetConsoleCtrlHandler(NodeVmmConsoleCtrlHandler, FALSE); + std::lock_guard lock(g_console_ctrl_mu); + g_console_ctrl_callback = nullptr; +} + +} // namespace node_vmm::whp diff --git a/native/whp/win_console_ctrl.h b/native/whp/win_console_ctrl.h new file mode 100644 index 0000000..e1786d6 --- /dev/null +++ b/native/whp/win_console_ctrl.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace node_vmm::whp { + +class ScopedConsoleCtrlHandler { + public: + ScopedConsoleCtrlHandler(bool enabled, std::function callback); + ~ScopedConsoleCtrlHandler(); + + ScopedConsoleCtrlHandler(const ScopedConsoleCtrlHandler&) = delete; + ScopedConsoleCtrlHandler& operator=(const ScopedConsoleCtrlHandler&) = delete; + + private: + bool enabled_{false}; +}; + +} // namespace node_vmm::whp diff --git a/native/whp/win_io.h b/native/whp/win_io.h new file mode 100644 index 0000000..c6e9382 --- /dev/null +++ b/native/whp/win_io.h @@ -0,0 +1,108 @@ +#pragma once + +// Small Windows file I/O helpers shared between the virtio-blk extracted +// module and backend.cc. RAII handle + positioned read/write + sparse- +// file marking. All header-only inline so multiple TUs can include +// without ODR concerns. + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include "../common/bytes.h" + +#include +#include +#include + +namespace node_vmm::whp { + +struct WinHandle { + HANDLE handle{INVALID_HANDLE_VALUE}; + + WinHandle() = default; + explicit WinHandle(HANDLE value) : handle(value) {} + WinHandle(const WinHandle&) = delete; + WinHandle& operator=(const WinHandle&) = delete; + WinHandle(WinHandle&& other) noexcept : handle(other.handle) { other.handle = INVALID_HANDLE_VALUE; } + WinHandle& operator=(WinHandle&& other) noexcept { + if (this != &other) { + reset(); + handle = other.handle; + other.handle = INVALID_HANDLE_VALUE; + } + return *this; + } + ~WinHandle() { reset(); } + + void reset(HANDLE next = INVALID_HANDLE_VALUE) { + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } + handle = next; + } + + HANDLE get() const { return handle; } + bool valid() const { return handle != INVALID_HANDLE_VALUE; } +}; + +inline void PreadAll(HANDLE file, uint8_t* dst, size_t len, uint64_t off) { + LARGE_INTEGER pos{}; + pos.QuadPart = static_cast(off); + node_vmm::common::Check( + SetFilePointerEx(file, pos, nullptr, FILE_BEGIN) != 0, + "seek disk read failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); + size_t done = 0; + while (done < len) { + DWORD got = 0; + DWORD want = static_cast(std::min(len - done, 1U << 20)); + node_vmm::common::Check( + ReadFile(file, dst + done, want, &got, nullptr) != 0, + "read disk failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); + node_vmm::common::Check(got != 0, "short disk read"); + done += got; + } +} + +inline void PwriteAll(HANDLE file, const uint8_t* src, size_t len, uint64_t off) { + LARGE_INTEGER pos{}; + pos.QuadPart = static_cast(off); + node_vmm::common::Check( + SetFilePointerEx(file, pos, nullptr, FILE_BEGIN) != 0, + "seek disk write failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); + size_t done = 0; + while (done < len) { + DWORD wrote = 0; + DWORD want = static_cast(std::min(len - done, 1U << 20)); + node_vmm::common::Check( + WriteFile(file, src + done, want, &wrote, nullptr) != 0, + "write disk failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); + node_vmm::common::Check(wrote != 0, "short disk write"); + done += wrote; + } +} + +inline void TruncateFileTo(HANDLE file, uint64_t size) { + LARGE_INTEGER pos{}; + pos.QuadPart = static_cast(size); + node_vmm::common::Check( + SetFilePointerEx(file, pos, nullptr, FILE_BEGIN) != 0, + "seek truncate failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); + node_vmm::common::Check( + SetEndOfFile(file) != 0, + "truncate overlay failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); +} + +inline void MarkFileSparse(HANDLE file) { + DWORD bytes_returned = 0; + node_vmm::common::Check( + DeviceIoControl(file, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &bytes_returned, nullptr) != 0, + "mark overlay sparse failed: " + node_vmm::common::WindowsErrorMessage(GetLastError())); +} + +} // namespace node_vmm::whp diff --git a/native/whp_backend.cc b/native/whp_backend.cc deleted file mode 100644 index 295ddea..0000000 --- a/native/whp_backend.cc +++ /dev/null @@ -1,467 +0,0 @@ -#include - -#define WIN32_LEAN_AND_MEAN -#define NOMINMAX -#include -#include - -#include - -#include -#include -#include -#include -#include - -namespace { - -constexpr uint64_t kGuestCodeAddr = 0x1000; -constexpr uint64_t kGuestWriteAddr = 0x2000; -constexpr uint64_t kGuestRamBytes = 0x10000; - -std::string WindowsErrorMessage(DWORD code) { - char* buffer = nullptr; - DWORD size = FormatMessageA( - FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, - nullptr, - code, - MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), - reinterpret_cast(&buffer), - 0, - nullptr); - std::string message = size && buffer ? std::string(buffer, size) : "unknown Windows error"; - if (buffer) { - LocalFree(buffer); - } - while (!message.empty() && (message.back() == '\n' || message.back() == '\r' || message.back() == ' ')) { - message.pop_back(); - } - return message; -} - -std::string HresultMessage(HRESULT hr, const std::string& what) { - char code[16]; - std::snprintf(code, sizeof(code), "0x%08lx", static_cast(hr)); - return what + " failed with HRESULT " + code + ": " + WindowsErrorMessage(static_cast(hr)); -} - -void Check(bool ok, const std::string& message) { - if (!ok) { - throw std::runtime_error(message); - } -} - -void CheckHr(HRESULT hr, const std::string& what) { - if (FAILED(hr)) { - throw std::runtime_error(HresultMessage(hr, what)); - } -} - -napi_value MakeObject(napi_env env) { - napi_value obj; - napi_create_object(env, &obj); - return obj; -} - -void SetString(napi_env env, napi_value obj, const char* name, const std::string& value) { - napi_value v; - napi_create_string_utf8(env, value.c_str(), value.size(), &v); - napi_set_named_property(env, obj, name, v); -} - -void SetBool(napi_env env, napi_value obj, const char* name, bool value) { - napi_value v; - napi_get_boolean(env, value, &v); - napi_set_named_property(env, obj, name, v); -} - -void SetUint32(napi_env env, napi_value obj, const char* name, uint32_t value) { - napi_value v; - napi_create_uint32(env, value, &v); - napi_set_named_property(env, obj, name, v); -} - -void SetDouble(napi_env env, napi_value obj, const char* name, double value) { - napi_value v; - napi_create_double(env, value, &v); - napi_set_named_property(env, obj, name, v); -} - -napi_value Throw(napi_env env, const std::exception& err) { - napi_throw_error(env, nullptr, err.what()); - return nullptr; -} - -template -T LoadSymbol(HMODULE dll, const char* name) { - FARPROC proc = GetProcAddress(dll, name); - if (!proc) { - throw std::runtime_error(std::string("WinHvPlatform.dll does not export ") + name); - } - return reinterpret_cast(proc); -} - -using WHvGetCapabilityFn = HRESULT(WINAPI*)(WHV_CAPABILITY_CODE, VOID*, UINT32, UINT32*); -using WHvCreatePartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE*); -using WHvSetupPartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE); -using WHvDeletePartitionFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE); -using WHvSetPartitionPropertyFn = - HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, WHV_PARTITION_PROPERTY_CODE, const VOID*, UINT32); -using WHvCreateVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, UINT32); -using WHvDeleteVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32); -using WHvMapGpaRangeFn = - HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, VOID*, WHV_GUEST_PHYSICAL_ADDRESS, UINT64, WHV_MAP_GPA_RANGE_FLAGS); -using WHvQueryGpaRangeDirtyBitmapFn = - HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, WHV_GUEST_PHYSICAL_ADDRESS, UINT64, UINT64*, UINT32); -using WHvRunVirtualProcessorFn = HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, VOID*, UINT32); -using WHvSetVirtualProcessorRegistersFn = - HRESULT(WINAPI*)(WHV_PARTITION_HANDLE, UINT32, const WHV_REGISTER_NAME*, UINT32, const WHV_REGISTER_VALUE*); - -struct WhpApi { - HMODULE dll{nullptr}; - WHvGetCapabilityFn get_capability{nullptr}; - WHvCreatePartitionFn create_partition{nullptr}; - WHvSetupPartitionFn setup_partition{nullptr}; - WHvDeletePartitionFn delete_partition{nullptr}; - WHvSetPartitionPropertyFn set_partition_property{nullptr}; - WHvCreateVirtualProcessorFn create_vp{nullptr}; - WHvDeleteVirtualProcessorFn delete_vp{nullptr}; - WHvMapGpaRangeFn map_gpa_range{nullptr}; - WHvQueryGpaRangeDirtyBitmapFn query_dirty_bitmap{nullptr}; - WHvRunVirtualProcessorFn run_vp{nullptr}; - WHvSetVirtualProcessorRegistersFn set_vp_registers{nullptr}; - - explicit WhpApi(bool require_all) { - dll = LoadLibraryW(L"WinHvPlatform.dll"); - if (!dll) { - if (require_all) { - throw std::runtime_error("WinHvPlatform.dll is not available: " + WindowsErrorMessage(GetLastError())); - } - return; - } - get_capability = LoadSymbol(dll, "WHvGetCapability"); - if (!require_all) { - query_dirty_bitmap = - reinterpret_cast(GetProcAddress(dll, "WHvQueryGpaRangeDirtyBitmap")); - create_partition = reinterpret_cast(GetProcAddress(dll, "WHvCreatePartition")); - setup_partition = reinterpret_cast(GetProcAddress(dll, "WHvSetupPartition")); - delete_partition = reinterpret_cast(GetProcAddress(dll, "WHvDeletePartition")); - set_partition_property = - reinterpret_cast(GetProcAddress(dll, "WHvSetPartitionProperty")); - return; - } - create_partition = LoadSymbol(dll, "WHvCreatePartition"); - setup_partition = LoadSymbol(dll, "WHvSetupPartition"); - delete_partition = LoadSymbol(dll, "WHvDeletePartition"); - set_partition_property = LoadSymbol(dll, "WHvSetPartitionProperty"); - create_vp = LoadSymbol(dll, "WHvCreateVirtualProcessor"); - delete_vp = LoadSymbol(dll, "WHvDeleteVirtualProcessor"); - map_gpa_range = LoadSymbol(dll, "WHvMapGpaRange"); - query_dirty_bitmap = LoadSymbol(dll, "WHvQueryGpaRangeDirtyBitmap"); - run_vp = LoadSymbol(dll, "WHvRunVirtualProcessor"); - set_vp_registers = LoadSymbol(dll, "WHvSetVirtualProcessorRegisters"); - } - - WhpApi(const WhpApi&) = delete; - WhpApi& operator=(const WhpApi&) = delete; - - ~WhpApi() { - if (dll) { - FreeLibrary(dll); - } - } -}; - -struct Partition { - WhpApi& api; - WHV_PARTITION_HANDLE handle{nullptr}; - - explicit Partition(WhpApi& api_ref) : api(api_ref) { - CheckHr(api.create_partition(&handle), "WHvCreatePartition"); - } - - Partition(const Partition&) = delete; - Partition& operator=(const Partition&) = delete; - - ~Partition() { - if (handle) { - api.delete_partition(handle); - } - } -}; - -struct VirtualProcessor { - WhpApi& api; - WHV_PARTITION_HANDLE partition{nullptr}; - UINT32 index{0}; - bool created{false}; - - VirtualProcessor(WhpApi& api_ref, WHV_PARTITION_HANDLE partition_handle, UINT32 vp_index) - : api(api_ref), partition(partition_handle), index(vp_index) { - CheckHr(api.create_vp(partition, index, 0), "WHvCreateVirtualProcessor"); - created = true; - } - - VirtualProcessor(const VirtualProcessor&) = delete; - VirtualProcessor& operator=(const VirtualProcessor&) = delete; - - ~VirtualProcessor() { - if (created) { - api.delete_vp(partition, index); - } - } -}; - -struct VirtualAllocMemory { - void* ptr{nullptr}; - - explicit VirtualAllocMemory(size_t bytes) { - ptr = VirtualAlloc(nullptr, bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE); - Check(ptr != nullptr, "VirtualAlloc guest RAM failed: " + WindowsErrorMessage(GetLastError())); - } - - VirtualAllocMemory(const VirtualAllocMemory&) = delete; - VirtualAllocMemory& operator=(const VirtualAllocMemory&) = delete; - - ~VirtualAllocMemory() { - if (ptr) { - VirtualFree(ptr, 0, MEM_RELEASE); - } - } - - uint8_t* bytes() const { return reinterpret_cast(ptr); } -}; - -WHV_X64_SEGMENT_REGISTER Segment(uint16_t selector, uint16_t attributes) { - WHV_X64_SEGMENT_REGISTER segment{}; - segment.Base = 0; - segment.Limit = 0xffff; - segment.Selector = selector; - segment.Attributes = attributes; - return segment; -} - -std::string WhpExitReason(uint32_t reason) { - switch (reason) { - case WHvRunVpExitReasonX64Halt: - return "hlt"; - case WHvRunVpExitReasonMemoryAccess: - return "memory-access"; - case WHvRunVpExitReasonX64IoPortAccess: - return "io-port"; - case WHvRunVpExitReasonUnrecoverableException: - return "unrecoverable-exception"; - case WHvRunVpExitReasonInvalidVpRegisterValue: - return "invalid-vp-register"; - case WHvRunVpExitReasonUnsupportedFeature: - return "unsupported-feature"; - case WHvRunVpExitReasonX64InterruptWindow: - return "interrupt-window"; - case WHvRunVpExitReasonCanceled: - return "canceled"; - default: - return "whp-exit-" + std::to_string(reason); - } -} - -uint32_t CountBits(uint64_t value) { - uint32_t count = 0; - while (value) { - value &= value - 1; - count++; - } - return count; -} - -bool ProbePartition(WhpApi& api, bool* setup_ok, std::string* reason) { - *setup_ok = false; - if (!api.create_partition || !api.set_partition_property || !api.setup_partition || !api.delete_partition) { - *reason = "WinHvPlatform.dll is missing partition lifecycle exports"; - return false; - } - Partition partition(api); - WHV_PARTITION_PROPERTY property{}; - property.ProcessorCount = 1; - HRESULT hr = api.set_partition_property( - partition.handle, - WHvPartitionPropertyCodeProcessorCount, - &property, - sizeof(property)); - if (FAILED(hr)) { - *reason = HresultMessage(hr, "WHvSetPartitionProperty(ProcessorCount)"); - return true; - } - hr = api.setup_partition(partition.handle); - if (FAILED(hr)) { - *reason = HresultMessage(hr, "WHvSetupPartition"); - return true; - } - *setup_ok = true; - return true; -} - -napi_value ProbeWhp(napi_env env, napi_callback_info) { - napi_value out = MakeObject(env); - SetString(env, out, "backend", "whp"); - SetString(env, out, "arch", "x86_64"); - SetBool(env, out, "available", false); - SetBool(env, out, "hypervisorPresent", false); - SetBool(env, out, "dirtyPageTracking", false); - SetBool(env, out, "queryDirtyBitmapExport", false); - SetBool(env, out, "partitionCreate", false); - SetBool(env, out, "partitionSetup", false); - - try { - WhpApi api(false); - if (!api.dll) { - SetString(env, out, "reason", "WinHvPlatform.dll is not available"); - return out; - } - SetBool(env, out, "queryDirtyBitmapExport", api.query_dirty_bitmap != nullptr); - - WHV_CAPABILITY present{}; - UINT32 written = 0; - HRESULT hr = api.get_capability(WHvCapabilityCodeHypervisorPresent, &present, sizeof(present), &written); - if (FAILED(hr)) { - SetString(env, out, "reason", HresultMessage(hr, "WHvGetCapability(HypervisorPresent)")); - return out; - } - SetBool(env, out, "hypervisorPresent", present.HypervisorPresent != FALSE); - - WHV_CAPABILITY features{}; - hr = api.get_capability(WHvCapabilityCodeFeatures, &features, sizeof(features), &written); - if (SUCCEEDED(hr)) { - SetBool(env, out, "dirtyPageTracking", features.Features.DirtyPageTracking != 0); - } - - bool setup_ok = false; - std::string reason; - bool partition_ok = ProbePartition(api, &setup_ok, &reason); - SetBool(env, out, "partitionCreate", partition_ok); - SetBool(env, out, "partitionSetup", setup_ok); - SetBool(env, out, "available", present.HypervisorPresent != FALSE && partition_ok && setup_ok); - if (!reason.empty()) { - SetString(env, out, "reason", reason); - } - return out; - } catch (const std::exception& err) { - SetString(env, out, "reason", err.what()); - return out; - } -} - -napi_value WhpSmokeHlt(napi_env env, napi_callback_info) { - try { - auto start = std::chrono::steady_clock::now(); - WhpApi api(true); - Partition partition(api); - - WHV_PARTITION_PROPERTY property{}; - property.ProcessorCount = 1; - CheckHr( - api.set_partition_property( - partition.handle, - WHvPartitionPropertyCodeProcessorCount, - &property, - sizeof(property)), - "WHvSetPartitionProperty(ProcessorCount)"); - CheckHr(api.setup_partition(partition.handle), "WHvSetupPartition"); - - VirtualAllocMemory ram(kGuestRamBytes); - uint8_t* bytes = ram.bytes(); - bytes[kGuestCodeAddr + 0] = 0xc6; // mov byte ptr [0x2000], 0x41 - bytes[kGuestCodeAddr + 1] = 0x06; - bytes[kGuestCodeAddr + 2] = static_cast(kGuestWriteAddr & 0xff); - bytes[kGuestCodeAddr + 3] = static_cast((kGuestWriteAddr >> 8) & 0xff); - bytes[kGuestCodeAddr + 4] = 0x41; - bytes[kGuestCodeAddr + 5] = 0xf4; // hlt - - WHV_MAP_GPA_RANGE_FLAGS flags = static_cast( - 0x00000001 | 0x00000002 | 0x00000004 | 0x00000008); - CheckHr(api.map_gpa_range(partition.handle, ram.ptr, 0, kGuestRamBytes, flags), "WHvMapGpaRange"); - - VirtualProcessor vp(api, partition.handle, 0); - - WHV_REGISTER_NAME names[] = { - WHvX64RegisterRip, - WHvX64RegisterRflags, - WHvX64RegisterCs, - WHvX64RegisterDs, - WHvX64RegisterEs, - WHvX64RegisterSs, - }; - WHV_REGISTER_VALUE values[sizeof(names) / sizeof(names[0])] = {}; - values[0].Reg64 = kGuestCodeAddr; - values[1].Reg64 = 0x2; - values[2].Segment = Segment(0, 0x9b); - values[3].Segment = Segment(0, 0x93); - values[4].Segment = Segment(0, 0x93); - values[5].Segment = Segment(0, 0x93); - CheckHr( - api.set_vp_registers( - partition.handle, - 0, - names, - static_cast(sizeof(names) / sizeof(names[0])), - values), - "WHvSetVirtualProcessorRegisters"); - - WHV_RUN_VP_EXIT_CONTEXT exit_context{}; - uint32_t runs = 0; - HRESULT hr = api.run_vp(partition.handle, 0, &exit_context, sizeof(exit_context)); - CheckHr(hr, "WHvRunVirtualProcessor"); - runs++; - - uint64_t dirty_bitmap = 0; - CheckHr( - api.query_dirty_bitmap(partition.handle, 0, kGuestRamBytes, &dirty_bitmap, sizeof(dirty_bitmap)), - "WHvQueryGpaRangeDirtyBitmap"); - auto end = std::chrono::steady_clock::now(); - auto elapsed_us = std::chrono::duration_cast(end - start).count(); - - napi_value out = MakeObject(env); - SetString(env, out, "backend", "whp"); - SetString(env, out, "exitReason", WhpExitReason(exit_context.ExitReason)); - SetUint32(env, out, "exitReasonCode", exit_context.ExitReason); - SetUint32(env, out, "runs", runs); - SetBool(env, out, "dirtyTracking", true); - SetDouble(env, out, "dirtyPages", CountBits(dirty_bitmap)); - SetDouble(env, out, "totalMs", static_cast(elapsed_us) / 1000.0); - return out; - } catch (const std::exception& err) { - return Throw(env, err); - } -} - -napi_value Unsupported(napi_env env, const char* method) { - napi_throw_error(env, nullptr, (std::string(method) + " is only available in the Linux KVM backend").c_str()); - return nullptr; -} - -napi_value ProbeKvm(napi_env env, napi_callback_info) { return Unsupported(env, "probeKvm"); } -napi_value SmokeHlt(napi_env env, napi_callback_info) { return Unsupported(env, "smokeHlt"); } -napi_value UartSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "uartSmoke"); } -napi_value GuestExitSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "guestExitSmoke"); } -napi_value RamSnapshotSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "ramSnapshotSmoke"); } -napi_value DirtyRamSnapshotSmoke(napi_env env, napi_callback_info) { return Unsupported(env, "dirtyRamSnapshotSmoke"); } -napi_value RunVm(napi_env env, napi_callback_info) { return Unsupported(env, "runVm"); } - -napi_value Init(napi_env env, napi_value exports) { - napi_property_descriptor desc[] = { - {"probeKvm", nullptr, ProbeKvm, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"probeWhp", nullptr, ProbeWhp, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"smokeHlt", nullptr, SmokeHlt, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"whpSmokeHlt", nullptr, WhpSmokeHlt, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"uartSmoke", nullptr, UartSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"guestExitSmoke", nullptr, GuestExitSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"ramSnapshotSmoke", nullptr, RamSnapshotSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"dirtyRamSnapshotSmoke", nullptr, DirtyRamSnapshotSmoke, nullptr, nullptr, nullptr, napi_default, nullptr}, - {"runVm", nullptr, RunVm, nullptr, nullptr, nullptr, napi_default, nullptr}, - }; - napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); - return exports; -} - -NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) - -} // namespace diff --git a/package.json b/package.json index 06a9c88..c2cb523 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,10 @@ "types": "./dist/src/args.d.ts", "import": "./dist/src/args.js" }, + "./disk": { + "types": "./dist/src/disk.d.ts", + "import": "./dist/src/disk.js" + }, "./kvm": { "types": "./dist/src/kvm.d.ts", "import": "./dist/src/kvm.js" @@ -53,6 +57,10 @@ "types": "./dist/src/oci.d.ts", "import": "./dist/src/oci.js" }, + "./prebuilt-rootfs": { + "types": "./dist/src/prebuilt-rootfs.d.ts", + "import": "./dist/src/prebuilt-rootfs.js" + }, "./process": { "types": "./dist/src/process.d.ts", "import": "./dist/src/process.js" @@ -82,6 +90,7 @@ "docs", "scripts/build-native.mjs", "scripts/package-prebuild.mjs", + "scripts/slirp-flags.mjs", "binding.gyp", "README.md", "LICENSE" @@ -97,12 +106,14 @@ "test": "npm run build && node --test dist/test/*.test.js", "test:unit": "npm run build && node --test dist/test/unit.test.js", "test:coverage": "npm run test:coverage:ts && npm run test:coverage:cpp", - "test:coverage:ts": "npm run build && c8 --100 --all --include 'dist/src/args.js' --include 'dist/src/kernel.js' --include 'dist/src/kvm.js' --include 'dist/src/process.js' --include 'dist/src/types.js' --include 'dist/src/utils.js' --reporter=text --reporter=json-summary node --test dist/test/*.test.js", + "test:coverage:ts": "npm run build && c8 --100 --all --include dist/src/args.js --include dist/src/kernel.js --include dist/src/kvm.js --include dist/src/process.js --include dist/src/types.js --include dist/src/utils.js --reporter=text --reporter=json-summary node --test dist/test/*.test.js", "test:coverage:cpp": "npm run build:native:coverage && node --test dist/test/native.test.js && node scripts/check-gcov.mjs", "test:e2e": "npm run build && NODE_VMM_E2E=1 node --test dist/test/e2e.test.js", "test:consumers": "npm run build && node scripts/consumer-smoke.mjs", "test:js-apps": "npm run build && node scripts/js-app-smoke.mjs", "test:real-apps": "node scripts/real-app-smoke.mjs", + "test:whp-apps": "npm run build && node scripts/whp-app-smoke.mjs", + "test:macos-hvf": "npm run build && node scripts/test-macos-hvf.mjs", "bench:boot-run": "npm run build && node bench/boot-run.mjs", "bench:restore": "npm run build && node bench/restore.mjs", "bench:hot-sandbox": "npm run build && node bench/hot-sandbox.mjs", diff --git a/prebuilds/darwin-arm64/libglib-2.0.0.dylib b/prebuilds/darwin-arm64/libglib-2.0.0.dylib new file mode 100755 index 0000000..7e4bcf3 Binary files /dev/null and b/prebuilds/darwin-arm64/libglib-2.0.0.dylib differ diff --git a/prebuilds/darwin-arm64/libintl.8.dylib b/prebuilds/darwin-arm64/libintl.8.dylib new file mode 100755 index 0000000..87c41a6 Binary files /dev/null and b/prebuilds/darwin-arm64/libintl.8.dylib differ diff --git a/prebuilds/darwin-arm64/libpcre2-8.0.dylib b/prebuilds/darwin-arm64/libpcre2-8.0.dylib new file mode 100755 index 0000000..f464cef Binary files /dev/null and b/prebuilds/darwin-arm64/libpcre2-8.0.dylib differ diff --git a/prebuilds/darwin-arm64/libslirp.0.dylib b/prebuilds/darwin-arm64/libslirp.0.dylib new file mode 100755 index 0000000..b677642 Binary files /dev/null and b/prebuilds/darwin-arm64/libslirp.0.dylib differ diff --git a/prebuilds/darwin-arm64/node_vmm_native.node b/prebuilds/darwin-arm64/node_vmm_native.node new file mode 100755 index 0000000..42f9c20 Binary files /dev/null and b/prebuilds/darwin-arm64/node_vmm_native.node differ diff --git a/prebuilds/linux-x64/node-vmm-console b/prebuilds/linux-x64/node-vmm-console new file mode 100644 index 0000000..f959043 Binary files /dev/null and b/prebuilds/linux-x64/node-vmm-console differ diff --git a/prebuilds/linux-x64/node_vmm_native.node b/prebuilds/linux-x64/node_vmm_native.node index 6508496..8a9f7db 100755 Binary files a/prebuilds/linux-x64/node_vmm_native.node and b/prebuilds/linux-x64/node_vmm_native.node differ diff --git a/scripts/build-native.mjs b/scripts/build-native.mjs index 7cfadd5..a22488e 100644 --- a/scripts/build-native.mjs +++ b/scripts/build-native.mjs @@ -6,7 +6,8 @@ const required = process.argv.includes("--required") || process.env.NODE_VMM_NAT const force = process.env.NODE_VMM_FORCE_NATIVE_BUILD === "1"; const supported = (process.platform === "linux" && process.arch === "x64") || - (process.platform === "win32" && process.arch === "x64"); + (process.platform === "win32" && process.arch === "x64") || + (process.platform === "darwin" && process.arch === "arm64"); if (process.env.NODE_VMM_SKIP_NATIVE === "1") { process.stdout.write("node-vmm native build skipped by NODE_VMM_SKIP_NATIVE=1\n"); @@ -24,7 +25,66 @@ if (!required && !force && existsSync(prebuild)) { process.exit(0); } -const result = spawnSync("node-gyp", ["rebuild"], { stdio: "inherit", shell: process.platform === "win32" }); +// Detect libslirp so WHP and KVM can pull in virtio-net + slirp when +// available. The binding.gyp side reads NODE_VMM_HAVE_LIBSLIRP and +// platform-specific include/link flags off the spawn environment. +const env = { ...process.env }; +if (process.platform === "win32") { + // Prefer the project-vendored libslirp (third_party/libslirp), populated by + // scripts/vendor-libslirp.mjs from MSYS2's mingw-w64 prebuilt packages. + // Fall back to a system vcpkg install if the user has one. Both paths can + // coexist; vendored beats system to keep CI builds reproducible. + const vendoredRoot = path.resolve("third_party", "libslirp"); + const vcpkgRoot = process.env.VCPKG_ROOT ?? "C:/vcpkg"; + const vcpkgInstalled = path.join(vcpkgRoot, "installed", "x64-windows"); + const candidates = [ + { root: vendoredRoot, header: path.join(vendoredRoot, "include", "libslirp.h"), libDir: path.join(vendoredRoot, "lib", "x64-windows"), binDir: path.join(vendoredRoot, "bin", "x64-windows"), label: "vendored" }, + { root: vcpkgInstalled, header: path.join(vcpkgInstalled, "include", "libslirp.h"), libDir: path.join(vcpkgInstalled, "lib"), binDir: path.join(vcpkgInstalled, "bin"), label: "vcpkg" }, + ]; + for (const c of candidates) { + if (!existsSync(c.header)) continue; + env.NODE_VMM_HAVE_LIBSLIRP = "1"; + env.NODE_VMM_LIBSLIRP_INCLUDE = path.dirname(c.header); + env.NODE_VMM_LIBSLIRP_LIB = c.libDir; + env.NODE_VMM_LIBSLIRP_BIN = c.binDir; + process.stdout.write(`node-vmm native build: linking libslirp (${c.label}) from ${c.root}\n`); + break; + } +} +if (process.platform === "linux") { + const exists = spawnSync("pkg-config", ["--exists", "slirp"], { stdio: "ignore" }); + if ((exists.status ?? 1) === 0) { + const cflags = spawnSync("pkg-config", ["--cflags", "slirp"], { encoding: "utf8" }); + const libs = spawnSync("pkg-config", ["--libs", "slirp"], { encoding: "utf8" }); + if ((cflags.status ?? 1) === 0 && (libs.status ?? 1) === 0) { + env.NODE_VMM_HAVE_LIBSLIRP = "1"; + env.NODE_VMM_LIBSLIRP_CFLAGS = cflags.stdout.trim(); + env.NODE_VMM_LIBSLIRP_LIBS = libs.stdout.trim(); + process.stdout.write("node-vmm native build: linking libslirp from pkg-config\n"); + } + } else { + process.stdout.write("node-vmm native build: libslirp not found; Linux --net slirp will be unavailable\n"); + } +} + +const result = spawnSync("node-gyp", ["rebuild"], { stdio: "inherit", shell: process.platform === "win32", env }); + +// On Windows, drop libslirp + glib runtime DLLs next to node_vmm_native.node +// so the loader finds them through the default Win32 DLL search path. This +// matches how vcpkg's "applocal" deployment hook stages binaries. +if ((result.status ?? 0) === 0 && process.platform === "win32" && env.NODE_VMM_LIBSLIRP_BIN) { + const { readdirSync, copyFileSync, existsSync: exists } = await import("node:fs"); + const targetDir = path.resolve("build", "Release"); + if (exists(env.NODE_VMM_LIBSLIRP_BIN) && exists(targetDir)) { + const dlls = readdirSync(env.NODE_VMM_LIBSLIRP_BIN).filter((name) => name.toLowerCase().endsWith(".dll")); + for (const dll of dlls) { + copyFileSync(path.join(env.NODE_VMM_LIBSLIRP_BIN, dll), path.join(targetDir, dll)); + } + if (dlls.length > 0) { + process.stdout.write(`node-vmm native build: staged ${dlls.length} libslirp runtime DLLs into ${targetDir}\n`); + } + } +} if ((result.status ?? 1) !== 0 && !required) { process.stderr.write( "node-vmm native build failed during optional npm install; the JS package installed, but KVM/WHP calls will fail until node-gyp succeeds.\n", diff --git a/scripts/build-prebuilt-rootfs.mjs b/scripts/build-prebuilt-rootfs.mjs new file mode 100644 index 0000000..58628c3 --- /dev/null +++ b/scripts/build-prebuilt-rootfs.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// Builds release assets for a prebuilt ext4 rootfs, using the same +// buildRootfsImage pipeline that `node-vmm build` runs. Designed to run +// on Linux GitHub Actions runners (ubuntu-latest) so we can publish +// prebuilt rootfs files alongside each tagged release. +// +// Usage: +// sudo node scripts/build-prebuilt-rootfs.mjs \ +// --image alpine:3.20 \ +// --output dist-rootfs/alpine-3.20.ext4 \ +// --disk-mib 256 +// +// Why sudo: the build pipeline calls `mkfs.ext4` + `mount` + chroot, +// which require CAP_SYS_ADMIN. CI workflows already use sudo for those. +// +// Output: +// - .ext4 +// - .ext4.gz +// - .ext4.manifest.json +// +// The gzip + manifest assets are suitable for GitHub Release upload. The +// manifest records compressed and uncompressed checksums/sizes so the SDK +// can verify downloads before caching them. + +import { existsSync, mkdirSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { buildRootfsImage } from "../dist/src/index.js"; +import { + createPrebuiltRootfsManifest, + gzipPrebuiltRootfs, + prebuiltRootfsAssetNames, + prebuiltSlugForImage, + writePrebuiltRootfsManifest, +} from "../dist/src/prebuilt-rootfs.js"; + +function parseArgs(argv) { + const out = { + image: undefined, + output: undefined, + diskMiB: 512, + cacheDir: undefined, + gzipOutput: undefined, + manifestOutput: undefined, + slug: undefined, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--image") { + out.image = argv[++i]; + } else if (a === "--output") { + out.output = argv[++i]; + } else if (a === "--disk-mib") { + out.diskMiB = Number.parseInt(argv[++i], 10); + } else if (a === "--cache-dir") { + out.cacheDir = argv[++i]; + } else if (a === "--gzip-output") { + out.gzipOutput = argv[++i]; + } else if (a === "--manifest-output") { + out.manifestOutput = argv[++i]; + } else if (a === "--slug") { + out.slug = argv[++i]; + } else if (a === "--help" || a === "-h") { + process.stdout.write( + [ + "usage: build-prebuilt-rootfs.mjs --image REF --output PATH [options]", + "", + "options:", + " --disk-mib N ext4 size in MiB (default: 512)", + " --cache-dir DIR OCI cache directory", + " --slug NAME release asset slug (defaults to known prebuilt mapping)", + " --gzip-output PATH gzip asset path (default: .gz)", + " --manifest-output PATH manifest asset path (default: .manifest.json)", + "", + ].join("\n"), + ); + process.exit(0); + } else { + process.stderr.write(`unknown arg: ${a}\n`); + process.exit(2); + } + } + if (!out.image || !out.output) { + process.stderr.write("--image and --output are required\n"); + process.exit(2); + } + if (!Number.isInteger(out.diskMiB) || out.diskMiB <= 0) { + process.stderr.write("--disk-mib must be a positive integer\n"); + process.exit(2); + } + return out; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const outputPath = path.resolve(args.output); + const slug = args.slug ?? prebuiltSlugForImage(args.image); + if (!slug) { + throw new Error(`no prebuilt slug mapping for image: ${args.image}`); + } + const names = prebuiltRootfsAssetNames(slug); + if (path.basename(outputPath) !== names.rootfs) { + throw new Error(`output basename must be ${names.rootfs} for image ${args.image}`); + } + + const gzipPath = path.resolve(args.gzipOutput ?? `${outputPath}.gz`); + const manifestPath = path.resolve(args.manifestOutput ?? `${outputPath}.manifest.json`); + if (path.basename(gzipPath) !== names.gzip) { + throw new Error(`gzip-output basename must be ${names.gzip} for image ${args.image}`); + } + if (path.basename(manifestPath) !== names.manifest) { + throw new Error(`manifest-output basename must be ${names.manifest} for image ${args.image}`); + } + for (const dir of [path.dirname(outputPath), path.dirname(gzipPath), path.dirname(manifestPath)]) { + mkdirSync(dir, { recursive: true }); + } + + const tempDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-prebuilt-")); + const cacheDir = args.cacheDir ?? path.join(tempDir, "cache"); + mkdirSync(cacheDir, { recursive: true }); + + process.stdout.write(`building prebuilt rootfs: image=${args.image} -> ${outputPath} (diskMiB=${args.diskMiB})\n`); + try { + const result = await buildRootfsImage({ + image: args.image, + output: outputPath, + diskMiB: args.diskMiB, + tempDir, + cacheDir, + // Keep the image self-contained: no entrypoint override, no env + // override. The user's `node-vmm run` invocation can still set + // --cmd / --env at runtime; those go through kernel cmdline. + initMode: "batch", + }); + if (!existsSync(result.outputPath)) { + throw new Error(`build reported success but output is missing: ${result.outputPath}`); + } + process.stdout.write(`compressing prebuilt rootfs: ${gzipPath}\n`); + await gzipPrebuiltRootfs(result.outputPath, gzipPath); + + const manifest = await createPrebuiltRootfsManifest({ + image: args.image, + slug, + rootfsPath: result.outputPath, + gzipPath, + diskMiB: args.diskMiB, + }); + await writePrebuiltRootfsManifest(manifestPath, manifest); + + process.stdout.write(`prebuilt rootfs ready: ${result.outputPath}\n`); + process.stdout.write(`prebuilt gzip ready: ${gzipPath}\n`); + process.stdout.write(`prebuilt manifest ready: ${manifestPath}\n`); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +main().catch((err) => { + process.stderr.write(`build-prebuilt-rootfs failed: ${err?.message ?? err}\n`); + process.exit(1); +}); diff --git a/scripts/check-gcov.mjs b/scripts/check-gcov.mjs index 00d7007..23735ca 100644 --- a/scripts/check-gcov.mjs +++ b/scripts/check-gcov.mjs @@ -3,8 +3,8 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; const root = process.cwd(); -const source = "native/kvm_backend.cc"; -const objectDir = "build/Release/obj.target/node_vmm_native/native"; +const source = "native/kvm/backend.cc"; +const objectDir = "build/Release/obj.target/node_vmm_native/native/kvm"; const exclusionsPath = path.join(root, "docs", "cpp-coverage-exclusions.json"); function runGcov() { @@ -25,7 +25,7 @@ function isExcluded(line) { } runGcov(); -const gcov = await readFile(path.join(root, "kvm_backend.cc.gcov"), "utf8"); +const gcov = await readFile(path.join(root, "backend.cc.gcov"), "utf8"); const uncovered = []; for (const rawLine of gcov.split("\n")) { const parsed = parseLine(rawLine); diff --git a/scripts/fetch-kernel.mjs b/scripts/fetch-kernel.mjs index 83283a8..39957c6 100644 --- a/scripts/fetch-kernel.mjs +++ b/scripts/fetch-kernel.mjs @@ -5,9 +5,14 @@ import path from "node:path"; import { Readable } from "node:stream"; import { gunzipSync } from "node:zlib"; -const name = process.env.NODE_VMM_KERNEL_NAME || process.argv[2] || "gocracker-guest-standard-vmlinux"; +const defaultName = process.platform === "darwin" + ? "gocracker-guest-standard-arm64-Image" + : "gocracker-guest-standard-vmlinux"; +const name = process.env.NODE_VMM_KERNEL_NAME || process.argv[2] || defaultName; const defaultSha256 = new Map([ ["gocracker-guest-standard-vmlinux", "d211c41e571a2f262796cd1631e1c69e6e5ca6345248a6c628a9868f05371ff3"], + ["gocracker-guest-standard-arm64-Image", "88fd13179b8d86afb817cfc41a305ad6d42642a2e27bb461da3a81024aaf715b"], + ["gocracker-guest-minimal-arm64-Image", "9bdcf296dcaf7742e3cfdc44e9e7dd52b4af4ebd920be5bf49074772e00537f0"], ]); const baseUrl = process.env.NODE_VMM_KERNEL_REPO || diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 50b4307..20dc6aa 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -1,6 +1,12 @@ import { execFileSync } from "node:child_process"; -const raw = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { encoding: "utf8" }); +const npmArgs = ["pack", "--dry-run", "--json", "--ignore-scripts"]; +const npmExecPath = process.env.npm_execpath; +const raw = npmExecPath + ? execFileSync(process.execPath, [npmExecPath, ...npmArgs], { encoding: "utf8" }) + : process.platform === "win32" + ? execFileSync("cmd.exe", ["/d", "/s", "/c", "npm.cmd pack --dry-run --json --ignore-scripts"], { encoding: "utf8" }) + : execFileSync("npm", npmArgs, { encoding: "utf8" }); const [pack] = JSON.parse(raw); const files = pack.files.map((file) => file.path); const forbidden = [ @@ -24,24 +30,71 @@ if (bad.length > 0) { throw new Error(`npm package includes forbidden files:\n${bad.join("\n")}`); } -for (const required of [ +const requiredFiles = [ "dist/src/index.js", "dist/src/index.d.ts", "dist/src/main.js", "prebuilds/linux-x64/node_vmm_native.node", - "native/kvm_backend.cc", - "native/whp_backend.cc", + "prebuilds/linux-x64/node-vmm-console", + "native/kvm/backend.cc", + "native/hvf/backend.cc", + "native/whp/backend.cc", "guest/node-vmm-console.cc", "scripts/build-native.mjs", "scripts/package-prebuild.mjs", + "scripts/slirp-flags.mjs", "binding.gyp", "README.md", "LICENSE", "package.json", -]) { +]; + +if (process.env.NODE_VMM_PACK_REQUIRE_WIN32 === "1") { + requiredFiles.push( + "prebuilds/win32-x64/node_vmm_native.node", + "prebuilds/win32-x64/libslirp-0.dll", + "prebuilds/win32-x64/libglib-2.0-0.dll", + "prebuilds/win32-x64/libiconv-2.dll", + "prebuilds/win32-x64/libintl-8.dll", + "prebuilds/win32-x64/libpcre2-8-0.dll", + "prebuilds/win32-x64/libcharset-1.dll", + "prebuilds/win32-x64/libgcc_s_seh-1.dll", + "prebuilds/win32-x64/libwinpthread-1.dll", + ); +} + +if (process.env.NODE_VMM_PACK_REQUIRE_DARWIN === "1") { + requiredFiles.push( + "prebuilds/darwin-arm64/node_vmm_native.node", + "prebuilds/darwin-arm64/libslirp.0.dylib", + "prebuilds/darwin-arm64/libglib-2.0.0.dylib", + "prebuilds/darwin-arm64/libintl.8.dylib", + "prebuilds/darwin-arm64/libpcre2-8.0.dylib", + ); +} + +for (const required of requiredFiles) { if (!files.includes(required)) { throw new Error(`npm package is missing required file: ${required}`); } } +const nativePrebuilds = files.filter((file) => /^prebuilds\/[^/]+\/node_vmm_native\.node$/.test(file)); +if (nativePrebuilds.length === 0) { + throw new Error("npm package is missing a native prebuild"); +} + +if (files.includes("prebuilds/darwin-arm64/node_vmm_native.node")) { + for (const required of [ + "prebuilds/darwin-arm64/libslirp.0.dylib", + "prebuilds/darwin-arm64/libglib-2.0.0.dylib", + "prebuilds/darwin-arm64/libintl.8.dylib", + "prebuilds/darwin-arm64/libpcre2-8.0.dylib", + ]) { + if (!files.includes(required)) { + throw new Error(`npm package is missing required macOS dylib: ${required}`); + } + } +} + process.stdout.write(`pack ok: ${pack.filename}, ${pack.entryCount} files\n`); diff --git a/scripts/package-prebuild.mjs b/scripts/package-prebuild.mjs index fe9c4e9..52c5c58 100644 --- a/scripts/package-prebuild.mjs +++ b/scripts/package-prebuild.mjs @@ -1,22 +1,129 @@ -import { chmodSync, copyFileSync, mkdirSync, statSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, statSync } from "node:fs"; import path from "node:path"; -const supported = process.platform === "linux" && process.arch === "x64"; -if (!supported) { +const supportedLinux = process.platform === "linux" && process.arch === "x64"; +const supportedWin = process.platform === "win32" && process.arch === "x64"; +const supportedDarwin = process.platform === "darwin" && process.arch === "arm64"; +if (!supportedLinux && !supportedWin && !supportedDarwin) { process.stdout.write(`node-vmm prebuild packaging skipped on ${process.platform}/${process.arch}\n`); process.exit(0); } -const source = path.resolve("build", "Release", "node_vmm_native.node"); const targetDir = path.resolve("prebuilds", `${process.platform}-${process.arch}`); -const target = path.join(targetDir, "node_vmm_native.node"); +mkdirSync(targetDir, { recursive: true, mode: 0o755 }); + +const WINDOWS_RUNTIME_DLLS = [ + "libslirp-0.dll", + "libglib-2.0-0.dll", + "libintl-8.dll", + "libiconv-2.dll", + "libpcre2-8-0.dll", + "libwinpthread-1.dll", + "libgcc_s_seh-1.dll", + "libcharset-1.dll", +]; -const info = statSync(source); -if (!info.isFile()) { - throw new Error(`native addon is not a file: ${source}`); +function run(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: "utf8", ...options }); + if ((result.status ?? 1) !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + throw new Error(`${command} ${args.join(" ")} failed${output ? `:\n${output}` : ""}`); + } + return result.stdout ?? ""; } -mkdirSync(targetDir, { recursive: true, mode: 0o755 }); -copyFileSync(source, target); -chmodSync(target, 0o755); -process.stdout.write(`node-vmm prebuild written: ${target}\n`); +function dylibReferences(file) { + const output = execFileSync("otool", ["-L", file], { encoding: "utf8" }); + return output + .split("\n") + .slice(1) + .map((line) => line.trim().match(/^(.+?) \(compatibility version/)?.[1]) + .filter(Boolean); +} + +function shouldBundleDylib(ref) { + return ( + path.isAbsolute(ref) && + !ref.startsWith("/usr/lib/") && + !ref.startsWith("/System/Library/") && + ref.endsWith(".dylib") + ); +} + +function rewriteDarwinDylibs(binary) { + const queue = [binary]; + const bundled = new Map(); + + for (let i = 0; i < queue.length; i += 1) { + for (const ref of dylibReferences(queue[i])) { + if (!shouldBundleDylib(ref) || bundled.has(ref)) { + continue; + } + const dest = path.join(targetDir, path.basename(ref)); + copyFileSync(ref, dest); + chmodSync(dest, 0o755); + bundled.set(ref, dest); + queue.push(dest); + } + } + + for (const file of [binary, ...bundled.values()]) { + for (const [original, dest] of bundled.entries()) { + run("install_name_tool", ["-change", original, `@loader_path/${path.basename(dest)}`, file]); + } + if (file !== binary) { + run("install_name_tool", ["-id", `@loader_path/${path.basename(file)}`, file]); + } + } + + for (const file of [...bundled.values(), binary]) { + run("codesign", ["--sign", "-", "--force", file]); + } + + return [...bundled.values()]; +} + +const nativeSource = path.resolve("build", "Release", "node_vmm_native.node"); +const nativeTarget = path.join(targetDir, "node_vmm_native.node"); +const nativeInfo = statSync(nativeSource); +if (!nativeInfo.isFile()) { + throw new Error(`native addon is not a file: ${nativeSource}`); +} +copyFileSync(nativeSource, nativeTarget); +chmodSync(nativeTarget, 0o755); +process.stdout.write(`node-vmm prebuild written: ${nativeTarget}\n`); + +if (supportedLinux) { + const consoleSource = path.resolve("guest", "node-vmm-console.cc"); + const consoleTarget = path.join(targetDir, "node-vmm-console"); + const compile = spawnSync( + "g++", + ["-static", "-Os", "-s", "-o", consoleTarget, consoleSource, "-lutil"], + { stdio: "inherit" }, + ); + if ((compile.status ?? 1) !== 0) { + throw new Error("failed to compile guest/node-vmm-console.cc"); + } + chmodSync(consoleTarget, 0o755); + process.stdout.write(`node-vmm prebuild written: ${consoleTarget}\n`); +} + +if (supportedWin) { + const releaseDir = path.resolve("build", "Release"); + for (const dll of WINDOWS_RUNTIME_DLLS) { + const src = path.join(releaseDir, dll); + if (!existsSync(src)) { + throw new Error(`required Windows runtime DLL is missing: ${src}`); + } + const dst = path.join(targetDir, dll); + copyFileSync(src, dst); + process.stdout.write(`node-vmm prebuild staged dll: ${dst}\n`); + } +} + +if (supportedDarwin) { + for (const file of rewriteDarwinDylibs(nativeTarget)) { + process.stdout.write(`node-vmm bundled dylib: ${file}\n`); + } +} diff --git a/scripts/slirp-flags.mjs b/scripts/slirp-flags.mjs new file mode 100644 index 0000000..873ce3b --- /dev/null +++ b/scripts/slirp-flags.mjs @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; + +const mode = process.argv[2] || ""; +const env = { ...process.env }; +const pkgPaths = [ + env.PKG_CONFIG_PATH, + "/opt/homebrew/opt/libslirp/lib/pkgconfig", + "/opt/homebrew/opt/glib/lib/pkgconfig", + "/usr/local/opt/libslirp/lib/pkgconfig", + "/usr/local/opt/glib/lib/pkgconfig", +].filter(Boolean); +env.PKG_CONFIG_PATH = [...new Set(pkgPaths)].join(":"); + +function pkgConfig(args) { + try { + return execFileSync("pkg-config", [...args, "slirp"], { + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return ""; + } +} + +if (mode === "cflags") { + process.stdout.write(pkgConfig(["--cflags"])); +} else if (mode === "libs") { + process.stdout.write(pkgConfig(["--libs"])); +} diff --git a/scripts/test-macos-hvf.mjs b/scripts/test-macos-hvf.mjs new file mode 100644 index 0000000..1f032b9 --- /dev/null +++ b/scripts/test-macos-hvf.mjs @@ -0,0 +1,775 @@ +#!/usr/bin/env node +/** + * E2E test for macOS HVF (Apple Hypervisor.framework) backend. + * + * Tests: kernel fetch, Alpine ARM64 rootfs from OCI, basic boot, + * echo command, networking (slirp, optional vmnet), SMP CPU count, + * start/stop lifecycle. + * + * Usage: + * node scripts/test-macos-hvf.mjs + * NODE_VMM_KERNEL= node scripts/test-macos-hvf.mjs # skip kernel fetch + */ + +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { existsSync, writeFileSync, mkdirSync } from "node:fs"; +import { mkdir, rm, writeFile, readFile } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const distDir = path.join(repoRoot, "dist"); + +// ── Platform check ──────────────────────────────────────────────────────────── +if (process.platform !== "darwin") { + console.log("SKIP: test-macos-hvf only runs on macOS"); + process.exit(0); +} +if (process.arch !== "arm64") { + console.log("SKIP: test-macos-hvf only runs on Apple Silicon (arm64)"); + process.exit(0); +} + +// ── Ensure dist is built ────────────────────────────────────────────────────── +if (!existsSync(path.join(distDir, "src", "index.js"))) { + console.log("[setup] building TypeScript..."); + spawnSync("npm", ["run", "build:ts"], { cwd: repoRoot, stdio: "inherit" }); +} + +// ── HVF entitlement check / re-exec ────────────────────────────────────────── +const SIGNED_NODE = path.join(os.homedir(), ".cache", "node-vmm", "node-hvf-signed"); +const HVF_ENTITLEMENT_PLIST = path.join(os.homedir(), ".cache", "node-vmm", "hvf-entitlement.plist"); +mkdirSync(path.dirname(SIGNED_NODE), { recursive: true }); +const ALREADY_SIGNED_MARKER = process.env._NODE_VMM_HVF_SIGNED === "1"; + +if (!ALREADY_SIGNED_MARKER) { + // Try to detect if we already have the entitlement by checking via codesign + const check = spawnSync( + "codesign", + ["-d", "--entitlements", "-", process.execPath], + { encoding: "utf8" }, + ); + const hasEntitlement = + check.stdout?.includes("com.apple.security.hypervisor") || + check.stderr?.includes("com.apple.security.hypervisor"); + + if (!hasEntitlement) { + console.log("[setup] node missing com.apple.security.hypervisor entitlement; creating signed copy..."); + const plist = ` + + + + com.apple.security.hypervisor + + +`; + writeFileSync(HVF_ENTITLEMENT_PLIST, plist); + // Copy node binary and ad-hoc sign (no sudo needed for files we own) + try { + // Remove any stale signed copy before re-creating + try { execFileSync("rm", ["-f", SIGNED_NODE]); } catch { /* ignore */ } + execFileSync("cp", [process.execPath, SIGNED_NODE]); + execFileSync("codesign", ["--sign", "-", "--entitlements", HVF_ENTITLEMENT_PLIST, "--force", SIGNED_NODE]); + console.log(`[setup] signed node at ${SIGNED_NODE}, re-execing...`); + const result = spawnSync(SIGNED_NODE, process.argv.slice(1), { + stdio: "inherit", + env: { ...process.env, _NODE_VMM_HVF_SIGNED: "1" }, + }); + process.exit(result.status ?? 1); + } catch (error) { + console.error("[setup] could not sign node binary:", error.message); + console.error(""); + console.error("To fix manually:"); + console.error(` cp ${process.execPath} ${SIGNED_NODE}`); + console.error(` codesign --sign - --entitlements ${HVF_ENTITLEMENT_PLIST} --force ${SIGNED_NODE}`); + console.error(` _NODE_VMM_HVF_SIGNED=1 ${SIGNED_NODE} ${process.argv.slice(1).join(" ")}`); + process.exit(1); + } + } +} + +// ── Load SDK from dist ──────────────────────────────────────────────────────── +const { + features, + doctor, + fetchGocrackerKernel, + buildRootfs, + bootRootfs, + runImage, + runCode, + startVm, + probeHvf, + DEFAULT_GOCRACKER_ARM64_KERNEL, +} = await import(path.join(distDir, "src", "index.js")); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +let passed = 0; +let failed = 0; +const errors = []; + +function pass(name) { + passed++; + process.stdout.write(` ✓ ${name}\n`); +} + +function fail(name, error) { + failed++; + errors.push({ name, error }); + process.stdout.write(` ✗ ${name}: ${error?.message || error}\n`); +} + +async function test(name, fn) { + try { + await fn(); + pass(name); + } catch (error) { + fail(name, error); + } +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message || "assertion failed"); + } +} + +function assertIncludes(haystack, needle, label) { + if (!haystack.includes(needle)) { + throw new Error(`${label ?? "string"} does not include ${JSON.stringify(needle)};\ngot: ${haystack.slice(0, 500)}`); + } +} + +function withEnv(key, value, fn) { + const previous = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + return Promise.resolve() + .then(fn) + .finally(() => { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + }); +} + +function httpGetText(url, timeoutMs = 1_000) { + return new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.setEncoding("utf8"); + let body = ""; + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve(body)); + }); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`HTTP timeout after ${timeoutMs}ms`)); + }); + req.on("error", reject); + }); +} + +async function waitForHttpText(url, needle, attempts = 80) { + let lastError; + for (let i = 0; i < attempts; i++) { + try { + const body = await httpGetText(url); + if (body.includes(needle)) { + return body; + } + lastError = new Error(`response did not include ${JSON.stringify(needle)}: ${body.slice(0, 120)}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw lastError || new Error(`timed out waiting for ${url}`); +} + +async function assertHttpUnavailable(url, timeoutMs = 750) { + try { + const body = await httpGetText(url, timeoutMs); + throw new Error(`paused VM unexpectedly served HTTP: ${body.slice(0, 120)}`); + } catch (error) { + if (error?.message?.startsWith("paused VM unexpectedly served HTTP")) { + throw error; + } + } +} + +function runPythonPty(command, options) { + const script = String.raw` +import fcntl +import json +import os +import pty +import select +import struct +import subprocess +import sys +import termios +import time + +cmd = sys.argv[1:] +events = json.loads(os.environ.get("NODE_VMM_HVF_PTY_EVENTS", "[]")) +expects = [item.encode() for item in json.loads(os.environ.get("NODE_VMM_HVF_PTY_EXPECTS", "[]"))] +require_stopped = os.environ.get("NODE_VMM_HVF_PTY_REQUIRE_STOPPED", "1") == "1" +rows = int(os.environ.get("NODE_VMM_HVF_PTY_ROWS", "0") or "0") +cols = int(os.environ.get("NODE_VMM_HVF_PTY_COLS", "0") or "0") + +master, slave = pty.openpty() +if rows > 0 and cols > 0: + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) +proc = subprocess.Popen(cmd, stdin=slave, stdout=slave, stderr=slave, close_fds=True) +os.close(slave) + +deadline = time.time() + 180 +buf = b"" +event_index = 0 +send_at = None +ok = False + +try: + while time.time() < deadline: + now = time.time() + if event_index < len(events): + event = events[event_index] + wait = event.get("wait") + if send_at is None: + if wait is None or wait.encode() in buf: + send_at = now + (float(event.get("delayMs", 0)) / 1000.0) + if send_at is not None and now >= send_at: + os.write(master, event.get("send", "").encode()) + event_index += 1 + send_at = None + + readable, _, _ = select.select([master], [], [], 0.1) + if readable: + try: + data = os.read(master, 4096) + except OSError: + data = b"" + if not data: + break + os.write(1, data) + buf += data + + if event_index >= len(events) and all(item in buf for item in expects): + if not require_stopped or b"stopped:" in buf: + ok = True + break + if proc.poll() is not None: + break + + if ok and proc.poll() is None: + try: + proc.wait(timeout=20) + except subprocess.TimeoutExpired: + pass + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) +finally: + try: + os.close(master) + except OSError: + pass + +sys.exit(0 if ok and proc.returncode == 0 else 1) +`; + + return new Promise((resolve, reject) => { + const child = spawn("python3", ["-c", script, ...command], { + cwd: repoRoot, + env: { + ...process.env, + NODE_VMM_HVF_PTY_EVENTS: JSON.stringify(options.events), + NODE_VMM_HVF_PTY_EXPECTS: JSON.stringify(options.expects), + NODE_VMM_HVF_PTY_REQUIRE_STOPPED: options.requireStopped === false ? "0" : "1", + NODE_VMM_HVF_PTY_ROWS: String(options.rows || ""), + NODE_VMM_HVF_PTY_COLS: String(options.cols || ""), + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, options.timeoutMs || 210_000); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, output }); + }); + }); +} + +const baseDir = path.join(os.tmpdir(), `node-vmm-hvf-test-${process.pid}`); +mkdirSync(baseDir, { recursive: true }); + +const cacheDir = path.join(os.homedir(), ".cache", "node-vmm"); +mkdirSync(cacheDir, { recursive: true }); + +// ── Tests ───────────────────────────────────────────────────────────────────── +console.log("\nnode-vmm macOS HVF E2E tests\n"); + +// 1. Feature / doctor checks +test("features() reports hvf backend and multi-vCPU range", () => { + const feats = features(); + assertIncludes(feats.join("\n"), "backend: hvf", "features"); + assertIncludes(feats.join("\n"), "vcpu: 1-64 on macOS/HVF; default 1", "features"); +}); + +test("doctor() shows HVF as available", async () => { + const result = await doctor(); + assert(typeof result.ok === "boolean", "doctor returns ok"); + const hvfCheck = result.checks.find((c) => c.name === "hvf-api"); + assert(hvfCheck, "doctor has hvf-api check"); + if (!hvfCheck.ok) { + throw new Error(`HVF not available: ${hvfCheck.label}`); + } +}); + +// 2. probeHvf +test("probeHvf() returns available=true", () => { + const probe = probeHvf(); + assert(probe.backend === "hvf", "backend=hvf"); + assert(probe.arch === "arm64", "arch=arm64"); + if (!probe.available) { + throw new Error(`HVF not available: ${probe.reason || "unknown reason"}`); + } +}); + +// 3. Kernel resolution/fetch +let kernelPath; +await test("resolve ARM64 kernel", async () => { + if (process.env.NODE_VMM_KERNEL) { + kernelPath = path.resolve(process.env.NODE_VMM_KERNEL); + } else { + const kernelResult = await fetchGocrackerKernel({ + name: DEFAULT_GOCRACKER_ARM64_KERNEL, + outputDir: path.join(cacheDir, "kernels"), + }); + kernelPath = kernelResult.path; + } + assert(existsSync(kernelPath), `kernel exists at ${kernelPath}`); + // ARM64 Image magic at offset 56: 0x644D5241 ("ARM\x64" little-endian) + const buf = await readFile(kernelPath); + if (buf.length > 60) { + const magic = buf.readUInt32LE(56); + assert(magic === 0x644d5241, `ARM64 Image magic 0x644D5241 at offset 56 (got 0x${magic.toString(16)})`); + } + pass(`kernel at ${kernelPath} (${(existsSync(kernelPath) ? "exists" : "missing")})`); +}); + +if (!kernelPath || !existsSync(kernelPath)) { + console.error("\n[fatal] kernel not available; aborting remaining tests"); + process.exit(1); +} + +// 4. Build Alpine ARM64 rootfs from OCI +const rootfsPath = path.join(baseDir, "alpine-arm64.ext4"); +const tempDir = path.join(baseDir, "build-temp"); +mkdirSync(tempDir, { recursive: true }); + +await test("build Alpine ARM64 rootfs from OCI (no root needed)", async () => { + await buildRootfs({ + image: "alpine:3.20", + output: rootfsPath, + diskMiB: 512, + buildArgs: {}, + env: {}, + cmd: "uname -m", + tempDir, + cacheDir: path.join(cacheDir, "oci"), + platformArch: "arm64", + }); + assert(existsSync(rootfsPath), `rootfs created at ${rootfsPath}`); + const stat = await import("node:fs/promises").then((m) => m.stat(rootfsPath)); + assert(stat.size > 0, "rootfs is non-empty"); +}); + +const interactiveRootfs = path.join(baseDir, "interactive-arm64.ext4"); +const interactiveTemp = path.join(baseDir, "interactive-temp"); +mkdirSync(interactiveTemp, { recursive: true }); + +await test("build interactive Alpine ARM64 rootfs", async () => { + await buildRootfs({ + image: "alpine:3.20", + output: interactiveRootfs, + diskMiB: 512, + buildArgs: {}, + env: {}, + cmd: "/bin/sh", + initMode: "interactive", + tempDir: interactiveTemp, + cacheDir: path.join(cacheDir, "oci"), + platformArch: "arm64", + }); + assert(existsSync(interactiveRootfs), `interactive rootfs created at ${interactiveRootfs}`); +}); + +if (!existsSync(rootfsPath)) { + console.error("\n[fatal] rootfs not built; aborting VM tests"); + process.exit(1); +} + +// 5. Boot: uname -m should print aarch64 +await test("boot Alpine ARM64 and run uname -m → aarch64", async () => { + const result = await bootRootfs({ + kernelPath, + rootfsPath, + memMiB: 256, + cpus: 1, + network: "none", + timeoutMs: 60_000, + }); + assertIncludes(result.console, "aarch64", "console output"); +}); + +// 6. runImage: echo command captured in guestOutput +const echoRootfs = path.join(baseDir, "echo.ext4"); +const echoTemp = path.join(baseDir, "echo-temp"); +mkdirSync(echoTemp, { recursive: true }); + +await test("build echo rootfs", async () => { + await buildRootfs({ + image: "alpine:3.20", + output: echoRootfs, + diskMiB: 512, + buildArgs: {}, + env: {}, + cmd: 'echo "node-vmm-hvf-echo-ok"', + tempDir: echoTemp, + cacheDir: path.join(cacheDir, "oci"), + platformArch: "arm64", + }); +}); + +await test("runImage captures echo output via console parsing", async () => { + const result = await runImage({ + kernelPath, + rootfsPath: echoRootfs, + memMiB: 256, + cpus: 1, + network: "none", + timeoutMs: 60_000, + }); + assertIncludes(result.console, "node-vmm-hvf-echo-ok", "console"); +}); + +function interactiveCliArgs() { + return [ + process.execPath, + path.join(distDir, "src", "main.js"), + "run", + "--rootfs", + interactiveRootfs, + "--kernel", + kernelPath, + "--cmd", + "/bin/sh", + "--interactive", + "--mem", + "256", + "--net", + "none", + "--timeout-ms", + "0", + ]; +} + +await test("interactive PTY shell accepts input and exits cleanly", async () => { + const result = await runPythonPty(interactiveCliArgs(), { + events: [{ wait: "# ", send: "echo node-vmm-hvf-pty-ok\nexit\n" }], + expects: ["node-vmm-hvf-pty-ok", "stopped:"], + }); + assert(result.code === 0, result.output); + assertIncludes(result.output, "node-vmm-hvf-pty-ok", "PTY output"); + assertIncludes(result.output, "stopped:", "CLI stop output"); + assert(/stopped: (shutdown|hlt)/.test(result.output), result.output); +}); + +await test("interactive Ctrl-C is delivered to the guest shell", async () => { + const result = await runPythonPty(interactiveCliArgs(), { + events: [ + { wait: "# ", send: "trap 'echo node-vmm-hvf-ctrl-c-guest-ok' INT\nsleep 30\n", delayMs: 100 }, + { send: "\x03", delayMs: 1000 }, + { send: "echo node-vmm-hvf-after-ctrl-c-ok\nexit\n", delayMs: 500 }, + ], + expects: ["node-vmm-hvf-ctrl-c-guest-ok", "node-vmm-hvf-after-ctrl-c-ok", "stopped:"], + }); + assert(result.code === 0, result.output); + assertIncludes(result.output, "node-vmm-hvf-ctrl-c-guest-ok", "guest Ctrl-C trap"); + assertIncludes(result.output, "node-vmm-hvf-after-ctrl-c-ok", "shell survived Ctrl-C"); +}); + +await test("interactive PTY applies host stty size", async () => { + const result = await runPythonPty(interactiveCliArgs(), { + rows: 33, + cols: 101, + events: [{ wait: "# ", send: "stty size; exit\n" }], + expects: ["33 101", "stopped:"], + }); + assert(result.code === 0, result.output); + assertIncludes(result.output, "33 101", "stty size output"); +}); + +await test("boot exposes QEMU-parity device-tree nodes", async () => { + const result = await runImage({ + image: "alpine:3.20", + kernelPath, + cmd: + "for node in /proc/device-tree/pl031@9010000 /proc/device-tree/pl061@9030000 /proc/device-tree/fw-cfg@9020000 /proc/device-tree/virtio_mmio@a000000 /proc/device-tree/virtio_mmio@a000200 /proc/device-tree/gpio-keys /proc/device-tree/pcie@10000000; do test -d \"$node\" || exit 10; done; " + + "test -d /proc/device-tree/virtio_mmio@a000400; " + + "test -e /proc/device-tree/dma-coherent; " + + "test -e /proc/device-tree/virtio_mmio@a000000/dma-coherent; " + + "test -e /proc/device-tree/virtio_mmio@a000400/dma-coherent; " + + "test -e /proc/device-tree/pcie@10000000/dma-coherent; " + + "test -e /proc/device-tree/pcie@10000000/interrupt-map; " + + "test -e /proc/device-tree/pcie@10000000/interrupt-map-mask; " + + "test -d /sys/bus/pci; " + + "tr -d '\\0' { + const result = await runImage({ + image: "alpine:3.20", + kernelPath, + cmd: + "echo nproc=$(nproc); " + + "echo cpuinfo=$(grep -c '^processor' /proc/cpuinfo); " + + "test \"$(nproc)\" = 2; " + + "test \"$(grep -c '^processor' /proc/cpuinfo)\" = 2; " + + "echo node-vmm-hvf-smp-ok", + memMiB: 384, + cpus: 2, + network: "none", + diskMiB: 512, + cacheDir: path.join(cacheDir, "oci"), + timeoutMs: 90_000, + }); + assertIncludes(result.console, "nproc=2", "nproc output"); + assertIncludes(result.console, "cpuinfo=2", "cpuinfo output"); + assertIncludes(result.console, "node-vmm-hvf-smp-ok", "SMP marker"); +}); + +// 8. Networking: slirp is the default macOS --net auto path. Direct vmnet still +// requires Apple's networking entitlement, so that remains gated below. +const netRootfs = path.join(baseDir, "net.ext4"); +const netTemp = path.join(baseDir, "net-temp"); +mkdirSync(netTemp, { recursive: true }); + +await test("build slirp network test rootfs", async () => { + await buildRootfs({ + image: "alpine:3.20", + output: netRootfs, + diskMiB: 512, + buildArgs: {}, + env: {}, + cmd: + "ip addr show eth0; ip route; cat /etc/resolv.conf; " + + "echo virtio_net_count=$(for f in /sys/bus/virtio/devices/*/device; do [ -e \"$f\" ] || continue; cat \"$f\"; done | grep -c '^0x0001$'); " + + "wget -qO- http://example.com | head -c 32; echo; echo slirp-net-ok", + tempDir: netTemp, + cacheDir: path.join(cacheDir, "oci"), + platformArch: "arm64", + }); +}); + +await test("runImage with --net auto uses slirp, gets IP/DNS, and reaches the internet", async () => { + await withEnv("NODE_VMM_HVF_NET_BACKEND", undefined, async () => { + const result = await runImage({ + kernelPath, + rootfsPath: netRootfs, + memMiB: 256, + cpus: 1, + network: "auto", + timeoutMs: 90_000, + }); + assertIncludes(result.console, "slirp-net-ok", "network command output"); + assertIncludes(result.console, "10.0.2.15/24", "guest IPv4 address"); + assertIncludes(result.console, "default via 10.0.2.2", "guest default route"); + assertIncludes(result.console, "nameserver 10.0.2.3", "guest resolver"); + assertIncludes(result.console, "virtio_net_count=1", "guest virtio-net device count"); + assertIncludes(result.console.toLowerCase(), "", "HTTP output"); + }); +}); + +await test("runImage can apk add htop nodejs npm over slirp", async () => { + await withEnv("NODE_VMM_HVF_NET_BACKEND", undefined, async () => { + const result = await runImage({ + image: "alpine:3.20", + kernelPath, + cmd: + "apk update && apk add --no-cache htop nodejs npm && " + + "command -v htop && node --version && npm --version && " + + "node -e \"console.log('node-vmm-node-e-ok', process.arch)\" && " + + "echo node-vmm-apk-install-ok", + memMiB: 256, + cpus: 1, + network: "auto", + diskMiB: 512, + cacheDir: path.join(cacheDir, "oci"), + timeoutMs: 120_000, + }); + assertIncludes(result.console, "OK:", "apk output"); + assertIncludes(result.console, "v20.", "node package version"); + assertIncludes(result.console, "node-vmm-node-e-ok arm64", "node -e output"); + assertIncludes(result.console, "node-vmm-apk-install-ok", "apk install marker"); + }); +}); + +await test("runCode on node:22-alpine works with --net auto", async () => { + await withEnv("NODE_VMM_HVF_NET_BACKEND", undefined, async () => { + const result = await runCode({ + image: "node:22-alpine", + kernelPath, + code: "console.log('node-vmm-node-image-ok', process.arch, typeof fetch)", + network: "auto", + diskMiB: 768, + cacheDir: path.join(cacheDir, "oci"), + timeoutMs: 120_000, + }); + assertIncludes(result.console, "node-vmm-node-image-ok arm64 function", "node runCode output"); + }); +}); + +await test("startVm slirp port forwarding pauses, resumes, and stops with host-stop", async () => { + await withEnv("NODE_VMM_HVF_NET_BACKEND", undefined, async () => { + const vm = await startVm({ + image: "node:22-alpine", + kernelPath, + cmd: "node -e \"require('http').createServer((req,res)=>res.end('hvf-slirp-port-ok')).listen(3000,'0.0.0.0')\"", + memMiB: 256, + cpus: 1, + network: "auto", + ports: ["0:3000"], + diskMiB: 768, + cacheDir: path.join(cacheDir, "oci"), + timeoutMs: 0, + }); + + try { + const port = vm.network.ports?.[0]?.hostPort; + assert(port && port > 0, "published host port is allocated"); + const url = `http://127.0.0.1:${port}/`; + const body = await waitForHttpText(url, "hvf-slirp-port-ok"); + assertIncludes(body, "hvf-slirp-port-ok", "host forwarded HTTP response"); + + await vm.pause(); + assert(vm.state() === "paused", `state is paused (got ${vm.state()})`); + await assertHttpUnavailable(url); + + await vm.resume(); + assert(vm.state() === "running", `state is running (got ${vm.state()})`); + const resumedBody = await waitForHttpText(url, "hvf-slirp-port-ok"); + assertIncludes(resumedBody, "hvf-slirp-port-ok", "resumed HTTP response"); + + const stopped = await vm.stop(); + assert(stopped.exitReason === "host-stop", `exitReason is host-stop (got ${stopped.exitReason})`); + assert(vm.state() === "exited", `state is exited (got ${vm.state()})`); + } finally { + if (vm.state() !== "exited") { + await vm.stop().catch(() => undefined); + await vm.wait().catch(() => undefined); + } + } + }); +}); + +if (process.env.NODE_VMM_HVF_TEST_VMNET === "1") { + await test("runImage with --net auto uses vmnet and shows network interface", async () => { + await withEnv("NODE_VMM_HVF_NET_BACKEND", "vmnet", async () => { + const result = await runImage({ + kernelPath, + rootfsPath: netRootfs, + memMiB: 256, + cpus: 1, + network: "auto", + timeoutMs: 60_000, + }); + assertIncludes(result.console, "slirp-net-ok", "network command output"); + }); + }); +} else { + process.stdout.write(" - skipped vmnet networking (set NODE_VMM_HVF_TEST_VMNET=1 to run)\n"); +} + +// 9. startVm / stop lifecycle +await test("startVm starts two vCPUs and stop() terminates cleanly", async () => { + const vm = await startVm({ + kernelPath, + rootfsPath, + memMiB: 384, + cpus: 2, + network: "none", + timeoutMs: 0, // no timeout — we will stop it + }); + + assert(typeof vm.state === "function", "vm.state is function"); + assert(typeof vm.stop === "function", "vm.stop is function"); + assert(typeof vm.wait === "function", "vm.wait is function"); + + // Give the VM a moment to start booting + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + // Stop the VM + await vm.stop(); + const result = await vm.wait(); + + assert(typeof result.exitReason === "string", "exitReason is string"); + assert(vm.state() === "exited", `state is exited (got ${vm.state()})`); +}); + +// ── Cleanup ─────────────────────────────────────────────────────────────────── +try { + await rm(baseDir, { recursive: true, force: true }); +} catch { /* best-effort */ } + +// ── Results ─────────────────────────────────────────────────────────────────── +console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`); +if (failed > 0) { + console.error("Failures:"); + for (const { name, error } of errors) { + console.error(` ✗ ${name}: ${error?.stack || error}`); + } + process.exit(1); +} +console.log("All HVF E2E tests passed."); diff --git a/scripts/vendor-libslirp.mjs b/scripts/vendor-libslirp.mjs new file mode 100644 index 0000000..bfd0013 --- /dev/null +++ b/scripts/vendor-libslirp.mjs @@ -0,0 +1,163 @@ +// Vendors libslirp + glib runtime DLLs from MSYS2 mingw64 packages and +// generates an MSVC-compatible import library so the WHP backend can link +// libslirp on Windows hosts where vcpkg's glib build can't run (Smart App +// Control / WDAC blocks the meson runtime probes - see docs/windows.md). +// +// The downloaded packages are MSYS2's official prebuilt mingw-w64-x86_64 +// archives, which carry the same reputation/signing footprint as anything +// installed through pacman; SAC accepts them without per-binary exemptions +// because nothing here compiles a fresh .exe at install time. +import { existsSync, mkdirSync, copyFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const vendorRoot = path.join(repoRoot, "third_party", "libslirp"); +const downloadDir = path.join(repoRoot, ".vendor-cache", "msys2"); +const extractDir = path.join(downloadDir, "extracted"); + +const MIRROR = "https://repo.msys2.org/mingw/mingw64"; + +// Pinned MSYS2 versions known to ship with the libslirp + glib runtimes the +// WHP backend needs. Bump together when refreshing. +const PACKAGES = [ + "mingw-w64-x86_64-libslirp-4.9.1-2-any.pkg.tar.zst", + "mingw-w64-x86_64-glib2-2.86.4-1-any.pkg.tar.zst", + "mingw-w64-x86_64-libiconv-1.18-1-any.pkg.tar.zst", + "mingw-w64-x86_64-libffi-3.5.2-1-any.pkg.tar.zst", + "mingw-w64-x86_64-pcre2-10.46-1-any.pkg.tar.zst", + "mingw-w64-x86_64-zlib-1.3.2-2-any.pkg.tar.zst", + "mingw-w64-x86_64-libwinpthread-git-12.0.0.r747.g1a99f8514-1-any.pkg.tar.zst", + "mingw-w64-x86_64-gcc-libs-15.2.0-14-any.pkg.tar.zst", + "mingw-w64-x86_64-gettext-runtime-0.26-2-any.pkg.tar.zst", +]; + +// Runtime DLLs to copy into third_party/libslirp/bin/x64-windows. The set is +// the transitive closure of libslirp-0.dll + libglib-2.0-0.dll on Windows; +// if a future MSYS2 update introduces or drops a transitive dep, refresh +// this list with `dumpbin /dependents` against libslirp-0.dll and glib. +const RUNTIME_DLLS = [ + "libslirp-0.dll", + "libglib-2.0-0.dll", + "libintl-8.dll", + "libiconv-2.dll", + "libpcre2-8-0.dll", + "libwinpthread-1.dll", + "libgcc_s_seh-1.dll", + "libcharset-1.dll", +]; + +function run(cmd, args, opts = {}) { + const result = spawnSync(cmd, args, { stdio: "inherit", shell: false, ...opts }); + if ((result.status ?? 1) !== 0) { + throw new Error(`${cmd} ${args.join(" ")} exited with ${result.status}`); + } +} + +function ensureDir(dir) { + mkdirSync(dir, { recursive: true }); +} + +function findVsTool(name) { + const candidates = [ + `C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/VC/Tools/MSVC`, + `C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Tools/MSVC`, + `C:/Program Files (x86)/Microsoft Visual Studio/2022/Community/VC/Tools/MSVC`, + `C:/Program Files/Microsoft Visual Studio/2022/Professional/VC/Tools/MSVC`, + `C:/Program Files/Microsoft Visual Studio/2022/Enterprise/VC/Tools/MSVC`, + ]; + for (const root of candidates) { + if (!existsSync(root)) continue; + const versions = spawnSync("cmd", ["/c", `dir /b "${root.replaceAll("/", "\\")}"`], { encoding: "utf8" }); + const list = (versions.stdout || "").split(/\r?\n/).filter(Boolean).sort().reverse(); + for (const ver of list) { + const candidate = path.join(root, ver, "bin", "Hostx64", "x64", name); + if (existsSync(candidate)) { + return candidate; + } + } + } + throw new Error(`Could not locate ${name}; install Visual Studio Build Tools 2022 with the C++ workload.`); +} + +async function main() { + if (process.platform !== "win32") { + console.log("vendor-libslirp.mjs only runs on Windows; skipping"); + return; + } + + ensureDir(downloadDir); + ensureDir(extractDir); + + for (const pkg of PACKAGES) { + const archive = path.join(downloadDir, pkg); + if (!existsSync(archive)) { + console.log(`download: ${pkg}`); + run("curl", ["-fsSL", "-o", archive, `${MIRROR}/${pkg}`]); + } + console.log(`extract: ${pkg}`); + run("C:/Windows/System32/tar.exe", ["-xf", archive, "-C", extractDir]); + } + + const mingwBin = path.join(extractDir, "mingw64", "bin"); + const mingwInclude = path.join(extractDir, "mingw64", "include", "slirp"); + + ensureDir(path.join(vendorRoot, "include")); + ensureDir(path.join(vendorRoot, "bin", "x64-windows")); + ensureDir(path.join(vendorRoot, "lib", "x64-windows")); + + for (const header of ["libslirp.h", "libslirp-version.h"]) { + copyFileSync(path.join(mingwInclude, header), path.join(vendorRoot, "include", header)); + } + for (const dll of RUNTIME_DLLS) { + const src = path.join(mingwBin, dll); + if (existsSync(src)) { + copyFileSync(src, path.join(vendorRoot, "bin", "x64-windows", dll)); + } else { + console.warn(`note: ${dll} missing in MSYS2 archive (pinned versions may have shuffled deps)`); + } + } + + const dumpbin = findVsTool("dumpbin.exe"); + const lib = findVsTool("lib.exe"); + const dllPath = path.join(vendorRoot, "bin", "x64-windows", "libslirp-0.dll"); + const defPath = path.join(vendorRoot, "lib", "x64-windows", "libslirp.def"); + const libPath = path.join(vendorRoot, "lib", "x64-windows", "libslirp.lib"); + + console.log("dumpbin: /exports libslirp-0.dll"); + const dump = spawnSync(dumpbin, ["/exports", dllPath], { encoding: "utf8" }); + if ((dump.status ?? 1) !== 0) { + throw new Error(`dumpbin failed: ${dump.stderr || dump.stdout}`); + } + + const lines = ["LIBRARY libslirp-0.dll", "EXPORTS"]; + let inTable = false; + for (const line of dump.stdout.split(/\r?\n/)) { + if (/^\s+ordinal\s+hint/.test(line)) { + inTable = true; + continue; + } + if (/^\s*Summary/.test(line)) { + inTable = false; + } + if (!inTable) continue; + const parts = line.trim().split(/\s+/); + if (parts.length === 4 && /^slirp_/.test(parts[3])) { + lines.push(` ${parts[3]}`); + } + } + const fs = await import("node:fs/promises"); + await fs.writeFile(defPath, lines.join("\r\n") + "\r\n", "utf8"); + console.log(`def: ${lines.length - 2} exports`); + + console.log("lib: /def libslirp.def -> libslirp.lib"); + run(lib, [`/def:${defPath}`, "/machine:x64", `/out:${libPath}`]); + console.log(`vendored libslirp into ${vendorRoot}`); +} + +main().catch((err) => { + console.error(err.message || err); + process.exitCode = 1; +}); diff --git a/scripts/whp-app-smoke.mjs b/scripts/whp-app-smoke.mjs new file mode 100644 index 0000000..0d790dc --- /dev/null +++ b/scripts/whp-app-smoke.mjs @@ -0,0 +1,294 @@ +import { mkdir, rm } from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { startVm } from "../dist/src/index.js"; + +const defaultCases = "plain-node,express,fastify,next-hello-world,vite-react,vite-vue"; +const selectedCases = new Set( + (process.env.NODE_VMM_WHP_APP_CASES || defaultCases) + .split(",") + .map((item) => item.trim()) + .filter(Boolean), +); +const base = + process.env.NODE_VMM_WHP_APP_BASE || + path.join(os.tmpdir(), `node-vmm-whp-apps-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`); +const keepBase = process.env.NODE_VMM_WHP_APP_KEEP === "1"; +const ociCache = path.join(base, "oci-cache"); +const cpus = Number.parseInt(process.env.NODE_VMM_WHP_APP_CPUS || "2", 10); +const image = process.env.NODE_VMM_WHP_APP_IMAGE || "node:22-alpine"; +const createNextAppVersion = "16.2.4"; +const createViteVersion = "9.0.6"; +const deps = { + express: "5.2.1", + fastify: "5.8.5", +}; +const results = []; + +if (process.platform !== "win32" && process.env.NODE_VMM_WHP_APP_ALLOW_NON_WINDOWS !== "1") { + throw new Error("test:whp-apps is intended for Windows/WHP; set NODE_VMM_WHP_APP_ALLOW_NON_WINDOWS=1 to override"); +} + +if (!Number.isInteger(cpus) || cpus < 1 || cpus > 64) { + throw new Error("NODE_VMM_WHP_APP_CPUS must be an integer from 1 to 64"); +} + +function sh(value) { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function nodeEval(source) { + return `node -e ${sh(source)}`; +} + +function npmEnv() { + return [ + "export npm_config_audit=false", + "export npm_config_fund=false", + "export npm_config_update_notifier=false", + "export npm_config_cache=/tmp/npm-cache", + ]; +} + +function appPrelude() { + return [...npmEnv(), "mkdir -p /app /tmp/npm-cache", "cd /app"]; +} + +function joinCommands(commands) { + return commands.join(" && "); +} + +function requestText(port, timeoutMs = 1000) { + return new Promise((resolve, reject) => { + const request = http.request( + { + host: "127.0.0.1", + port, + path: "/", + method: "GET", + agent: false, + timeout: timeoutMs, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => resolve({ body, statusCode: response.statusCode || 0 })); + }, + ); + request.on("timeout", () => { + request.destroy(new Error("request timed out")); + }); + request.on("error", reject); + request.end(); + }); +} + +async function waitHttp(port, marker, timeoutMs) { + const start = Date.now(); + let lastError; + while (Date.now() - start < timeoutMs) { + try { + const response = await requestText(port, 1000); + if (response.statusCode >= 200 && response.statusCode < 500) { + const htmlMarker = marker(response.body); + if (!htmlMarker) { + throw new Error(`HTTP body did not include expected marker; status=${response.statusCode}`); + } + return { ...response, htmlMarker, ms: Date.now() - start }; + } + throw new Error(`unexpected HTTP status: ${response.statusCode}`); + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw lastError || new Error(`HTTP did not become ready on ${port}`); +} + +async function pauseResume(vm, port) { + const pauseStart = Date.now(); + await vm.pause(); + const pauseMs = Date.now() - pauseStart; + let pausedHttpBlocked = false; + try { + await requestText(port, 250); + } catch { + pausedHttpBlocked = true; + } + if (!pausedHttpBlocked) { + throw new Error("HTTP request unexpectedly succeeded while VM was paused"); + } + const resumeStart = Date.now(); + await vm.resume(); + await requestText(port, 1000); + return { pauseMs, pausedHttpBlocked, resumeToHttpMs: Date.now() - resumeStart }; +} + +async function runApp(app) { + if (!selectedCases.has(app.name)) { + return; + } + process.stdout.write(`\n=== ${app.name} ===\n`); + const start = Date.now(); + const vm = await startVm( + { + id: `whp-${app.name}`, + image, + cacheDir: ociCache, + disk: app.disk, + memory: app.memory, + cpus, + net: "auto", + ports: ["3000"], + sandbox: true, + timeoutMs: 0, + consoleLimit: 1024 * 1024, + cmd: app.command(), + }, + { logger: (line) => process.stdout.write(`${line}\n`) }, + ); + const port = vm.network.ports?.[0]?.hostPort; + if (!port) { + await vm.stop().catch(() => undefined); + throw new Error(`${app.name} did not publish a host port`); + } + try { + const ready = await waitHttp(port, app.marker, app.timeoutMs); + const lifecycle = await pauseResume(vm, port); + const resumed = await waitHttp(port, app.marker, 15_000); + const stopped = await vm.stop(); + const result = { + app: app.name, + url: `http://127.0.0.1:${port}`, + totalMs: Date.now() - start, + bootToHttpMs: ready.ms, + resumeToHttpMs: lifecycle.resumeToHttpMs, + pauseMs: lifecycle.pauseMs, + pausedHttpBlocked: lifecycle.pausedHttpBlocked, + stopExitReason: stopped.exitReason, + htmlMarker: ready.htmlMarker, + resumedMarker: resumed.htmlMarker, + }; + results.push(result); + process.stdout.write(`\n[whp-app-result] ${JSON.stringify(result)}\n`); + } catch (error) { + const stopped = await vm.stop().catch(() => undefined); + if (stopped?.console) { + process.stdout.write(`\n[${app.name} console]\n${stopped.console.slice(-16000)}\n`); + } + throw error; + } +} + +const apps = [ + { + name: "plain-node", + disk: 2048, + memory: 512, + timeoutMs: 90_000, + command: () => + joinCommands([ + ...appPrelude(), + nodeEval(`require("node:http").createServer((_req, res) => res.end("plain-node-ok\\n")).listen(3000, "0.0.0.0");`), + ]), + marker: (body) => body.includes("plain-node-ok") && "plain-node-ok", + }, + { + name: "express", + disk: 2048, + memory: 768, + timeoutMs: 180_000, + command: () => + joinCommands([ + ...appPrelude(), + "npm init -y >/dev/null", + `npm install express@${deps.express} --ignore-scripts --no-audit --no-fund`, + nodeEval( + `const express = require("express"); const app = express(); app.get("/", (_req, res) => res.type("text").send("express-ok\\n")); app.listen(3000, "0.0.0.0");`, + ), + ]), + marker: (body) => body.includes("express-ok") && "express-ok", + }, + { + name: "fastify", + disk: 2048, + memory: 768, + timeoutMs: 180_000, + command: () => + joinCommands([ + ...appPrelude(), + "npm init -y >/dev/null", + `npm install fastify@${deps.fastify} --ignore-scripts --no-audit --no-fund`, + nodeEval( + `const Fastify = require("fastify"); const app = Fastify(); app.get("/", async () => "fastify-ok\\n"); app.listen({ port: 3000, host: "0.0.0.0" });`, + ), + ]), + marker: (body) => body.includes("fastify-ok") && "fastify-ok", + }, + { + name: "next-hello-world", + disk: 8192, + memory: 2048, + timeoutMs: 900_000, + command: () => + joinCommands([ + ...appPrelude(), + `npm exec --yes -- create-next-app@${createNextAppVersion} app --example hello-world --use-npm --disable-git --yes`, + "cd app", + "NEXT_TELEMETRY_DISABLED=1 npm run build", + "HOSTNAME=0.0.0.0 PORT=3000 npm start -- -H 0.0.0.0 -p 3000", + ]), + marker: (body) => /]*>Hello, Next\.js!<\/h1>/.exec(body)?.[0], + }, + { + name: "vite-react", + disk: 4096, + memory: 1024, + timeoutMs: 600_000, + command: () => + joinCommands([ + ...appPrelude(), + `npm exec --yes -- create-vite@${createViteVersion} app --template react`, + "cd app", + "npm install --ignore-scripts --no-audit --no-fund", + "npm run build", + "npm run preview -- --host 0.0.0.0 --port 3000", + ]), + marker: (body) => /[^<]*<\/title>/.exec(body)?.[0] || (body.includes("/assets/") && "vite-react-html"), + }, + { + name: "vite-vue", + disk: 4096, + memory: 1024, + timeoutMs: 600_000, + command: () => + joinCommands([ + ...appPrelude(), + `npm exec --yes -- create-vite@${createViteVersion} app --template vue`, + "cd app", + "npm install --ignore-scripts --no-audit --no-fund", + "npm run build", + "npm run preview -- --host 0.0.0.0 --port 3000", + ]), + marker: (body) => /<title>[^<]*<\/title>/.exec(body)?.[0] || (body.includes("/assets/") && "vite-vue-html"), + }, +]; + +try { + await mkdir(base, { recursive: true, mode: 0o700 }); + for (const app of apps) { + await runApp(app); + } + process.stdout.write(`\n${JSON.stringify(results, null, 2)}\n`); +} finally { + if (!keepBase) { + await rm(base, { recursive: true, force: true }).catch(() => undefined); + } else { + process.stdout.write(`\n[whp-apps] kept temp dir: ${base}\n`); + } +} diff --git a/src/cli.ts b/src/cli.ts index 1dbc860..8fc806f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,14 +8,18 @@ import { restoreSnapshot, runImage, runCode, + type NodeVmmProgressEvent, type NetworkMode, } from "./index.js"; -import { boolOption, intOption, keyValueOption, parseOptions, stringListOption, stringOption } from "./args.js"; -import { fetchGocrackerKernel, requireKernelPath } from "./kernel.js"; +import { boolOption, intOption, keyValueOption, parseOptions, stringListOption, stringOption, type ParsedArgs } from "./args.js"; +import { parsePrebuiltRootfsMode } from "./disk.js"; +import { fetchNodeVmmKernel, requireKernelPath } from "./kernel.js"; import { NodeVmmError } from "./utils.js"; -const BOOL_FLAGS = new Set(["help", "interactive", "wait", "restore", "sandbox", "keep-overlay", "fast-exit", "force"]); +const BOOL_FLAGS = new Set(["help", "interactive", "wait", "restore", "sandbox", "keep-overlay", "fast-exit", "force", "reset"]); const VALUE_FLAGS = new Set([ + "attach-disk", + "attach-disk-ro", "arch", "boot-args", "build-arg", @@ -25,6 +29,8 @@ const VALUE_FLAGS = new Set([ "code", "context", "disk", + "disk-path", + "disk-size", "disk-image", "dockerfile", "dockerfile-run-timeout-ms", @@ -43,6 +49,8 @@ const VALUE_FLAGS = new Set([ "overlay", "overlay-dir", "port", + "prebuilt", + "persist", "publish", "ref", "repo", @@ -68,8 +76,8 @@ Commands: repo Shortcut for build/run/code with a Git repository URL snapshot Create or restore a core snapshot bundle kernel Find or download the default guest kernel - features Print supported KVM backend features - doctor Check host dependencies + features Print supported backend features + doctor Report host dependencies and known limits version Print version Run examples: @@ -90,13 +98,21 @@ Common flags: --subdir DIR repository subdirectory to use as build context --dockerfile-run-timeout-ms MS max time for each Dockerfile RUN (default: 300000) - --disk PATH rootfs size for build/run, or disk path for boot + --disk-size MIB root disk size for build/run (preferred) + --disk-path PATH explicit writable root disk path for run + --disk PATH|MIB disk path for run/boot, or legacy numeric size for build/run + --prebuilt MODE auto|off|require for release rootfs downloads (default: auto) + --persist NAME create/reuse a cache-managed persistent root disk + --reset recreate the persistent/explicit root disk from the selected image + --attach-disk PATH attach an extra read-write raw disk as /dev/vdb, repeatable + --attach-disk-ro PATH + attach an extra read-only raw disk, repeatable --output PATH output rootfs path for build --cmd STRING command to run in the guest via /bin/sh -lc --code STRING source code for the code command --js STRING JavaScript source alias for --code --language NAME javascript|typescript|shell for the code command - --interactive connect a terminal TTY; Ctrl-C stops the VM + --interactive connect a terminal TTY; use exit or Ctrl-D to stop the VM --sandbox restore-fast mode: write guest disk changes to a temp overlay --restore alias for --sandbox --overlay PATH explicit sparse overlay path for --sandbox @@ -110,7 +126,8 @@ Common flags: --arch ARCH OCI architecture (default: host arch) --mem MIB guest memory (default: 256) --cpus N vCPU count, 1-64 (default: 1) - --net auto|none|tap network mode (default: auto) + --net auto|none|tap|slirp + network mode (default: auto; WHP/HVF auto resolves to slirp) --tap NAME existing/created TAP name for --net tap or auto -p, --publish SPEC Docker-style TCP publish: [IP:]HOST:CONTAINER[/tcp] --port SPEC alias for --publish, repeatable @@ -124,6 +141,59 @@ function print(message: string): void { process.stdout.write(`${message}\n`); } +function createGuestConsoleLoading(enabled: boolean): { + logger(message: string): void; + progress(event: NodeVmmProgressEvent): void; + stop(): void; +} { + let active = false; + let started = 0; + let timer: ReturnType<typeof setInterval> | undefined; + const shouldShow = enabled && process.stderr.isTTY && process.env.NODE_VMM_NO_LOADING !== "1"; + + const write = (message: string): void => { + process.stderr.write(`${message}\n`); + }; + const stop = (): void => { + active = false; + if (timer) { + clearInterval(timer); + timer = undefined; + } + }; + const start = (): void => { + if (!shouldShow || active) { + return; + } + active = true; + started = Date.now(); + write("node-vmm loading: booting guest, waiting for console output..."); + timer = setInterval(() => { + if (!active) { + return; + } + const elapsed = Math.max(1, Math.round((Date.now() - started) / 1000)); + write(`node-vmm loading: still booting guest (${elapsed}s)`); + }, 2000); + timer.unref?.(); + }; + + return { + logger(message: string): void { + print(message); + if (message.includes("console enabled;")) { + start(); + } + }, + progress(event: NodeVmmProgressEvent): void { + if (event.type === "guest-console-ready") { + stop(); + } + }, + stop, + }; +} + function normalizeShortFlags(args: string[]): string[] { const normalized: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -161,16 +231,60 @@ function timeoutOption(parsed: ReturnType<typeof parseCommon>): number | undefin return parsed.values.has("timeout-ms") ? intOption(parsed, "timeout-ms", 60000) : undefined; } -function networkOption(parsed: ReturnType<typeof parseCommon>): NetworkMode { +export function networkOption(parsed: ParsedArgs): NetworkMode | undefined { + if (!parsed.values.has("net")) { + return undefined; + } const network = stringOption(parsed, "net", "auto"); - if (!["auto", "none", "tap"].includes(network)) { - throw new NodeVmmError("--net must be auto, none, or tap"); + if (!["auto", "none", "tap", "slirp"].includes(network)) { + throw new NodeVmmError("--net must be auto, none, tap, or slirp"); } return network as NetworkMode; } +function looksLikeNonNegativeInteger(value: string): boolean { + return /^(0|[1-9][0-9]*)$/.test(value.trim()); +} + +function validateDiskSizeMiB(value: number, flag: string): number { + if (value < 1) { + throw new NodeVmmError(`${flag} must be at least 1 MiB`); + } + return value; +} + function diskSizeOption(parsed: ReturnType<typeof parseCommon>, fallback: number): number { - return intOption(parsed, "disk", fallback); + if (parsed.values.has("disk-size")) { + return validateDiskSizeMiB(intOption(parsed, "disk-size", fallback), "--disk-size"); + } + const disk = stringOption(parsed, "disk"); + if (disk && looksLikeNonNegativeInteger(disk)) { + return validateDiskSizeMiB(Number.parseInt(disk, 10), "--disk"); + } + return fallback; +} + +function runDiskPathOption(parsed: ReturnType<typeof parseCommon>): string | undefined { + const diskPath = stringOption(parsed, "disk-path"); + if (diskPath) { + return diskPath; + } + const disk = stringOption(parsed, "disk"); + if (!disk || looksLikeNonNegativeInteger(disk)) { + return undefined; + } + return disk; +} + +function prebuiltOption(parsed: ReturnType<typeof parseCommon>) { + return parsePrebuiltRootfsMode(stringOption(parsed, "prebuilt") || undefined); +} + +function attachDiskOptions(parsed: ReturnType<typeof parseCommon>) { + return [ + ...stringListOption(parsed, "attach-disk").map((diskPath) => ({ path: diskPath })), + ...stringListOption(parsed, "attach-disk-ro").map((diskPath) => ({ path: diskPath, readonly: true })), + ]; } function portOptions(parsed: ReturnType<typeof parseCommon>): string[] { @@ -187,7 +301,7 @@ function printRunResult(result: Awaited<ReturnType<typeof runImage>>): void { if (result.guestStatus && result.guestStatus !== 0) { process.exitCode = result.guestStatus; } - print(`node-vmm ${result.id} stopped: ${result.exitReason} after ${result.runs} KVM_RUN calls`); + print(`node-vmm ${result.id} stopped: ${result.exitReason} after ${result.runs} VM run calls`); } async function commandBuild(args: string[]): Promise<void> { @@ -205,7 +319,7 @@ async function commandBuild(args: string[]): Promise<void> { subdir: stringOption(parsed, "subdir") || undefined, contextDir: stringOption(parsed, "context", "."), output, - diskMiB: diskSizeOption(parsed, 2048), + diskSizeMiB: diskSizeOption(parsed, 2048), buildArgs: keyValueOption(parsed, "build-arg"), env: keyValueOption(parsed, "env"), cmd: stringOption(parsed, "cmd") || undefined, @@ -223,55 +337,75 @@ async function commandBuild(args: string[]): Promise<void> { async function commandRun(args: string[]): Promise<void> { const parsed = parseCommon(args); + const interactive = boolOption(parsed, "interactive") || undefined; + const loading = createGuestConsoleLoading(Boolean(interactive)); + const diskPath = runDiskPathOption(parsed); + const diskSizeMiB = diskSizeOption(parsed, 2048); + const attachDisks = attachDiskOptions(parsed); + const network = networkOption(parsed); + const timeoutMs = timeoutOption(parsed); const kernel = await requireKernelPath({ kernel: stringOption(parsed, "kernel") || undefined }); - const result = await runImage( - { - id: stringOption(parsed, "id") || undefined, - kernelPath: kernel, - image: stringOption(parsed, "image") || undefined, - dockerfile: stringOption(parsed, "dockerfile") || undefined, - repo: stringOption(parsed, "repo") || undefined, - ref: stringOption(parsed, "ref") || undefined, - subdir: stringOption(parsed, "subdir") || undefined, - contextDir: stringOption(parsed, "context", "."), - rootfsPath: stringOption(parsed, "rootfs") || undefined, - diskMiB: diskSizeOption(parsed, 2048), - buildArgs: keyValueOption(parsed, "build-arg"), - env: keyValueOption(parsed, "env"), - cmd: stringOption(parsed, "cmd") || undefined, - entrypoint: stringOption(parsed, "entrypoint") || undefined, - workdir: stringOption(parsed, "workdir") || undefined, - initMode: boolOption(parsed, "interactive") ? "interactive" : undefined, - cacheDir: stringOption(parsed, "cache-dir") || undefined, - platformArch: stringOption(parsed, "arch") || undefined, - dockerfileRunTimeoutMs: parsed.values.has("dockerfile-run-timeout-ms") - ? intOption(parsed, "dockerfile-run-timeout-ms", 300000) - : undefined, - memMiB: intOption(parsed, "mem", 256), - cpus: intOption(parsed, "cpus", 1), - network: networkOption(parsed), - tapName: stringOption(parsed, "tap") || undefined, - ports: portOptions(parsed), - cmdline: stringOption(parsed, "cmdline") || undefined, - bootArgs: stringOption(parsed, "boot-args") || undefined, - timeoutMs: timeoutOption(parsed), - interactive: boolOption(parsed, "interactive") || undefined, - sandbox: aliasBoolOption(parsed, "sandbox"), - restore: aliasBoolOption(parsed, "restore"), - overlayPath: stringOption(parsed, "overlay") || undefined, - overlayDir: stringOption(parsed, "overlay-dir") || undefined, - keepOverlay: boolOption(parsed, "keep-overlay"), - fastExit: boolOption(parsed, "fast-exit"), - }, - { logger: print }, - ); - - printRunResult(result); + try { + const result = await runImage( + { + id: stringOption(parsed, "id") || undefined, + kernelPath: kernel, + image: stringOption(parsed, "image") || undefined, + dockerfile: stringOption(parsed, "dockerfile") || undefined, + repo: stringOption(parsed, "repo") || undefined, + ref: stringOption(parsed, "ref") || undefined, + subdir: stringOption(parsed, "subdir") || undefined, + contextDir: stringOption(parsed, "context", "."), + rootfsPath: stringOption(parsed, "rootfs") || undefined, + diskPath, + diskSizeMiB, + prebuilt: prebuiltOption(parsed), + persist: stringOption(parsed, "persist") || undefined, + reset: boolOption(parsed, "reset") || undefined, + attachDisks, + buildArgs: keyValueOption(parsed, "build-arg"), + env: keyValueOption(parsed, "env"), + cmd: stringOption(parsed, "cmd") || undefined, + entrypoint: stringOption(parsed, "entrypoint") || undefined, + workdir: stringOption(parsed, "workdir") || undefined, + initMode: boolOption(parsed, "interactive") ? "interactive" : undefined, + cacheDir: stringOption(parsed, "cache-dir") || undefined, + platformArch: stringOption(parsed, "arch") || undefined, + dockerfileRunTimeoutMs: parsed.values.has("dockerfile-run-timeout-ms") + ? intOption(parsed, "dockerfile-run-timeout-ms", 300000) + : undefined, + memMiB: intOption(parsed, "mem", 256), + cpus: intOption(parsed, "cpus", 1), + network, + tapName: stringOption(parsed, "tap") || undefined, + ports: portOptions(parsed), + cmdline: stringOption(parsed, "cmdline") || undefined, + bootArgs: stringOption(parsed, "boot-args") || undefined, + timeoutMs, + interactive, + sandbox: aliasBoolOption(parsed, "sandbox"), + restore: aliasBoolOption(parsed, "restore"), + overlayPath: stringOption(parsed, "overlay") || undefined, + overlayDir: stringOption(parsed, "overlay-dir") || undefined, + keepOverlay: boolOption(parsed, "keep-overlay"), + fastExit: boolOption(parsed, "fast-exit"), + }, + { logger: loading.logger, progress: loading.progress }, + ); + printRunResult(result); + } finally { + loading.stop(); + } } async function commandCode(args: string[]): Promise<void> { const parsed = parseCommon(args); + const diskPath = runDiskPathOption(parsed); + const diskSizeMiB = diskSizeOption(parsed, 2048); + const attachDisks = attachDiskOptions(parsed); + const network = networkOption(parsed); + const timeoutMs = timeoutOption(parsed); const kernel = await requireKernelPath({ kernel: stringOption(parsed, "kernel") || undefined }); const source = stringOption(parsed, "code") || stringOption(parsed, "js"); if (!source) { @@ -292,7 +426,12 @@ async function commandCode(args: string[]): Promise<void> { subdir: stringOption(parsed, "subdir") || undefined, contextDir: stringOption(parsed, "context", "."), rootfsPath: stringOption(parsed, "rootfs") || undefined, - diskMiB: diskSizeOption(parsed, 2048), + diskPath, + diskSizeMiB, + prebuilt: prebuiltOption(parsed), + persist: stringOption(parsed, "persist") || undefined, + reset: boolOption(parsed, "reset") || undefined, + attachDisks, buildArgs: keyValueOption(parsed, "build-arg"), env: keyValueOption(parsed, "env"), code: source, @@ -307,12 +446,12 @@ async function commandCode(args: string[]): Promise<void> { : undefined, memMiB: intOption(parsed, "mem", 512), cpus: intOption(parsed, "cpus", 1), - network: networkOption(parsed), + network, tapName: stringOption(parsed, "tap") || undefined, ports: portOptions(parsed), cmdline: stringOption(parsed, "cmdline") || undefined, bootArgs: stringOption(parsed, "boot-args") || undefined, - timeoutMs: timeoutOption(parsed), + timeoutMs, interactive: false, sandbox: aliasBoolOption(parsed, "sandbox"), restore: aliasBoolOption(parsed, "restore"), @@ -328,36 +467,44 @@ async function commandCode(args: string[]): Promise<void> { async function commandBoot(args: string[]): Promise<void> { const parsed = parseCommon(args); - const disk = stringOption(parsed, "disk") || stringOption(parsed, "rootfs") || stringOption(parsed, "disk-image"); + const interactive = boolOption(parsed, "interactive"); + const loading = createGuestConsoleLoading(interactive); + const disk = + stringOption(parsed, "disk-path") || stringOption(parsed, "disk") || stringOption(parsed, "rootfs") || stringOption(parsed, "disk-image"); if (!disk) { throw new NodeVmmError("boot requires --disk PATH"); } const kernel = await requireKernelPath({ kernel: stringOption(parsed, "kernel") || undefined }); - const result = await bootRootfs( - { - id: stringOption(parsed, "id") || undefined, - kernelPath: kernel, - diskPath: disk, - memMiB: intOption(parsed, "mem", 256), - cpus: intOption(parsed, "cpus", 1), - network: networkOption(parsed), - tapName: stringOption(parsed, "tap") || undefined, - ports: portOptions(parsed), - cmdline: stringOption(parsed, "cmdline") || undefined, - bootArgs: stringOption(parsed, "boot-args") || undefined, - timeoutMs: timeoutOption(parsed), - interactive: boolOption(parsed, "interactive"), - sandbox: aliasBoolOption(parsed, "sandbox"), - restore: aliasBoolOption(parsed, "restore"), - overlayPath: stringOption(parsed, "overlay") || undefined, - overlayDir: stringOption(parsed, "overlay-dir") || undefined, - keepOverlay: boolOption(parsed, "keep-overlay"), - fastExit: boolOption(parsed, "fast-exit"), - }, - { logger: print }, - ); - printRunResult(result); + try { + const result = await bootRootfs( + { + id: stringOption(parsed, "id") || undefined, + kernelPath: kernel, + diskPath: disk, + attachDisks: attachDiskOptions(parsed), + memMiB: intOption(parsed, "mem", 256), + cpus: intOption(parsed, "cpus", 1), + network: networkOption(parsed), + tapName: stringOption(parsed, "tap") || undefined, + ports: portOptions(parsed), + cmdline: stringOption(parsed, "cmdline") || undefined, + bootArgs: stringOption(parsed, "boot-args") || undefined, + timeoutMs: timeoutOption(parsed), + interactive, + sandbox: aliasBoolOption(parsed, "sandbox"), + restore: aliasBoolOption(parsed, "restore"), + overlayPath: stringOption(parsed, "overlay") || undefined, + overlayDir: stringOption(parsed, "overlay-dir") || undefined, + keepOverlay: boolOption(parsed, "keep-overlay"), + fastExit: boolOption(parsed, "fast-exit"), + }, + { logger: loading.logger, progress: loading.progress }, + ); + printRunResult(result); + } finally { + loading.stop(); + } } async function commandSnapshot(args: string[]): Promise<void> { @@ -368,6 +515,7 @@ async function commandSnapshot(args: string[]): Promise<void> { if (!output) { throw new NodeVmmError("snapshot create requires --output DIR"); } + const diskSizeMiB = diskSizeOption(parsed, 2048); const kernel = await requireKernelPath({ kernel: stringOption(parsed, "kernel") || undefined }); const result = await createSnapshot( { @@ -381,7 +529,8 @@ async function commandSnapshot(args: string[]): Promise<void> { subdir: stringOption(parsed, "subdir") || undefined, contextDir: stringOption(parsed, "context", "."), rootfsPath: stringOption(parsed, "rootfs") || undefined, - diskMiB: diskSizeOption(parsed, 2048), + diskSizeMiB, + prebuilt: prebuiltOption(parsed), buildArgs: keyValueOption(parsed, "build-arg"), env: keyValueOption(parsed, "env"), cmd: stringOption(parsed, "cmd") || undefined, @@ -419,6 +568,7 @@ async function commandSnapshot(args: string[]): Promise<void> { bootArgs: stringOption(parsed, "boot-args") || undefined, timeoutMs: timeoutOption(parsed), interactive: boolOption(parsed, "interactive") || undefined, + attachDisks: attachDiskOptions(parsed), overlayPath: stringOption(parsed, "overlay") || undefined, overlayDir: stringOption(parsed, "overlay-dir") || undefined, keepOverlay: boolOption(parsed, "keep-overlay"), @@ -436,7 +586,7 @@ async function commandKernel(args: string[]): Promise<void> { const [action = "find", ...rest] = args; const parsed = parseCommon(rest); if (action === "fetch") { - const result = await fetchGocrackerKernel({ + const result = await fetchNodeVmmKernel({ name: stringOption(parsed, "name") || undefined, outputDir: stringOption(parsed, "output-dir") || stringOption(parsed, "cache-dir") || undefined, force: boolOption(parsed, "force"), diff --git a/src/disk.ts b/src/disk.ts new file mode 100644 index 0000000..ba24d31 --- /dev/null +++ b/src/disk.ts @@ -0,0 +1,259 @@ +import { copyFile, mkdir, readFile, rename, rm, stat, truncate, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { AttachedDisk, PrebuiltRootfsMode, ResolvedAttachedDisk } from "./types.js"; +import { NodeVmmError, pathExists, randomId } from "./utils.js"; + +const MIB = 1024 * 1024; +const PERSISTENT_DISK_METADATA_VERSION = 1; + +export interface PersistentDiskMetadata { + kind: "node-vmm-persistent-disk"; + version: typeof PERSISTENT_DISK_METADATA_VERSION; + name: string; + sourceKey: string; + sizeMiB: number; + createdAt: string; + updatedAt: string; +} + +export interface MaterializePersistentDiskOptions { + name: string; + baseRootfsPath: string; + cacheDir: string; + sourceKey: string; + diskMiB: number; + reset?: boolean; + logger?: (message: string) => void; +} + +export interface MaterializeExplicitDiskOptions { + diskPath: string; + baseRootfsPath: string; + diskMiB: number; + reset?: boolean; + logger?: (message: string) => void; +} + +export interface MaterializedDisk { + rootfsPath: string; + resized: boolean; + created: boolean; +} + +export function parsePrebuiltRootfsMode(raw: string | undefined): PrebuiltRootfsMode | undefined { + if (raw === undefined || raw === "") { + return undefined; + } + if (raw === "auto" || raw === "off" || raw === "require") { + return raw; + } + throw new NodeVmmError("--prebuilt must be auto, off, or require"); +} + +export function attachedDiskDeviceName(index: number): string { + if (!Number.isInteger(index) || index < 0 || index >= 16) { + throw new NodeVmmError("node-vmm supports up to 16 attached data disks"); + } + return `/dev/vd${String.fromCharCode("b".charCodeAt(0) + index)}`; +} + +export function resolveAttachedDisks(cwd: string, attachDisks: AttachedDisk[] | undefined): ResolvedAttachedDisk[] { + return (attachDisks ?? []).map((disk, index) => { + if (!disk.path) { + throw new NodeVmmError(`attachDisks[${index}].path is required`); + } + return { + path: path.isAbsolute(disk.path) ? disk.path : path.resolve(cwd, disk.path), + readonly: disk.readonly === true, + device: attachedDiskDeviceName(index), + }; + }); +} + +export async function validateAttachedDiskPaths(disks: ResolvedAttachedDisk[]): Promise<void> { + for (const disk of disks) { + const info = await stat(disk.path).catch(() => undefined); + if (!info?.isFile()) { + throw new NodeVmmError(`attached disk does not exist: ${disk.path}`); + } + } +} + +export async function ensureDiskSizeAtLeast(diskPath: string, diskMiB: number): Promise<boolean> { + if (!Number.isInteger(diskMiB) || diskMiB <= 0) { + throw new NodeVmmError("disk size must be a positive integer MiB value"); + } + const targetBytes = diskMiB * MIB; + const info = await stat(diskPath); + if (info.size > targetBytes) { + const currentMiB = Math.ceil(info.size / MIB); + throw new NodeVmmError(`cannot shrink disk ${diskPath} from ${currentMiB} MiB to ${diskMiB} MiB`); + } + if (info.size === targetBytes) { + return false; + } + await truncate(diskPath, targetBytes); + return true; +} + +function validatePersistentName(name: string): void { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)) { + throw new NodeVmmError("--persist must be a simple name using letters, numbers, '.', '_' or '-'"); + } +} + +async function readPersistentMetadata(metaPath: string): Promise<PersistentDiskMetadata | null> { + try { + const parsed = JSON.parse(await readFile(metaPath, "utf8")) as PersistentDiskMetadata; + if (parsed.kind === "node-vmm-persistent-disk" && parsed.version === PERSISTENT_DISK_METADATA_VERSION) { + return parsed; + } + } catch { + return null; + } + return null; +} + +async function writePersistentMetadataAtomic(metaPath: string, metadata: PersistentDiskMetadata): Promise<void> { + const tmp = `${metaPath}.tmp-${process.pid}-${randomId("meta")}`; + try { + await writeFile(tmp, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); + await rename(tmp, metaPath); + } catch (error) { + await rm(tmp, { force: true }).catch(() => {}); + throw error; + } +} + +async function installPersistentDiskPair(options: { + diskPath: string; + metaPath: string; + tmpDiskPath: string; + metadata: PersistentDiskMetadata; +}): Promise<void> { + const dir = path.dirname(options.diskPath); + const id = `${process.pid}-${randomId("replace")}`; + const tmpMetaPath = path.join(dir, `${path.basename(options.metaPath)}.tmp-${id}`); + const backupDiskPath = path.join(dir, `${path.basename(options.diskPath)}.bak-${id}`); + const backupMetaPath = path.join(dir, `${path.basename(options.metaPath)}.bak-${id}`); + let backedDisk = false; + let backedMeta = false; + let targetsCleared = false; + + try { + await writeFile(tmpMetaPath, `${JSON.stringify(options.metadata, null, 2)}\n`, { mode: 0o600 }); + if (await pathExists(options.diskPath)) { + await rename(options.diskPath, backupDiskPath); + backedDisk = true; + } + if (await pathExists(options.metaPath)) { + await rename(options.metaPath, backupMetaPath); + backedMeta = true; + } + targetsCleared = true; + await rename(options.tmpDiskPath, options.diskPath); + await rename(tmpMetaPath, options.metaPath); + await rm(backupDiskPath, { force: true }).catch(() => {}); + await rm(backupMetaPath, { force: true }).catch(() => {}); + } catch (error) { + await rm(options.tmpDiskPath, { force: true }).catch(() => {}); + await rm(tmpMetaPath, { force: true }).catch(() => {}); + if (targetsCleared) { + await rm(options.diskPath, { force: true }).catch(() => {}); + await rm(options.metaPath, { force: true }).catch(() => {}); + } + if (backedDisk) { + await rename(backupDiskPath, options.diskPath).catch(() => {}); + } + if (backedMeta) { + await rename(backupMetaPath, options.metaPath).catch(() => {}); + } + throw error; + } +} + +export async function materializePersistentDisk(options: MaterializePersistentDiskOptions): Promise<MaterializedDisk> { + validatePersistentName(options.name); + const diskDir = path.join(options.cacheDir, "disks"); + await mkdir(diskDir, { recursive: true, mode: 0o700 }); + const diskPath = path.join(diskDir, `${options.name}.ext4`); + const metaPath = path.join(diskDir, `${options.name}.json`); + const diskExists = await pathExists(diskPath); + const meta = await readPersistentMetadata(metaPath); + + if (diskExists && !meta) { + throw new NodeVmmError(`persistent disk ${options.name} exists without node-vmm metadata; use --reset to recreate it`); + } + if (diskExists && meta && meta.sourceKey !== options.sourceKey && !options.reset) { + throw new NodeVmmError(`persistent disk ${options.name} was created from a different image/rootfs; use --reset to recreate it`); + } + + let created = false; + let createdResized = false; + if (!diskExists || options.reset) { + const tmp = path.join(diskDir, `${options.name}.tmp-${process.pid}-${randomId("disk")}.ext4`); + try { + await copyFile(options.baseRootfsPath, tmp); + createdResized = await ensureDiskSizeAtLeast(tmp, options.diskMiB); + const now = new Date().toISOString(); + const nextMeta: PersistentDiskMetadata = { + kind: "node-vmm-persistent-disk", + version: PERSISTENT_DISK_METADATA_VERSION, + name: options.name, + sourceKey: options.sourceKey, + sizeMiB: options.diskMiB, + createdAt: meta?.createdAt ?? now, + updatedAt: now, + }; + await installPersistentDiskPair({ diskPath, metaPath, tmpDiskPath: tmp, metadata: nextMeta }); + options.logger?.(`node-vmm persistent disk ready: ${diskPath}`); + created = true; + } catch (error) { + await rm(tmp, { force: true }).catch(() => {}); + throw error; + } + } + + const resized = (await ensureDiskSizeAtLeast(diskPath, options.diskMiB)) || createdResized; + if (resized || (meta && meta.sizeMiB !== options.diskMiB)) { + const now = new Date().toISOString(); + const nextMeta: PersistentDiskMetadata = { + ...(meta ?? { + kind: "node-vmm-persistent-disk", + version: PERSISTENT_DISK_METADATA_VERSION, + name: options.name, + sourceKey: options.sourceKey, + createdAt: now, + }), + sizeMiB: options.diskMiB, + updatedAt: now, + }; + await writePersistentMetadataAtomic(metaPath, nextMeta); + } + + return { rootfsPath: diskPath, resized, created }; +} + +export async function materializeExplicitDisk(options: MaterializeExplicitDiskOptions): Promise<MaterializedDisk> { + const diskExists = await pathExists(options.diskPath); + let created = false; + let createdResized = false; + if (!diskExists || options.reset) { + await mkdir(path.dirname(options.diskPath), { recursive: true, mode: 0o700 }); + const tmp = `${options.diskPath}.tmp-${process.pid}-${randomId("disk")}`; + try { + await copyFile(options.baseRootfsPath, tmp); + createdResized = await ensureDiskSizeAtLeast(tmp, options.diskMiB); + await rm(options.diskPath, { force: true }); + await rename(tmp, options.diskPath); + options.logger?.(`node-vmm disk ready: ${options.diskPath}`); + created = true; + } catch (error) { + await rm(tmp, { force: true }).catch(() => {}); + throw error; + } + } + const resized = (await ensureDiskSizeAtLeast(options.diskPath, options.diskMiB)) || createdResized; + return { rootfsPath: options.diskPath, resized, created }; +} diff --git a/src/ext4/bitmap.ts b/src/ext4/bitmap.ts new file mode 100644 index 0000000..0b6aa7b --- /dev/null +++ b/src/ext4/bitmap.ts @@ -0,0 +1,63 @@ +/** + * Block + inode bitmap helpers. + * + * Each block group has a 1-block-sized inode bitmap and a 1-block-sized block + * bitmap. Bit `i` set means object `i` (0-indexed within the group) is in use. + * ext4 uses little-endian bit order (LSB first) — same as Linux's `set_bit` + * primitives. + */ + +import { NotImplementedError } from "./superblock.js"; + +/** A single bitmap backed by a Buffer. Length is fixed at construction. */ +export class Bitmap { + readonly buffer: Buffer; + + constructor(public readonly bitCount: number) { + this.buffer = Buffer.alloc(Math.ceil(bitCount / 8)); + } + + /** Mark bit `index` as used. */ + set(_index: number): void { + throw new NotImplementedError("Bitmap.set"); + } + + /** Mark bit `index` as free. */ + clear(_index: number): void { + throw new NotImplementedError("Bitmap.clear"); + } + + /** Test whether bit `index` is set. */ + test(_index: number): boolean { + throw new NotImplementedError("Bitmap.test"); + } + + /** Find first clear bit at or after `start`, or -1 if none. */ + findFirstClear(_start = 0): number { + throw new NotImplementedError("Bitmap.findFirstClear"); + } + + /** Allocate a contiguous run of `count` bits. Returns starting index or -1. */ + allocateRun(_count: number, _start = 0): number { + throw new NotImplementedError("Bitmap.allocateRun"); + } + + /** Population count — number of set bits. */ + popcount(): number { + throw new NotImplementedError("Bitmap.popcount"); + } +} + +/** Convenience wrapper bundling a group's two bitmaps. */ +export interface GroupBitmaps { + blocks: Bitmap; + inodes: Bitmap; +} + +/** Allocate the per-group bitmap pair sized for one block group. */ +export function createGroupBitmaps(blocksPerGroup: number, inodesPerGroup: number): GroupBitmaps { + return { + blocks: new Bitmap(blocksPerGroup), + inodes: new Bitmap(inodesPerGroup), + }; +} diff --git a/src/ext4/dir.ts b/src/ext4/dir.ts new file mode 100644 index 0000000..f3ec629 --- /dev/null +++ b/src/ext4/dir.ts @@ -0,0 +1,78 @@ +/** + * ext4 directory writer — linear (rev 0) and HTREE (`dir_index`) variants. + * + * Linear directories store an array of struct ext4_dir_entry_2 records packed + * into directory data blocks. HTREE directories add a B+tree-like hash index + * keyed on a half-MD4 hash of the entry name; the leaves still hold the same + * record layout. We always emit `.` and `..` first, then sort children by + * insertion order (matching `mkfs.ext4` deterministic builds). + * + * Spec: https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html + */ + +import { NotImplementedError } from "./superblock.js"; +import { FileType } from "./types.js"; + +/** Maximum length of a single directory entry name (255 bytes). */ +export const EXT4_NAME_LEN = 255; + +/** struct ext4_dir_entry_2 has an 8-byte fixed header. */ +export const DIR_ENTRY_HEADER_SIZE = 8; + +/** Half-MD4 hash version selector for HTREE (s_def_hash_version). */ +export enum HashVersion { + Legacy = 0, + HalfMD4 = 1, + Tea = 2, + LegacyUnsigned = 3, + HalfMD4Unsigned = 4, + TeaUnsigned = 5, +} + +/** Directory entry record as stored on disk (post-name-padding). */ +export interface DirEntry { + inode: number; + name: string; + fileType: FileType; +} + +/** + * DirectoryWriter — accumulates entries for one directory inode and emits the + * data blocks during `finalize()`. The writer auto-promotes from linear to + * HTREE once the linear form spills past `blockSize` bytes. + */ +export class DirectoryWriter { + private readonly entries: DirEntry[] = []; + + constructor( + public readonly inodeNumber: number, + public readonly parentInodeNumber: number, + public readonly blockSize: number, + public readonly htreeEnabled: boolean, + ) {} + + /** Add a child entry. Order is preserved for deterministic output. */ + addEntry(_entry: DirEntry): void { + throw new NotImplementedError("DirectoryWriter.addEntry"); + } + + /** Number of entries added (excluding the implicit `.` and `..`). */ + size(): number { + return this.entries.length; + } + + /** Emit the on-disk byte layout — one or more `blockSize`-sized buffers. */ + serialize(): Buffer[] { + throw new NotImplementedError("DirectoryWriter.serialize"); + } + + /** Half-MD4 name hash used for HTREE bucket selection. */ + static hashName(_name: string, _version: HashVersion = HashVersion.HalfMD4): number { + throw new NotImplementedError("DirectoryWriter.hashName"); + } + + /** Compute padded record length: 8 + name_len rounded up to 4 bytes. */ + static recordLength(nameLen: number): number { + return Math.ceil((DIR_ENTRY_HEADER_SIZE + nameLen) / 4) * 4; + } +} diff --git a/src/ext4/group.ts b/src/ext4/group.ts new file mode 100644 index 0000000..422b23c --- /dev/null +++ b/src/ext4/group.ts @@ -0,0 +1,50 @@ +/** + * Block group descriptor table (GDT). + * + * For each block group, ext4 stores a struct ext4_group_desc (32 bytes for + * non-64bit filesystems, 64 bytes when INCOMPAT_64BIT is set). The GDT lives + * immediately after the primary superblock and is replicated in groups 0, 1 + * and powers of 3, 5, 7 when SPARSE_SUPER is on. + * + * Spec: https://www.kernel.org/doc/html/latest/filesystems/ext4/group_descr.html + */ + +import { NotImplementedError } from "./superblock.js"; +import type { BlockGroup } from "./types.js"; + +/** Default group descriptor size (s_desc_size == 0 → legacy 32-byte). */ +export const GROUP_DESC_SIZE = 32; + +/** Build the in-memory list of block groups for a sized image. */ +export function planBlockGroups( + _totalBlocks: number, + _blocksPerGroup: number, + _inodesPerGroup: number, +): BlockGroup[] { + throw new NotImplementedError("planBlockGroups"); +} + +/** + * GroupDescriptorTable — manages the GDT contents and emits the on-disk bytes. + */ +export class GroupDescriptorTable { + constructor(public readonly groups: BlockGroup[]) {} + + /** Total descriptor table size in bytes (rounded to a block). */ + byteSize(): number { + return this.groups.length * GROUP_DESC_SIZE; + } + + /** Serialize the GDT to a Buffer suitable for splicing into the image. */ + serialize(): Buffer { + throw new NotImplementedError("GroupDescriptorTable.serialize"); + } + + /** + * Indices of block groups that hold a backup superblock + GDT copy. With + * SPARSE_SUPER this is groups 0, 1, and powers of 3, 5, 7. + */ + backupGroupIndices(): number[] { + throw new NotImplementedError("GroupDescriptorTable.backupGroupIndices"); + } +} diff --git a/src/ext4/index.ts b/src/ext4/index.ts new file mode 100644 index 0000000..70fc650 --- /dev/null +++ b/src/ext4/index.ts @@ -0,0 +1,38 @@ +/** + * Public entry point for the native ext4 writer (Track D.2.b). + * + * NOTE: This module is intentionally re-exported under the `ext4` namespace + * only — it is NOT part of the stable SDK surface yet. Consumers should + * treat the API as unstable until phase 2 lands real serialization. + */ + +export { Ext4ImageWriter } from "./writer.js"; +export { + Superblock, + NotImplementedError, + EXT4_SUPER_MAGIC, + SUPERBLOCK_OFFSET, + SUPERBLOCK_SIZE, + FsState, + FeatureCompat, + FeatureIncompat, + FeatureRoCompat, +} from "./superblock.js"; +export { Inode, InodeTable, InodeFlags, EXT4_FIRST_INO, EXT4_INODE_SIZE } from "./inode.js"; +export { DirectoryWriter, HashVersion, EXT4_NAME_LEN, DIR_ENTRY_HEADER_SIZE } from "./dir.js"; +export { Bitmap, createGroupBitmaps } from "./bitmap.js"; +export { GroupDescriptorTable, planBlockGroups, GROUP_DESC_SIZE } from "./group.js"; +export type { + BlockGroup, + DeviceSpec, + EntryAttrs, + Ext4WriterOptions, + FileBody, + FinalizeResult, + HardlinkSpec, + Mode, + PendingEntry, + SymlinkSpec, + WhiteoutSpec, +} from "./types.js"; +export { FileType, InodeMode } from "./types.js"; diff --git a/src/ext4/inode.ts b/src/ext4/inode.ts new file mode 100644 index 0000000..fc61309 --- /dev/null +++ b/src/ext4/inode.ts @@ -0,0 +1,104 @@ +/** + * ext4 inode + inode-table management. + * + * struct ext4_inode is 256 bytes when `extra_isize` is in use. The inode + * table is an array of these structs, allocated per block group. Inode 1 is + * reserved (bad-blocks), inode 2 is the root directory; both must be present + * before user-visible entries. + * + * Spec: https://www.kernel.org/doc/html/latest/filesystems/ext4/inodes.html + */ + +import { NotImplementedError } from "./superblock.js"; +import type { EntryAttrs, FileType } from "./types.js"; + +/** ext4 reserves inodes 1..10 for filesystem metadata. */ +export const EXT4_FIRST_INO = 11; + +/** Standard inode size for new ext4 filesystems. */ +export const EXT4_INODE_SIZE = 256; + +/** struct ext4_inode flags (i_flags). */ +export enum InodeFlags { + ExtentsFlag = 0x80000, + HugeFile = 0x40000, + Index = 0x1000, + Immutable = 0x10, + Append = 0x20, +} + +/** struct ext4_extent_header — leaf or index. */ +export interface ExtentHeader { + magic: 0xf30a; + entries: number; + max: number; + depth: number; + generation: number; +} + +/** + * In-memory inode prior to placement in the inode table. The writer fills in + * `i_block` (extent tree or fast-symlink data) during `finalize()`. + */ +export class Inode { + number = 0; + type: FileType; + attrs: EntryAttrs; + size = 0; + blocks: number[] = []; + linksCount = 1; + flags: number = InodeFlags.ExtentsFlag; + /** Major/minor for device nodes; encoded into i_block[0] per kdev_t. */ + devMajor?: number; + devMinor?: number; + /** Fast-symlink data when target ≤ 60 bytes; else stored in extents. */ + fastSymlinkTarget?: string; + + constructor(type: FileType, attrs: EntryAttrs) { + this.type = type; + this.attrs = attrs; + } + + /** Compose i_mode (top 4 bits = file type, bottom 12 = perms). */ + composeMode(): number { + throw new NotImplementedError("Inode.composeMode"); + } + + /** Serialize this inode into a 256-byte slice of the inode-table buffer. */ + serialize(_out: Buffer, _offset: number): void { + throw new NotImplementedError("Inode.serialize"); + } +} + +/** + * InodeTable — owns inode allocation across the image. + * + * Allocation policy mirrors mkfs.ext4: round-robin across block groups so the + * directory and its children land in the same group when possible. + */ +export class InodeTable { + private readonly inodes: Inode[] = []; + private nextNumber = EXT4_FIRST_INO; + + constructor(public readonly inodesPerGroup: number) {} + + /** Allocate a new inode number and register the in-memory record. */ + allocate(_inode: Inode): number { + throw new NotImplementedError("InodeTable.allocate"); + } + + /** Resolve an inode by number (used by hardlink + directory writers). */ + get(_number: number): Inode { + throw new NotImplementedError("InodeTable.get"); + } + + /** Total inodes used so far, including reserved low numbers. */ + count(): number { + return this.nextNumber - 1; + } + + /** Iterate inodes in allocation order — for serialization passes. */ + values(): Iterable<Inode> { + return this.inodes.values(); + } +} diff --git a/src/ext4/superblock.ts b/src/ext4/superblock.ts new file mode 100644 index 0000000..e2de008 --- /dev/null +++ b/src/ext4/superblock.ts @@ -0,0 +1,115 @@ +/** + * ext4 superblock serialization. + * + * The ext4 superblock is a 1024-byte structure (struct ext4_super_block in + * fs/ext4/ext4.h) at offset 1024 of the image. We emit revision 1 with the + * `extents` and `dir_index` features enabled by default and the `has_journal` + * feature explicitly disabled — the rootfs builder does not need a journal. + * + * Spec: https://www.kernel.org/doc/html/latest/filesystems/ext4/super_block.html + */ + +import type { Ext4WriterOptions } from "./types.js"; + +/** ext4 magic number at offset 0x38 (s_magic). */ +export const EXT4_SUPER_MAGIC = 0xef53; + +/** Superblock byte size (always 1024 regardless of block size). */ +export const SUPERBLOCK_SIZE = 1024; + +/** Offset of the primary superblock within the image. */ +export const SUPERBLOCK_OFFSET = 1024; + +/** s_state values (mounted/clean/error). */ +export enum FsState { + Valid = 0x0001, + Error = 0x0002, + OrphanFs = 0x0004, +} + +/** s_feature_compat — readable & writable when unknown. */ +export enum FeatureCompat { + DirPrealloc = 0x0001, + ImagicInodes = 0x0002, + HasJournal = 0x0004, + ExtAttr = 0x0008, + ResizeInode = 0x0010, + DirIndex = 0x0020, +} + +/** s_feature_incompat — refuse mount if unknown. */ +export enum FeatureIncompat { + Compression = 0x0001, + Filetype = 0x0002, + Recover = 0x0004, + JournalDev = 0x0008, + MetaBg = 0x0010, + Extents = 0x0040, + SixtyFour = 0x0080, + MMP = 0x0100, + FlexBg = 0x0200, +} + +/** s_feature_ro_compat — mount read-only if unknown. */ +export enum FeatureRoCompat { + SparseSuper = 0x0001, + LargeFile = 0x0002, + BtreeDir = 0x0004, + HugeFile = 0x0008, + GdtCsum = 0x0010, + DirNlink = 0x0020, + ExtraIsize = 0x0040, + MetadataCsum = 0x0400, +} + +/** + * Superblock builder — caller fills in counts after the layout pass, then + * `serialize()` produces the 1024-byte buffer ready to splice into the image. + */ +export class Superblock { + inodesCount = 0; + blocksCount = 0; + freeBlocksCount = 0; + freeInodesCount = 0; + firstDataBlock = 0; + logBlockSize = 2; // 4096 = 1024 << 2 + blocksPerGroup = 32768; + inodesPerGroup = 0; + mountTime = 0; + writeTime = 0; + state: FsState = FsState.Valid; + rev = 1; + inodeSize = 256; + uuid = "00000000-0000-0000-0000-000000000000"; + label = ""; + featureCompat: number = FeatureCompat.DirIndex; + featureIncompat: number = FeatureIncompat.Filetype | FeatureIncompat.Extents; + featureRoCompat: number = FeatureRoCompat.SparseSuper | FeatureRoCompat.LargeFile | FeatureRoCompat.HugeFile; + + constructor(private readonly options: Required<Ext4WriterOptions>) { + this.logBlockSize = Math.log2(options.blockSize) - 10; + } + + /** Compute group/inode counts from the image size — pure planning, no I/O. */ + plan(): void { + throw new NotImplementedError("Superblock.plan"); + } + + /** Serialize to a 1024-byte buffer at offset 0 of the returned Buffer. */ + serialize(): Buffer { + throw new NotImplementedError("Superblock.serialize"); + } + + /** Parse an existing on-disk superblock (used by the parity round-trip test). */ + static parse(_buf: Buffer): Superblock { + throw new NotImplementedError("Superblock.parse"); + } +} + +/** Sentinel error so callers can branch on stub behavior cleanly. */ +export class NotImplementedError extends Error { + constructor(method: string) { + super(`ext4: ${method} not implemented yet`); + this.name = "NotImplementedError"; + } +} diff --git a/src/ext4/types.ts b/src/ext4/types.ts new file mode 100644 index 0000000..c3467da --- /dev/null +++ b/src/ext4/types.ts @@ -0,0 +1,125 @@ +/** + * Shared types for the native ext4 writer. + * + * Track D.2.b — pure-TypeScript ext4 image construction so Windows hosts no + * longer need WSL2 to build a guest rootfs. See docs/ext4-writer.md for the + * design and parity plan against the existing WSL2-driven path. + * + * References: + * - https://www.kernel.org/doc/html/latest/filesystems/ext4/index.html + * - https://www.kernel.org/doc/html/latest/filesystems/ext4/super_block.html + */ + +/** POSIX permission bits (rwx for owner/group/other plus setuid/setgid/sticky). */ +export type Mode = number; + +/** ext4 file types as encoded in dir_entry_2.file_type. */ +export enum FileType { + Unknown = 0, + RegularFile = 1, + Directory = 2, + CharDevice = 3, + BlockDevice = 4, + Fifo = 5, + Socket = 6, + Symlink = 7, +} + +/** Subset of i_mode top bits the writer cares about. */ +export enum InodeMode { + IFREG = 0o100000, + IFDIR = 0o040000, + IFLNK = 0o120000, + IFCHR = 0o020000, + IFBLK = 0o060000, + IFIFO = 0o010000, + IFSOCK = 0o140000, +} + +/** Filesystem-wide tunables; defaults match `mkfs.ext4 -O ^has_journal`. */ +export interface Ext4WriterOptions { + /** Total image size in bytes; must be a multiple of `blockSize`. */ + sizeBytes: number; + /** Block size in bytes — only 4096 is supported in phase 1. */ + blockSize?: 1024 | 2048 | 4096; + /** Inodes per block group; 0 means "auto" via the standard mkfs heuristic. */ + inodesPerGroup?: number; + /** Volume label written to the superblock (max 16 bytes UTF-8). */ + label?: string; + /** UUID; if omitted a random v4 UUID is generated via node:crypto. */ + uuid?: string; + /** Enable HTREE directory indexing (`dir_index`). Default: true. */ + dirIndex?: boolean; + /** Enable extents (`extents`). Default: true — block maps are not implemented. */ + extents?: boolean; +} + +/** A block group descriptor (subset of the on-disk struct ext4_group_desc). */ +export interface BlockGroup { + index: number; + blockBitmapBlock: number; + inodeBitmapBlock: number; + inodeTableBlock: number; + freeBlocksCount: number; + freeInodesCount: number; + usedDirsCount: number; +} + +/** Resolved owner/permission metadata for a single tree entry. */ +export interface EntryAttrs { + uid: number; + gid: number; + mode: Mode; + /** Seconds since epoch; defaults to image build time. */ + mtime?: number; + atime?: number; + ctime?: number; +} + +/** Add-file payload — a buffer or a host path the writer streams from. */ +export type FileBody = Buffer | { hostPath: string }; + +/** Symlink target stored verbatim; ext4 uses fast-symlink when ≤ 60 bytes. */ +export interface SymlinkSpec { + target: string; + attrs: EntryAttrs; +} + +/** Hardlink request — increments i_links_count on the existing inode. */ +export interface HardlinkSpec { + /** Path to the existing entry already added via `addFile`/`addDir`/etc. */ + existingPath: string; +} + +/** Device node spec; Linux uses (major, minor) → kdev_t for i_block[0]. */ +export interface DeviceSpec { + major: number; + minor: number; + attrs: EntryAttrs; +} + +/** Whiteout entry produced by overlayfs-style image layering. */ +export interface WhiteoutSpec { + /** Char device 0:0 per kernel overlayfs convention. */ + attrs?: EntryAttrs; +} + +/** Result of `finalize()` — bookkeeping consumed by the rootfs builder. */ +export interface FinalizeResult { + outputPath: string; + sizeBytes: number; + uuid: string; + inodesUsed: number; + blocksUsed: number; +} + +/** Internal: in-memory representation of one filesystem entry pre-finalize. */ +export interface PendingEntry { + path: string; + type: FileType; + attrs: EntryAttrs; + body?: FileBody; + symlinkTarget?: string; + device?: { major: number; minor: number }; + hardlinkOf?: string; +} diff --git a/src/ext4/writer.ts b/src/ext4/writer.ts new file mode 100644 index 0000000..4f7be3a --- /dev/null +++ b/src/ext4/writer.ts @@ -0,0 +1,124 @@ +/** + * Ext4ImageWriter — public entry point for the native ext4 writer. + * + * Usage: + * const w = new Ext4ImageWriter("/tmp/rootfs.ext4", { sizeBytes: 256 * 1024 * 1024 }); + * await w.addDir("/etc", { uid: 0, gid: 0, mode: 0o755 }); + * await w.addFile("/etc/hostname", Buffer.from("microvm\n"), { uid: 0, gid: 0, mode: 0o644 }); + * const result = await w.finalize(); + * + * Implementation status: SCAFFOLD ONLY. All public methods throw + * NotImplementedError. The full byte-level serializer is tracked in Track + * D.2.b phase 2 — see docs/ext4-writer.md for the build-out plan. + */ + +import { NotImplementedError } from "./superblock.js"; +import type { + DeviceSpec, + Ext4WriterOptions, + EntryAttrs, + FileBody, + FinalizeResult, + HardlinkSpec, + PendingEntry, + SymlinkSpec, + WhiteoutSpec, +} from "./types.js"; + +/** Default block size — 4 KiB matches kernel page size on x86_64/aarch64. */ +const DEFAULT_BLOCK_SIZE: 4096 = 4096; + +/** Default inodes-per-group when caller passes 0 ("auto"). */ +const DEFAULT_INODES_PER_GROUP = 8192; + +/** Resolve sparse defaults so the rest of the writer can rely on full options. */ +function withDefaults(options: Ext4WriterOptions): Required<Ext4WriterOptions> { + return { + sizeBytes: options.sizeBytes, + blockSize: options.blockSize ?? DEFAULT_BLOCK_SIZE, + inodesPerGroup: options.inodesPerGroup ?? DEFAULT_INODES_PER_GROUP, + label: options.label ?? "", + uuid: options.uuid ?? "", + dirIndex: options.dirIndex ?? true, + extents: options.extents ?? true, + }; +} + +export class Ext4ImageWriter { + readonly outputPath: string; + readonly options: Required<Ext4WriterOptions>; + private readonly pending: PendingEntry[] = []; + private finalized = false; + + constructor(outputPath: string, options: Ext4WriterOptions) { + if (!outputPath) { + throw new Error("Ext4ImageWriter: outputPath is required"); + } + this.outputPath = outputPath; + this.options = withDefaults(options); + if (this.options.blockSize !== DEFAULT_BLOCK_SIZE) { + // Phase 2 will lift this once 1 KiB / 2 KiB block layouts are implemented. + throw new Error(`Ext4ImageWriter: only blockSize=${DEFAULT_BLOCK_SIZE} is supported`); + } + } + + /** Add a regular file. `body` may be a Buffer or a host path to stream. */ + async addFile(_path: string, _body: FileBody, _attrs: EntryAttrs): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addFile"); + } + + /** Add an empty directory. The writer creates intermediate parents lazily. */ + async addDir(_path: string, _attrs: EntryAttrs): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addDir"); + } + + /** Add a symbolic link. Targets ≤ 60 bytes use the fast-symlink encoding. */ + async addSymlink(_path: string, _spec: SymlinkSpec): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addSymlink"); + } + + /** Increment i_links_count on the existing inode and add a new dir entry. */ + async addHardlink(_path: string, _spec: HardlinkSpec): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addHardlink"); + } + + /** Add a character device node (e.g. /dev/null = c 1 3). */ + async addCharDevice(_path: string, _spec: DeviceSpec): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addCharDevice"); + } + + /** Add a block device node (e.g. /dev/loop0 = b 7 0). */ + async addBlockDevice(_path: string, _spec: DeviceSpec): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addBlockDevice"); + } + + /** Add an overlayfs whiteout (char 0:0). Used during OCI layer flattening. */ + async addWhiteout(_path: string, _spec?: WhiteoutSpec): Promise<void> { + this.assertOpen(); + throw new NotImplementedError("Ext4ImageWriter.addWhiteout"); + } + + /** Flush the planned tree to disk and return the final image metadata. */ + async finalize(): Promise<FinalizeResult> { + this.assertOpen(); + this.finalized = true; + throw new NotImplementedError("Ext4ImageWriter.finalize"); + } + + /** Read-only view of pending entries — exposed for tests. */ + pendingCount(): number { + return this.pending.length; + } + + private assertOpen(): void { + if (this.finalized) { + throw new Error("Ext4ImageWriter: writer has already been finalized"); + } + } +} diff --git a/src/index.ts b/src/index.ts index 2bfa449..78be2f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,26 @@ import { createHash } from "node:crypto"; -import { copyFile, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { copyFile, link, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { setupNetwork } from "./net.js"; import { requireKernelPath } from "./kernel.js"; import { commandExists, runCommand } from "./process.js"; +import { + ensureDiskSizeAtLeast, + materializeExplicitDisk, + materializePersistentDisk, + resolveAttachedDisks, + validateAttachedDiskPaths, +} from "./disk.js"; +import { prebuiltSlugForImage, readPackageVersion, tryFetchPrebuiltRootfs } from "./prebuilt-rootfs.js"; import { buildRootfs } from "./rootfs.js"; import type { DoctorCheck, DoctorResult, + HostBackend, + HostCapabilities, + HostPlatformInfo, NodeVmmClient, NodeVmmClientOptions, NetworkConfig, @@ -27,9 +38,22 @@ import type { SdkSnapshotCreateOptions, SdkSnapshotManifest, SdkSnapshotRestoreOptions, + ResolvedAttachedDisk, } from "./types.js"; -import type { KvmRunResult } from "./native.js"; -import { defaultKernelCmdline, probeKvm, probeWhp, runKvmVmAsync, runKvmVmControlled } from "./kvm.js"; +import { native, type KvmRunResult } from "./native.js"; +import { + defaultKernelCmdline, + hvfDefaultKernelCmdline, + probeHvf, + probeKvm, + probeWhp, + runHvfVmAsync, + runHvfVmControlled, + runKvmVmAsync, + runKvmVmControlled, + virtioExtraBlkKernelArgs, + virtioNetKernelArg, +} from "./kvm.js"; import { NodeVmmError, isWritableDirectory, @@ -42,49 +66,214 @@ import { } from "./utils.js"; export * from "./args.js"; +export * from "./disk.js"; export * from "./kernel.js"; export * from "./kvm.js"; export * from "./native.js"; export * from "./net.js"; export * from "./oci.js"; +export * from "./prebuilt-rootfs.js"; export * from "./process.js"; export * from "./rootfs.js"; export * from "./types.js"; export * from "./utils.js"; +// Internal-only namespace export — the ext4 writer is a scaffold (Track +// D.2.b, follow-up to D.2.a prebuilt rootfs downloads). All public +// methods throw NotImplementedError until the writer ships. Not part of +// the stable SDK surface. +export * as ext4 from "./ext4/index.js"; + export const VERSION = "0.1.3"; const PRODUCT_NAME = "node-vmm"; const DEFAULT_CACHE_DIR = path.join(os.tmpdir(), PRODUCT_NAME, "oci-cache"); -const ROOTFS_CACHE_VERSION = 1; +const ROOTFS_CACHE_VERSION = 37; const SNAPSHOT_MANIFEST = "snapshot.json"; +const WINDOWS_ROOTFS_BUILD_ERROR = + "Windows/WHP can build OCI image rootfs images through WSL2. Dockerfile and repo builds still require Linux for now; pass rootfsPath/diskPath to an existing ext4 image or build from an OCI image with WSL2 installed."; -function hostBackend(): "kvm" | "whp" | "unsupported" { - if (process.platform === "linux") { +export function hostBackendForHost(host: Partial<HostPlatformInfo> = {}): HostBackend { + const platform = host.platform ?? process.platform; + const arch = host.arch ?? process.arch; + if (platform === "linux" && arch === "x64") { return "kvm"; } - if (process.platform === "win32") { + if (platform === "win32" && arch === "x64") { return "whp"; } + if (platform === "darwin" && arch === "arm64") { + return "hvf"; + } return "unsupported"; } -function featureLines(): string[] { - const backend = hostBackend(); +function hostArchLine(backend: HostBackend, host: HostPlatformInfo): string { + if (backend === "kvm") { + return "linux/x86_64"; + } + if (backend === "whp") { + return "windows/x86_64"; + } + if (backend === "hvf") { + return "darwin/arm64"; + } + return `${host.platform}/${host.arch}`; +} + +export function capabilitiesForHost(host: Partial<HostPlatformInfo> = {}): HostCapabilities { + const resolved = { + platform: host.platform ?? process.platform, + arch: host.arch ?? process.arch, + }; + const backend = hostBackendForHost(resolved); + if (backend === "kvm") { + return { + backend, + platform: resolved.platform, + arch: resolved.arch, + archLine: hostArchLine(backend, resolved), + vmRuntime: true, + rootfsBuild: true, + prebuiltRootfs: true, + defaultNetwork: "auto", + networkModes: ["auto", "none", "tap", "slirp"], + tapNetwork: true, + portForwarding: true, + minCpus: 1, + maxCpus: 64, + rootfsMaxCpus: 64, + }; + } + if (backend === "whp") { + return { + backend, + platform: resolved.platform, + arch: resolved.arch, + archLine: hostArchLine(backend, resolved), + vmRuntime: true, + rootfsBuild: true, + prebuiltRootfs: true, + // Mirror the Linux/KVM surface: defaultNetwork is "auto" so that the + // same `node-vmm run --net auto` works cross-platform. On WHP, "auto" + // resolves to libslirp user-mode networking (10.0.2.0/24 NAT), which + // is the closest analogue to KVM's TAP+iptables auto setup. + defaultNetwork: "auto", + networkModes: ["auto", "none", "slirp"], + tapNetwork: false, + portForwarding: true, + minCpus: 1, + maxCpus: 64, + rootfsMaxCpus: 64, + }; + } + if (backend === "hvf") { + return { + backend, + platform: resolved.platform, + arch: resolved.arch, + archLine: hostArchLine(backend, resolved), + vmRuntime: true, + rootfsBuild: true, + prebuiltRootfs: false, + defaultNetwork: "auto", + networkModes: ["auto", "none", "slirp"], + tapNetwork: false, + portForwarding: true, + minCpus: 1, + maxCpus: 64, + rootfsMaxCpus: 64, + }; + } + return { + backend, + platform: resolved.platform, + arch: resolved.arch, + archLine: hostArchLine(backend, resolved), + vmRuntime: false, + rootfsBuild: false, + prebuiltRootfs: false, + defaultNetwork: "none", + networkModes: ["none"], + tapNetwork: false, + portForwarding: false, + minCpus: 1, + maxCpus: 1, + rootfsMaxCpus: 1, + }; +} + +function hostCapabilities(): HostCapabilities { + return capabilitiesForHost(); +} + +function assertVmRuntimeAvailable(capabilities = hostCapabilities()): void { + if (capabilities.backend === "kvm") { + probeKvm(); + return; + } + if (capabilities.backend === "whp") { + const probe = probeWhp(); + if (!probe.available) { + throw new NodeVmmError(probe.reason || "Windows Hypervisor Platform is not available"); + } + return; + } + if (capabilities.backend === "hvf") { + const probe = probeHvf(); + if (!probe.available) { + throw new NodeVmmError(probe.reason || "Hypervisor.framework is not available"); + } + return; + } + throw new NodeVmmError(`node-vmm VM execution is not supported on ${capabilities.platform}/${capabilities.arch}`); +} + +function featureLines(capabilities = hostCapabilities()): string[] { + const backend = capabilities.backend; + if (backend === "unsupported") { + return [ + "backend: none", + `arch: ${capabilities.archLine}`, + "vm: unsupported on this host", + "vcpu: unsupported on this host", + "rootfs: prebuilt ext4 boot/build not available through node-vmm on this host", + "network: none", + "unsupported: Linux x64 KVM and Windows x64 WHP are the supported host backends", + ]; + } return [ - `backend: ${backend === "unsupported" ? "none" : backend}`, - `arch: ${backend === "whp" ? "windows/x86_64" : "linux/x86_64"}`, - "kernel: ELF vmlinux", - "vcpu: 1-64 on Linux/KVM; default 1", - "memory: configurable with --mem", - "disk: virtio-mmio block at /dev/vda", - "restore: core snapshots restore with a sparse copy-on-write disk overlay", - "rootfs: build from OCI image or boot prebuilt ext4 disk", - "console: UART COM1, batch or --interactive PTY helper", - "network: virtio-mmio net with TAP/NAT via --net auto", - "snapshot: native RAM and dirty-page primitives are exposed for backend release gates", - backend === "whp" ? "windows: WHP probe and native smoke are available in the Windows addon" : "windows: WHP backend builds on Windows", - "unsupported: bzImage, jailer", + `backend: ${backend}`, + `arch: ${capabilities.archLine}`, + backend === "hvf" ? "kernel: ARM64 Image" : "kernel: ELF vmlinux", + backend === "whp" + ? "vcpu: 1-64 on Windows/WHP; rootfs-backed Alpine SMP is covered by WHP tests" + : backend === "hvf" + ? "vcpu: 1-64 on macOS/HVF; default 1" + : "vcpu: 1-64 on Linux/KVM; default 1", + "memory: configurable with --mem", + "disk: virtio-mmio root block at /dev/vda; attached data disks start at /dev/vdb", + "restore: core snapshots restore with a sparse copy-on-write disk overlay", + backend === "whp" + ? "rootfs: build OCI images through WSL2 or boot prebuilt ext4 disk; Dockerfile/repo builds require Linux for now" + : backend === "hvf" + ? "rootfs: build from OCI image on macOS or boot ext4 disk; Dockerfile RUN requires Linux" + : "rootfs: build from OCI image, Dockerfile, repo, or boot prebuilt ext4 disk", + backend === "hvf" ? "console: PL011 UART, batch or --interactive" : "console: UART COM1, batch or --interactive PTY helper", + capabilities.portForwarding + ? backend === "whp" + ? "network: virtio-mmio net with libslirp via --net auto/--net slirp; TCP publish supported" + : backend === "hvf" + ? "network: virtio-mmio net with libslirp via --net auto/--net slirp; TCP publish supported" + : "network: virtio-mmio net with TAP/NAT via --net auto or libslirp via --net slirp; TCP publish supported" + : "network: none by default; WHP networking, TAP, and TCP publish are not available yet", + backend === "whp" + ? "snapshot: rootfs snapshot bundles and WHP dirty-page probes; RAM/device restore still pending" + : backend === "hvf" + ? "snapshot: core disk snapshot/restore; native RAM snapshots are Linux/KVM only" + : "snapshot: native RAM and dirty-page primitives are exposed for backend release gates", + backend === "whp" ? "windows: WHP backend selected for VM execution" : "windows: WHP backend builds on Windows", + "unsupported: bzImage, jailer", ]; } @@ -92,8 +281,22 @@ function resolvePath(cwd: string, target: string): string { return path.isAbsolute(target) ? target : path.resolve(cwd, target); } -function defaultNetwork(mode: NetworkMode | undefined, tapName?: string): NetworkMode { - return mode || (tapName ? "tap" : "auto"); +export function defaultNetworkForCapabilities( + capabilities: HostCapabilities, + mode: NetworkMode | undefined, + tapName?: string, +): NetworkMode { + if (mode) { + return mode; + } + if (capabilities.defaultNetwork === "auto" && tapName) { + return "tap"; + } + return capabilities.defaultNetwork; +} + +function defaultNetwork(mode: NetworkMode | undefined, tapName?: string, capabilities = hostCapabilities()): NetworkMode { + return defaultNetworkForCapabilities(capabilities, mode, tapName); } function sameValue<T>(a: T | undefined, b: T | undefined): boolean { @@ -106,9 +309,12 @@ function requireNoAliasConflict<T>(label: string, a: T | undefined, b: T | undef } } -function networkFromOptions(options: { net?: NetworkMode; network?: NetworkMode; tapName?: string }): NetworkMode { +function networkFromOptions( + options: { net?: NetworkMode; network?: NetworkMode; tapName?: string }, + capabilities = hostCapabilities(), +): NetworkMode { requireNoAliasConflict("net/network", options.net, options.network); - return defaultNetwork(options.network ?? options.net, options.tapName); + return defaultNetwork(options.network ?? options.net, options.tapName, capabilities); } async function kernelPathFromOptions( @@ -127,11 +333,14 @@ function memoryFromOptions(options: { memory?: number; memMiB?: number }): numbe return options.memMiB ?? options.memory ?? 256; } -function cpusFromOptions(options: { cpus?: number }): number { +function cpusFromOptions(options: { cpus?: number }, capabilities = hostCapabilities()): number { const cpus = options.cpus ?? 1; if (!Number.isInteger(cpus) || cpus < 1 || cpus > 64) { throw new NodeVmmError("cpus must be an integer between 1 and 64"); } + if (cpus > capabilities.maxCpus) { + throw new NodeVmmError(`cpus must be an integer between ${capabilities.minCpus} and ${capabilities.maxCpus}`); + } return cpus; } @@ -140,17 +349,140 @@ function restoreEnabled(options: { restore?: boolean; sandbox?: boolean; overlay return options.restore === true || options.sandbox === true || Boolean(options.overlayPath); } -function diskMiBFromOptions(options: { disk?: number; diskMiB?: number }, fallback: number): number { +function diskMiBFromOptions(options: { disk?: number; diskMiB?: number; diskSizeMiB?: number }, fallback: number): number { requireNoAliasConflict("disk/diskMiB", options.disk, options.diskMiB); - return options.diskMiB ?? options.disk ?? fallback; + requireNoAliasConflict("disk/diskSizeMiB", options.disk, options.diskSizeMiB); + requireNoAliasConflict("diskMiB/diskSizeMiB", options.diskMiB, options.diskSizeMiB); + return options.diskSizeMiB ?? options.diskMiB ?? options.disk ?? fallback; +} + +function unsupportedNetworkError(capabilities: HostCapabilities, feature: string, network?: NetworkMode): NodeVmmError { + if (capabilities.backend === "whp") { + if (feature === "tap") { + return new NodeVmmError("Windows/WHP does not support TAP networking yet; remove tapName/--tap and use network: 'none'"); + } + if (feature === "ports") { + return new NodeVmmError("Windows/WHP does not support TCP port publishing yet; remove ports/--publish and use network: 'none'"); + } + return new NodeVmmError( + `Windows/WHP does not support network:${network} yet; use network: 'none' until WHP networking is available`, + ); + } + return new NodeVmmError(`node-vmm networking is not supported on ${capabilities.platform}/${capabilities.arch}`); +} + +export function validateVmOptionsForCapabilities( + options: { + cpus?: number; + net?: NetworkMode; + network?: NetworkMode; + tapName?: string; + ports?: PortForwardInput[]; + attachDisks?: { path: string; readonly?: boolean }[]; + }, + capabilities = hostCapabilities(), +): void { + const network = networkFromOptions(options, capabilities); + if (!["auto", "none", "tap", "slirp"].includes(network)) { + throw new NodeVmmError("net must be auto, none, tap, or slirp"); + } + cpusFromOptions(options, capabilities); + if (options.tapName && !capabilities.tapNetwork) { + throw unsupportedNetworkError(capabilities, "tap"); + } + if (!capabilities.networkModes.includes(network)) { + throw unsupportedNetworkError(capabilities, "network", network); + } + if ((options.ports?.length ?? 0) > 0 && !capabilities.portForwarding) { + throw unsupportedNetworkError(capabilities, "ports"); + } + resolveAttachedDisks(process.cwd(), options.attachDisks); +} + +function validateVmOptions( + options: { + cpus?: number; + net?: NetworkMode; + network?: NetworkMode; + tapName?: string; + ports?: PortForwardInput[]; + attachDisks?: { path: string; readonly?: boolean }[]; + }, + capabilities = hostCapabilities(), +): void { + validateVmOptionsForCapabilities(options, capabilities); +} + +export function validateRootfsRuntimeForCapabilities( + operation: string, + options: { cpus?: number }, + capabilities = hostCapabilities(), +): void { + const cpus = cpusFromOptions(options, capabilities); + if (cpus > capabilities.rootfsMaxCpus) { + if (capabilities.backend === "whp") { + throw new NodeVmmError( + `${operation} cannot run rootfs-backed Linux with cpus=${cpus} on Windows/WHP. ` + + `This build currently supports up to ${capabilities.rootfsMaxCpus} vCPUs for rootfs-backed Linux guests.`, + ); + } + throw new NodeVmmError(`cpus must be an integer between ${capabilities.minCpus} and ${capabilities.rootfsMaxCpus}`); + } +} + +async function validateRootfsRuntimePathForCapabilities( + operation: string, + options: { cpus?: number }, + rootfsPath: string, + capabilities = hostCapabilities(), +): Promise<void> { + const cpus = cpusFromOptions(options, capabilities); + if (cpus <= capabilities.rootfsMaxCpus) { + return; + } + const info = await stat(rootfsPath); + if (info.size > 0) { + validateRootfsRuntimeForCapabilities(operation, options, capabilities); + } +} + +function hasRootfsBuildInput(options: { image?: string; dockerfile?: string; repo?: string }): boolean { + return Boolean(options.image || options.dockerfile || options.repo); +} + +function assertRootfsInput( + operation: string, + options: { rootfsPath?: string; diskPath?: string; image?: string; dockerfile?: string; repo?: string }, +): void { + if (!options.rootfsPath && !options.diskPath && !hasRootfsBuildInput(options)) { + throw new NodeVmmError(`${operation} requires rootfsPath, diskPath, image, dockerfile, or repo`); + } } -function validateVmOptions(options: { cpus?: number; net?: NetworkMode; network?: NetworkMode; tapName?: string }): void { - const network = networkFromOptions(options); - if (!["auto", "none", "tap"].includes(network)) { - throw new NodeVmmError("net must be auto, none, or tap"); +export function validateRootfsOptionsForCapabilities( + operation: string, + options: { rootfsPath?: string; diskPath?: string; image?: string; dockerfile?: string; repo?: string }, + capabilities = hostCapabilities(), +): void { + assertRootfsInput(operation, options); + assertRootfsBuildSupportedForCapabilities(operation, options, capabilities); +} + +export function assertRootfsBuildSupportedForCapabilities( + operation: string, + options: { rootfsPath?: string; diskPath?: string; image?: string; dockerfile?: string; repo?: string }, + capabilities = hostCapabilities(), +): void { + if (!options.rootfsPath && !options.diskPath && hasRootfsBuildInput(options) && capabilities.backend === "whp" && (options.dockerfile || options.repo)) { + throw new NodeVmmError(`${operation} cannot build this rootfs on Windows/WHP. ${WINDOWS_ROOTFS_BUILD_ERROR}`); + } + if (!options.rootfsPath && !options.diskPath && hasRootfsBuildInput(options) && !capabilities.rootfsBuild) { + const reason = + capabilities.backend === "whp" + ? WINDOWS_ROOTFS_BUILD_ERROR + : `node-vmm cannot build rootfs images on unsupported host ${capabilities.platform}/${capabilities.arch}`; + throw new NodeVmmError(`${operation} cannot build a rootfs on this host. ${reason}`); } - cpusFromOptions(options); } async function cleanupBestEffort(steps: Array<(() => Promise<void>) | undefined>): Promise<void> { @@ -192,6 +524,117 @@ function fastExitBootArg(enabled: boolean): string | undefined { return enabled ? "node_vmm.fast_exit=1" : undefined; } +function resizeRootfsBootArg(enabled: boolean): string | undefined { + return enabled ? "node_vmm.resize_rootfs=1" : undefined; +} + +function interactiveBootArg(enabled: boolean): string | undefined { + return enabled ? "node_vmm.interactive=1" : undefined; +} + +function windowsConsoleBootArg(enabled: boolean): string | undefined { + return enabled ? "node_vmm.windows_console=1" : undefined; +} + +function windowsConsoleRouteBootArg(enabled: boolean): string | undefined { + if (!enabled) { + return undefined; + } + const route = process.env.NODE_VMM_WHP_CONSOLE_ROUTE; + if (route !== "getty" && route !== "pty") { + return undefined; + } + return `node_vmm.console_route=${route}`; +} + +function windowsConsoleSizeBootArg(enabled: boolean): string | undefined { + if (!enabled) { + return undefined; + } + const envCols = Number.parseInt(process.env.NODE_VMM_WHP_TTY_COLS || "", 10); + const envRows = Number.parseInt(process.env.NODE_VMM_WHP_TTY_ROWS || "", 10); + const ttySizeMode = process.env.NODE_VMM_WHP_TTY_SIZE || "qemu"; + let cols = Number.isInteger(envCols) && envCols >= 20 ? envCols : 0; + let rows = Number.isInteger(envRows) && envRows >= 10 ? envRows : 0; + + if ((cols === 0 || rows === 0) && ttySizeMode === "host") { + // Host-size mode is diagnostic: copy the visible Windows console size, + // then leave a small right-edge guard for ConPTY/VS Code. + try { + const sz = native.whpHostConsoleSize?.(); + if (sz && sz.cols > 0 && sz.rows > 0) { + if (cols === 0) cols = sz.cols; + if (rows === 0) rows = sz.rows; + } + } catch { + // native not loaded yet (e.g. on Linux); fall through to stdio. + } + if (cols === 0 && Number.isInteger(process.stdout.columns) && process.stdout.columns > 0) { + cols = process.stdout.columns; + } + if (rows === 0 && Number.isInteger(process.stdout.rows) && process.stdout.rows > 0) { + rows = process.stdout.rows; + } + if (!Number.isInteger(envCols) && cols > 0) { + const guard = Number.parseInt(process.env.NODE_VMM_WHP_TTY_COL_GUARD || "2", 10); + cols = Math.max(20, cols - (Number.isInteger(guard) && guard >= 0 ? guard : 2)); + } + } + + // QEMU's stdio serial path does not propagate host terminal geometry to + // the guest. Keeping WHP at classic serial geometry by default avoids + // right-edge wrap artifacts in VS Code/ConPTY. Set + // NODE_VMM_WHP_TTY_SIZE=host to opt into host-sized serial geometry. + if (cols === 0) cols = 80; + if (rows === 0) rows = 24; + return `node_vmm.tty_cols=${cols} node_vmm.tty_rows=${rows}`; +} + +function backendBootArgs(capabilities: HostCapabilities, interactive: boolean): { + backend: HostBackend; + interactiveArg?: string; + windowsConsoleArg?: string; + windowsConsoleRouteArg?: string; + windowsConsoleSizeArg?: string; +} { + const windowsConsole = capabilities.backend === "whp" && interactive; + return { + backend: capabilities.backend, + interactiveArg: interactiveBootArg(interactive), + windowsConsoleArg: windowsConsoleBootArg(windowsConsole), + windowsConsoleRouteArg: windowsConsoleRouteBootArg(windowsConsole), + windowsConsoleSizeArg: windowsConsoleSizeBootArg(windowsConsole), + }; +} + +function defaultKernelCmdlineForBackend(backend: string): string { + const args = defaultKernelCmdline().split(/\s+/).filter(Boolean); + if (backend === "whp") { + return [...args.filter((arg) => arg !== "noapictimer"), "clocksource=refined-jiffies"].join(" "); + } + return args.join(" "); +} + +function timeBootArgs(): string { + const now = new Date(); + const epoch = Math.floor(now.getTime() / 1000); + const utc = now.toISOString().slice(0, 19).replace("T", "_"); + return `node_vmm.epoch=${epoch} node_vmm.utc=${utc}`; +} + +function positiveInteger(value: number | undefined): number | undefined { + return value !== undefined && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function terminalBootArgs(): string { + const cols = positiveInteger(process.stdout.columns) ?? positiveInteger(Number.parseInt(process.env.COLUMNS || "", 10)); + const rows = positiveInteger(process.stdout.rows) ?? positiveInteger(Number.parseInt(process.env.LINES || "", 10)); + return [ + cols ? `node_vmm.term_cols=${cols}` : undefined, + rows ? `node_vmm.term_rows=${rows}` : undefined, + ].filter(Boolean).join(" "); +} + function commandForCode(options: SdkRunCodeOptions): string { const language = options.language ?? "javascript"; if (language === "javascript") { @@ -211,21 +654,68 @@ function kernelCmdline( cmdline: string | undefined, bootArgs: string | undefined, networkArg?: string, + storageArg?: string, commandArg?: string, fastExitArg?: string, + resizeRootfsArg?: string, + interactiveArg?: string, + windowsConsoleArg?: string, + windowsConsoleRouteArg?: string, + windowsConsoleSizeArg?: string, + backend = "kvm", ): string { + // Even when the user passes a full --cmdline override, the node_vmm.* + // helper args (interactive flag, base64 command, fast-exit, network) + // must survive: the in-guest /init parser keys off them to pick the + // batch vs interactive run-block, decode the command, set up network. + // Dropping them silently lands the guest in batch mode with stdin + // closed -- which is exactly the symptom the user reported. + const append = [ + timeBootArgs(), + terminalBootArgs(), + networkArg, + storageArg, + commandArg, + fastExitArg, + resizeRootfsArg, + interactiveArg, + windowsConsoleArg, + windowsConsoleRouteArg, + windowsConsoleSizeArg, + ].filter(Boolean) as string[]; if (cmdline) { - return cmdline; + return [cmdline, ...append].join(" "); } const extraBootArgs = (bootArgs || "") .split(/\s+/) .map((item) => item.trim()) .filter(Boolean) .join(" "); - return [defaultKernelCmdline(), networkArg, commandArg, fastExitArg, extraBootArgs].filter(Boolean).join(" "); + const baseCmdline = backend === "hvf" ? hvfDefaultKernelCmdline() : defaultKernelCmdlineForBackend(backend); + return [baseCmdline, ...append, extraBootArgs] + .filter(Boolean) + .join(" "); +} + +async function withInteractiveSignalGuard<T>(interactive: boolean, action: () => Promise<T>): Promise<T> { + if (!interactive) { + return action(); + } + const ignoreSigint = (): void => { + // Terminal raw mode should pass Ctrl-C to the guest as 0x03. + }; + process.on("SIGINT", ignoreSigint); + try { + return await action(); + } finally { + process.off("SIGINT", ignoreSigint); + } } async function readGuestCommandResult(rootfsPath: string): Promise<{ output: string; status?: number }> { + if (process.platform === "win32") { + return { output: "", status: undefined }; + } const mountDir = await makeTempDir(`${PRODUCT_NAME}-result-`); let mounted = false; try { @@ -294,9 +784,9 @@ async function setupVmNetwork(options: { } function assertInteractiveTty(interactive: boolean, label: string): void { - if (interactive && !process.stdin.isTTY) { + if (interactive && !process.stdin.isTTY && process.env.NODE_VMM_ALLOW_NONTTY_INTERACTIVE !== "1") { throw new NodeVmmError( - `${label} requires a TTY stdin; run from a real terminal or use script(1) for automation`, + `${label} requires a TTY stdin; run from a real terminal or use script(1) for automation (set NODE_VMM_ALLOW_NONTTY_INTERACTIVE=1 to bypass)`, ); } } @@ -305,6 +795,7 @@ function createResult(params: { id: string; rootfsPath: string; overlayPath?: string; + attachedDisks?: ResolvedAttachedDisk[]; restored: boolean; builtRootfs: boolean; network: NetworkConfig; @@ -317,6 +808,7 @@ function createResult(params: { id: params.id, rootfsPath: params.rootfsPath, overlayPath: params.overlayPath, + attachedDisks: params.attachedDisks ?? [], restored: params.restored, builtRootfs: params.builtRootfs, network: params.network, @@ -330,20 +822,24 @@ function createResult(params: { }; } -function withDefaults(options: NodeVmmClientOptions = {}): Required<Omit<NodeVmmClientOptions, "logger">> & { +type ResolvedClientOptions = Required<Omit<NodeVmmClientOptions, "logger" | "progress">> & { logger?: (message: string) => void; -} { + progress?: NodeVmmClientOptions["progress"]; +}; + +function withDefaults(options: NodeVmmClientOptions = {}): ResolvedClientOptions { return { cwd: options.cwd || process.cwd(), cacheDir: options.cacheDir || DEFAULT_CACHE_DIR, tempDir: options.tempDir || os.tmpdir(), logger: options.logger, + progress: options.progress, }; } async function makeOverlayTempDir( id: string, - defaults: Required<Omit<NodeVmmClientOptions, "logger">>, + defaults: ResolvedClientOptions, options: { overlayDir?: string; tempDir?: string }, ): Promise<string> { if (options.overlayDir) { @@ -369,7 +865,14 @@ function stableJson(value: unknown): string { return JSON.stringify(value); } -function rootfsCacheKey(options: SdkRunOptions | SdkPrepareOptions): string { +// Exported for regression tests that assert WSL2 is not invoked on cache +// hits; the cache lookup is the contract that buildOrReuseRootfs is built +// around. Not part of the public SDK surface. +/** @internal */ +export function rootfsCacheKey(options: SdkRunOptions | SdkPrepareOptions): string { + // initMode is intentionally NOT in the key: the rootfs ships both batch + // and interactive run-blocks and selects between them at boot time via + // node_vmm.interactive=... on the kernel cmdline. return createHash("sha256") .update( stableJson({ @@ -380,14 +883,33 @@ function rootfsCacheKey(options: SdkRunOptions | SdkPrepareOptions): string { env: options.env ?? {}, entrypoint: options.entrypoint, workdir: options.workdir, - initMode: options.initMode, - platformArch: options.platformArch, + platformArch: options.platformArch ?? defaultRootfsPlatformArch(), dockerfileRunTimeoutMs: options.dockerfileRunTimeoutMs, }), ) .digest("hex"); } +function defaultRootfsPlatformArch(): string { + return process.platform === "darwin" && process.arch === "arm64" ? "arm64" : "amd64"; +} + +function normalizeRootfsPlatformArch(arch: string): string { + if (arch === "x64" || arch === "x86_64") { + return "amd64"; + } + if (arch === "aarch64") { + return "arm64"; + } + return arch; +} + +function canUseCurrentPrebuiltRootfsAssets(platformArch: string): boolean { + // The release assets imported from the WHP PR are Linux/x86_64 ext4 images. + // Keep arm64/HVF on the local builder until arm64 assets are published. + return normalizeRootfsPlatformArch(platformArch) === "amd64"; +} + function isCacheableOciRootfs(options: SdkRunOptions | SdkPrepareOptions, source: { dockerfile?: string }): boolean { return Boolean( options.image && @@ -399,15 +921,17 @@ function isCacheableOciRootfs(options: SdkRunOptions | SdkPrepareOptions, source ); } -async function buildOrReuseRootfs(params: { +/** @internal */ +export async function buildOrReuseRootfs(params: { options: SdkRunOptions | SdkPrepareOptions; source: { contextDir: string; dockerfile?: string }; output: string; tempDir: string; cacheDir: string; logger?: (message: string) => void; -}): Promise<{ rootfsPath: string; fromCache: boolean; built: boolean }> { +}): Promise<{ rootfsPath: string; fromCache: boolean; built: boolean; resizeRootfs: boolean }> { const cacheable = isCacheableOciRootfs(params.options, params.source); + const platformArch = params.options.platformArch ?? defaultRootfsPlatformArch(); const buildOptions = { image: params.options.image, dockerfile: params.source.dockerfile, @@ -421,14 +945,17 @@ async function buildOrReuseRootfs(params: { initMode: params.options.initMode, tempDir: params.tempDir, cacheDir: params.cacheDir, - platformArch: params.options.platformArch, + platformArch, dockerfileRunTimeoutMs: params.options.dockerfileRunTimeoutMs, signal: params.options.signal, }; + if (params.options.prebuilt === "require" && !cacheable) { + throw new NodeVmmError("required prebuilt rootfs unavailable for this rootfs input"); + } if (!cacheable) { await buildRootfs({ ...buildOptions, output: params.output }); - return { rootfsPath: params.output, fromCache: false, built: true }; + return { rootfsPath: params.output, fromCache: false, built: true, resizeRootfs: false }; } const rootfsCacheDir = path.join(params.cacheDir, "rootfs"); @@ -437,22 +964,194 @@ async function buildOrReuseRootfs(params: { const cached = path.join(rootfsCacheDir, `${key}.ext4`); if (await pathExists(cached)) { params.logger?.(`${PRODUCT_NAME} rootfs cache hit: ${cached}`); - return { rootfsPath: cached, fromCache: true, built: false }; + return { rootfsPath: cached, fromCache: true, built: false, resizeRootfs: true }; } params.logger?.(`${PRODUCT_NAME} rootfs cache miss: ${params.options.image}`); const tmp = path.join(rootfsCacheDir, `${key}.tmp-${process.pid}-${randomId("rootfs")}.ext4`); + + // Track D.2.a: before falling back to the WSL2 build path, try + // fetching a prebuilt rootfs from the GitHub release for the current + // package version. Only attempts a download for the small set of + // images we publish (alpine:3.20, node:20-alpine, node:22-alpine); + // skips silently otherwise. On Windows this lets `run --image + // alpine:3.20` complete without any WSL2 spawn at all. + const image = params.options.image; + const prebuiltMode = params.options.prebuilt ?? "auto"; + const prebuiltSlug = image ? prebuiltSlugForImage(image) : null; + if (prebuiltMode === "require" && !prebuiltSlug) { + throw new NodeVmmError(`no prebuilt rootfs is published for ${image ?? "this rootfs input"}`); + } + const prebuiltArchSupported = canUseCurrentPrebuiltRootfsAssets(platformArch); + if (prebuiltMode === "require" && !prebuiltArchSupported) { + throw new NodeVmmError(`required prebuilt rootfs unavailable for linux/${platformArch}`); + } + if (image && prebuiltSlug && prebuiltMode !== "off" && prebuiltArchSupported) { + const version = await readPackageVersion(); + if (!version && prebuiltMode === "require") { + throw new NodeVmmError("cannot determine package version for required prebuilt rootfs download"); + } + if (version) { + const fetched = await tryFetchPrebuiltRootfs({ + image, + destPath: tmp, + packageVersion: version, + signal: params.options.signal, + logger: params.logger, + }); + if (fetched.fetched) { + try { + const resizeRootfs = await ensureDiskSizeAtLeast(tmp, buildOptions.diskMiB); + await rename(tmp, cached); + params.logger?.(`${PRODUCT_NAME} rootfs cached from prebuilt: ${cached}`); + return { rootfsPath: cached, fromCache: true, built: true, resizeRootfs }; + } catch (error) { + await rm(tmp, { force: true }); + throw error; + } + } else if (prebuiltMode === "require") { + throw new NodeVmmError(`required prebuilt rootfs unavailable for ${image}: ${fetched.reason ?? "unknown error"}`); + } + } + } else if (prebuiltMode === "require") { + throw new NodeVmmError(`required prebuilt rootfs unavailable for ${image ?? "this rootfs input"}`); + } + try { await buildRootfs({ ...buildOptions, output: tmp }); await rename(tmp, cached); params.logger?.(`${PRODUCT_NAME} rootfs cached: ${cached}`); - return { rootfsPath: cached, fromCache: true, built: true }; + return { rootfsPath: cached, fromCache: true, built: true, resizeRootfs: false }; } catch (error) { await rm(tmp, { force: true }); throw error; } } +interface PreparedRootDisk { + rootfsPath: string; + fromCache: boolean; + built: boolean; + resizeRootfs: boolean; + persistent: boolean; +} + +function validateRunRootDiskOptionShape(operation: "run" | "start", options: SdkRunOptions): void { + if (options.persist && options.diskPath) { + throw new NodeVmmError("--persist and diskPath/--disk PATH cannot be combined"); + } + if (options.rootfsPath && options.diskPath) { + throw new NodeVmmError(`rootfsPath and diskPath cannot both be set for ${operation}`); + } + if (options.reset && !options.diskPath && !options.persist) { + throw new NodeVmmError("--reset requires --persist or --disk PATH"); + } +} + +function rootDiskSourceKey(options: SdkRunOptions | SdkPrepareOptions, baseRootfsPath: string): string { + return createHash("sha256") + .update( + stableJson({ + version: ROOTFS_CACHE_VERSION, + image: options.image, + rootfsPath: options.rootfsPath ? path.resolve(options.rootfsPath) : undefined, + baseRootfsPath, + buildArgs: options.buildArgs ?? {}, + env: options.env ?? {}, + entrypoint: options.entrypoint, + workdir: options.workdir, + platformArch: options.platformArch, + dockerfileRunTimeoutMs: options.dockerfileRunTimeoutMs, + }), + ) + .digest("hex"); +} + +async function prepareRunRootDisk(params: { + operation: "run" | "start"; + id: string; + options: SdkRunOptions; + defaults: ResolvedClientOptions; + tempDir: string; + interactive: boolean; +}): Promise<PreparedRootDisk> { + const options = params.options; + validateRunRootDiskOptionShape(params.operation, options); + const diskMiB = diskMiBFromOptions(options, 2048); + const cacheDir = resolvePath(params.defaults.cwd, options.cacheDir || params.defaults.cacheDir); + const explicitDiskPath = options.diskPath ? resolvePath(params.defaults.cwd, options.diskPath) : ""; + + if (explicitDiskPath && (await pathExists(explicitDiskPath)) && !options.reset) { + const resized = await ensureDiskSizeAtLeast(explicitDiskPath, diskMiB); + return { rootfsPath: explicitDiskPath, fromCache: false, built: false, resizeRootfs: resized, persistent: true }; + } + + if (explicitDiskPath && !options.rootfsPath && !hasRootfsBuildInput(options)) { + throw new NodeVmmError(`${params.operation} cannot create --disk PATH without rootfsPath, image, dockerfile, or repo`); + } + let baseRootfsPath = options.rootfsPath ? resolvePath(params.defaults.cwd, options.rootfsPath) : path.join(params.tempDir, `${params.id}.ext4`); + let builtRootfs = !options.rootfsPath; + let rootfsFromCache = false; + let resizeRootfs = false; + + if (builtRootfs) { + if (!options.image && !options.dockerfile && !options.repo) { + throw new NodeVmmError(`${params.operation} requires rootfsPath, diskPath, image, dockerfile, or repo`); + } + const source = await resolveSourceContext(options, params.defaults, params.tempDir); + const prepared = await buildOrReuseRootfs({ + options: { ...options, initMode: options.initMode ?? (params.interactive ? "interactive" : "batch") }, + source, + output: baseRootfsPath, + tempDir: params.tempDir, + cacheDir, + logger: params.defaults.logger, + }); + baseRootfsPath = prepared.rootfsPath; + rootfsFromCache = prepared.fromCache; + builtRootfs = prepared.built; + resizeRootfs = prepared.resizeRootfs; + } + + if (explicitDiskPath) { + const materialized = await materializeExplicitDisk({ + diskPath: explicitDiskPath, + baseRootfsPath, + diskMiB, + reset: options.reset, + logger: params.defaults.logger, + }); + return { + rootfsPath: materialized.rootfsPath, + fromCache: false, + built: builtRootfs || materialized.created, + resizeRootfs: resizeRootfs || materialized.resized, + persistent: true, + }; + } + + if (options.persist) { + const materialized = await materializePersistentDisk({ + name: options.persist, + baseRootfsPath, + cacheDir, + sourceKey: rootDiskSourceKey(options, baseRootfsPath), + diskMiB, + reset: options.reset, + logger: params.defaults.logger, + }); + return { + rootfsPath: materialized.rootfsPath, + fromCache: false, + built: builtRootfs || materialized.created, + resizeRootfs: resizeRootfs || materialized.resized, + persistent: true, + }; + } + + return { rootfsPath: baseRootfsPath, fromCache: rootfsFromCache, built: builtRootfs, resizeRootfs, persistent: false }; +} + async function hardlinkOrCopy(source: string, dest: string): Promise<void> { try { await link(source, dest); @@ -516,7 +1215,7 @@ async function resolveSourceContext( dockerfile?: string; signal?: AbortSignal; }, - defaults: Required<Omit<NodeVmmClientOptions, "logger">> & { logger?: (message: string) => void }, + defaults: ResolvedClientOptions, tempDir: string, ): Promise<{ contextDir: string; dockerfile?: string }> { if (!options.repo) { @@ -586,6 +1285,11 @@ export async function buildRootfsImage( options: SdkBuildOptions, clientOptions: NodeVmmClientOptions = {}, ): Promise<SdkBuildResult> { + const capabilities = hostCapabilities(); + if (!hasRootfsBuildInput(options)) { + throw new NodeVmmError("build requires --image, --dockerfile, or --repo"); + } + assertRootfsBuildSupportedForCapabilities("build", options, capabilities); const defaults = withDefaults(clientOptions); const outputPath = resolvePath(defaults.cwd, options.output); const tempDir = options.tempDir ? resolvePath(defaults.cwd, options.tempDir) : await makeTempDir(`${PRODUCT_NAME}-build-`); @@ -623,13 +1327,16 @@ export async function runImage( clientOptions: NodeVmmClientOptions = {}, ): Promise<SdkRunResult> { options.signal?.throwIfAborted(); + const capabilities = hostCapabilities(); + validateRootfsOptionsForCapabilities("run", options, capabilities); + validateRunRootDiskOptionShape("run", options); const defaults = withDefaults(clientOptions); const id = options.id || randomId("vm"); const kernelPath = await kernelPathFromOptions(defaults, options); const interactive = interactiveForRun(options); assertInteractiveTty(interactive, "interactive shell"); - validateVmOptions(options); - probeKvm(); + validateVmOptions(options, capabilities); + assertVmRuntimeAvailable(capabilities); const tempDir = options.tempDir ? resolvePath(defaults.cwd, options.tempDir) : await makeTempDir(`${PRODUCT_NAME}-run-${id}-`); const ownsTemp = !options.tempDir; @@ -637,32 +1344,20 @@ export async function runImage( let rootfsPath = ""; let overlayPath: string | undefined; let overlayTempDir = ""; + let attachedDisks: ResolvedAttachedDisk[] = []; + let resizeRootfs = false; let builtRootfs = false; let generatedOverlay = false; try { - network = await setupVmNetwork({ id, network: networkFromOptions(options), tapName: options.tapName, ports: options.ports }); - rootfsPath = options.rootfsPath ? resolvePath(defaults.cwd, options.rootfsPath) : path.join(tempDir, `${id}.ext4`); - builtRootfs = !options.rootfsPath; - let rootfsFromCache = false; - if (builtRootfs) { - if (!options.image && !options.dockerfile && !options.repo) { - throw new NodeVmmError("run requires rootfsPath, image, dockerfile, or repo"); - } - const source = await resolveSourceContext(options, defaults, tempDir); - const prepared = await buildOrReuseRootfs({ - options: { ...options, initMode: options.initMode ?? (interactive ? "interactive" : "batch") }, - source, - output: rootfsPath, - tempDir, - cacheDir: resolvePath(defaults.cwd, options.cacheDir || defaults.cacheDir), - logger: defaults.logger, - }); - rootfsPath = prepared.rootfsPath; - rootfsFromCache = prepared.fromCache; - builtRootfs = prepared.built; - } - const needsOverlay = restoreEnabled(options) || rootfsFromCache; + network = await setupVmNetwork({ id, network: networkFromOptions(options, capabilities), tapName: options.tapName, ports: options.ports }); + const preparedDisk = await prepareRunRootDisk({ operation: "run", id, options, defaults, tempDir, interactive }); + rootfsPath = preparedDisk.rootfsPath; + builtRootfs = preparedDisk.built; + resizeRootfs = preparedDisk.resizeRootfs; + attachedDisks = resolveAttachedDisks(defaults.cwd, options.attachDisks); + await validateAttachedDiskPaths(attachedDisks); + const needsOverlay = restoreEnabled(options) || (preparedDisk.fromCache && !preparedDisk.persistent); generatedOverlay = needsOverlay && !options.overlayPath; overlayTempDir = generatedOverlay ? await makeOverlayTempDir(id, defaults, options) : ""; overlayPath = needsOverlay @@ -670,6 +1365,7 @@ export async function runImage( ? resolvePath(defaults.cwd, options.overlayPath) : path.join(overlayTempDir || tempDir, `${id}.restore.overlay`) : undefined; + await validateRootfsRuntimePathForCapabilities("run", options, rootfsPath, capabilities); defaults.logger?.(`${PRODUCT_NAME} starting ${id}`); if (network.mode === "tap") { @@ -684,30 +1380,62 @@ export async function runImage( defaults.logger?.(`${PRODUCT_NAME} console enabled; type \`exit\` or Ctrl-D to stop the guest`); } - const kvm = await runKvmVmAsync( - { - kernelPath: resolvePath(defaults.cwd, kernelPath), - rootfsPath, - overlayPath, - memMiB: memoryFromOptions(options), - cpus: cpusFromOptions(options), - cmdline: kernelCmdline( - options.cmdline, - options.bootArgs, - [network.kernelIpArg, network.kernelNetArgs].filter(Boolean).join(" "), - commandBootArg(options.cmd), - fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), - ), - timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), - consoleLimit: options.consoleLimit, - interactive, - netTapName: network.tapName, - netGuestMac: network.guestMac, - }, - { signal: options.signal }, + const bootProfile = backendBootArgs(capabilities, interactive); + const vmConfig = { + kernelPath: resolvePath(defaults.cwd, kernelPath), + rootfsPath, + overlayPath, + memMiB: memoryFromOptions(options), + cpus: cpusFromOptions(options, capabilities), + cmdline: kernelCmdline( + options.cmdline, + options.bootArgs, + [ + network.mode !== "none" && capabilities.backend !== "hvf" + ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) + : undefined, + network.kernelIpArg, + network.kernelNetArgs, + ] + .filter(Boolean) + .join(" "), + capabilities.backend === "hvf" ? undefined : virtioExtraBlkKernelArgs(attachedDisks.length, capabilities.backend), + commandBootArg(options.cmd), + fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), + resizeRootfsBootArg(resizeRootfs), + bootProfile.interactiveArg, + bootProfile.windowsConsoleArg, + bootProfile.windowsConsoleRouteArg, + bootProfile.windowsConsoleSizeArg, + bootProfile.backend, + ), + timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), + consoleLimit: options.consoleLimit, + interactive, + attachDisks: attachedDisks.map((disk) => ({ path: disk.path, readonly: disk.readonly })), + netTapName: network.tapName, + netGuestMac: network.guestMac, + netHostIp: network.hostIp, + netGuestIp: network.guestIp, + netNetmask: network.netmask, + netDns: network.dns, + netPortForwards: network.ports, + netSlirpEnabled: network.mode === "slirp", + netSlirpHostFwds: network.hostFwds, + }; + const nativeRunOptions = { + signal: options.signal, + onConsoleOutput: interactive && defaults.progress + ? () => defaults.progress?.({ type: "guest-console-ready", id }) + : undefined, + }; + const kvm = await withInteractiveSignalGuard(interactive, async () => + capabilities.backend === "hvf" + ? runHvfVmAsync(vmConfig, nativeRunOptions) + : runKvmVmAsync(vmConfig, nativeRunOptions), ); let guest = !interactive ? readGuestCommandResultFromConsole(kvm.console) : { output: "", status: undefined }; - if (!interactive && !overlayPath && guest.status === undefined && guest.output === "") { + if (!interactive && !overlayPath && guest.status === undefined && guest.output === "" && process.platform !== "darwin") { guest = await readGuestCommandResult(rootfsPath); } return createResult({ @@ -716,6 +1444,7 @@ export async function runImage( overlayPath, restored: Boolean(overlayPath), builtRootfs, + attachedDisks, network, kvm, guestOutput: guest.output, @@ -737,13 +1466,16 @@ export async function startVm( clientOptions: NodeVmmClientOptions = {}, ): Promise<RunningVm> { options.signal?.throwIfAborted(); + const capabilities = hostCapabilities(); + validateRootfsOptionsForCapabilities("start", options, capabilities); + validateRunRootDiskOptionShape("start", options); const defaults = withDefaults(clientOptions); const id = options.id || randomId("vm"); const kernelPath = await kernelPathFromOptions(defaults, options); const interactive = interactiveForRun(options); assertInteractiveTty(interactive, "interactive shell"); - validateVmOptions(options); - probeKvm(); + validateVmOptions(options, capabilities); + assertVmRuntimeAvailable(capabilities); const tempDir = options.tempDir ? resolvePath(defaults.cwd, options.tempDir) : await makeTempDir(`${PRODUCT_NAME}-run-${id}-`); const ownsTemp = !options.tempDir; @@ -751,6 +1483,8 @@ export async function startVm( let rootfsPath = ""; let overlayPath: string | undefined; let overlayTempDir = ""; + let attachedDisks: ResolvedAttachedDisk[] = []; + let resizeRootfs = false; let builtRootfs = false; let generatedOverlay = false; let cleaned = false; @@ -769,28 +1503,14 @@ export async function startVm( }; try { - network = await setupVmNetwork({ id, network: networkFromOptions(options), tapName: options.tapName, ports: options.ports }); - rootfsPath = options.rootfsPath ? resolvePath(defaults.cwd, options.rootfsPath) : path.join(tempDir, `${id}.ext4`); - builtRootfs = !options.rootfsPath; - let rootfsFromCache = false; - if (builtRootfs) { - if (!options.image && !options.dockerfile && !options.repo) { - throw new NodeVmmError("start requires rootfsPath, image, dockerfile, or repo"); - } - const source = await resolveSourceContext(options, defaults, tempDir); - const prepared = await buildOrReuseRootfs({ - options: { ...options, initMode: options.initMode ?? (interactive ? "interactive" : "batch") }, - source, - output: rootfsPath, - tempDir, - cacheDir: resolvePath(defaults.cwd, options.cacheDir || defaults.cacheDir), - logger: defaults.logger, - }); - rootfsPath = prepared.rootfsPath; - rootfsFromCache = prepared.fromCache; - builtRootfs = prepared.built; - } - const needsOverlay = restoreEnabled(options) || rootfsFromCache; + network = await setupVmNetwork({ id, network: networkFromOptions(options, capabilities), tapName: options.tapName, ports: options.ports }); + const preparedDisk = await prepareRunRootDisk({ operation: "start", id, options, defaults, tempDir, interactive }); + rootfsPath = preparedDisk.rootfsPath; + builtRootfs = preparedDisk.built; + resizeRootfs = preparedDisk.resizeRootfs; + attachedDisks = resolveAttachedDisks(defaults.cwd, options.attachDisks); + await validateAttachedDiskPaths(attachedDisks); + const needsOverlay = restoreEnabled(options) || (preparedDisk.fromCache && !preparedDisk.persistent); generatedOverlay = needsOverlay && !options.overlayPath; overlayTempDir = generatedOverlay ? await makeOverlayTempDir(id, defaults, options) : ""; overlayPath = needsOverlay @@ -798,6 +1518,7 @@ export async function startVm( ? resolvePath(defaults.cwd, options.overlayPath) : path.join(overlayTempDir || tempDir, `${id}.restore.overlay`) : undefined; + await validateRootfsRuntimePathForCapabilities("start", options, rootfsPath, capabilities); defaults.logger?.(`${PRODUCT_NAME} starting ${id}`); if (network.mode === "tap") { @@ -809,40 +1530,65 @@ export async function startVm( ); } - const nativeHandle = runKvmVmControlled( - { - kernelPath: resolvePath(defaults.cwd, kernelPath), - rootfsPath, - overlayPath, - memMiB: memoryFromOptions(options), - cpus: cpusFromOptions(options), - cmdline: kernelCmdline( - options.cmdline, - options.bootArgs, - [network.kernelIpArg, network.kernelNetArgs].filter(Boolean).join(" "), - commandBootArg(options.cmd), - fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), - ), - timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), - consoleLimit: options.consoleLimit, - interactive, - netTapName: network.tapName, - netGuestMac: network.guestMac, - }, - { signal: options.signal }, - ); + const bootProfile = backendBootArgs(capabilities, interactive); + const startVmConfig = { + kernelPath: resolvePath(defaults.cwd, kernelPath), + rootfsPath, + overlayPath, + memMiB: memoryFromOptions(options), + cpus: cpusFromOptions(options, capabilities), + cmdline: kernelCmdline( + options.cmdline, + options.bootArgs, + [ + network.mode !== "none" && capabilities.backend !== "hvf" + ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) + : undefined, + network.kernelIpArg, + network.kernelNetArgs, + ] + .filter(Boolean) + .join(" "), + capabilities.backend === "hvf" ? undefined : virtioExtraBlkKernelArgs(attachedDisks.length, capabilities.backend), + commandBootArg(options.cmd), + fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), + resizeRootfsBootArg(resizeRootfs), + bootProfile.interactiveArg, + bootProfile.windowsConsoleArg, + bootProfile.windowsConsoleRouteArg, + bootProfile.windowsConsoleSizeArg, + bootProfile.backend, + ), + timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), + consoleLimit: options.consoleLimit, + interactive, + attachDisks: attachedDisks.map((disk) => ({ path: disk.path, readonly: disk.readonly })), + netTapName: network.tapName, + netGuestMac: network.guestMac, + netHostIp: network.hostIp, + netGuestIp: network.guestIp, + netNetmask: network.netmask, + netDns: network.dns, + netPortForwards: network.ports, + netSlirpEnabled: network.mode === "slirp", + netSlirpHostFwds: network.hostFwds, + }; + const nativeHandle = capabilities.backend === "hvf" + ? runHvfVmControlled(startVmConfig, { signal: options.signal }) + : runKvmVmControlled(startVmConfig, { signal: options.signal }); const waitResult = nativeHandle .wait() .then(async (kvm) => { let guest = !interactive ? readGuestCommandResultFromConsole(kvm.console) : { output: "", status: undefined }; - if (!interactive && !overlayPath && guest.status === undefined && guest.output === "") { + if (!interactive && !overlayPath && guest.status === undefined && guest.output === "" && process.platform !== "darwin") { guest = await readGuestCommandResult(rootfsPath); } return createResult({ id, rootfsPath, overlayPath, + attachedDisks, restored: Boolean(overlayPath), builtRootfs, network: network as NetworkConfig, @@ -857,6 +1603,7 @@ export async function startVm( id, rootfsPath, overlayPath, + attachedDisks, restored: Boolean(overlayPath), builtRootfs, network, @@ -893,6 +1640,7 @@ export async function bootRootfs( clientOptions: NodeVmmClientOptions = {}, ): Promise<SdkRunResult> { options.signal?.throwIfAborted(); + const capabilities = hostCapabilities(); const defaults = withDefaults(clientOptions); const id = options.id || randomId("vm"); const kernelPath = await kernelPathFromOptions(defaults, options); @@ -905,11 +1653,12 @@ export async function bootRootfs( } const interactive = options.interactive ?? false; assertInteractiveTty(interactive, "boot --interactive"); - validateVmOptions(options); - probeKvm(); + validateVmOptions(options, capabilities); + assertVmRuntimeAvailable(capabilities); let overlayTempDir = ""; let overlayPath: string | undefined; + let attachedDisks: ResolvedAttachedDisk[] = []; let network: NetworkConfig | undefined; try { overlayTempDir = restoreEnabled(options) && !options.overlayPath ? await makeOverlayTempDir(id, defaults, options) : ""; @@ -918,7 +1667,7 @@ export async function bootRootfs( ? resolvePath(defaults.cwd, options.overlayPath) : path.join(overlayTempDir, `${id}.restore.overlay`) : undefined; - network = await setupVmNetwork({ id, network: networkFromOptions(options), tapName: options.tapName, ports: options.ports }); + network = await setupVmNetwork({ id, network: networkFromOptions(options, capabilities), tapName: options.tapName, ports: options.ports }); defaults.logger?.(`${PRODUCT_NAME} booting ${id}`); if (network.mode === "tap") { defaults.logger?.(`${PRODUCT_NAME} network enabled: ${network.tapName} ${network.guestMac}`); @@ -932,32 +1681,68 @@ export async function bootRootfs( defaults.logger?.(`${PRODUCT_NAME} console enabled; type \`exit\` or Ctrl-D to stop the guest`); } const resolvedRootfs = resolvePath(defaults.cwd, rootfsPath); - const kvm = await runKvmVmAsync( - { - kernelPath: resolvePath(defaults.cwd, kernelPath), - rootfsPath: resolvedRootfs, - overlayPath, - memMiB: memoryFromOptions(options), - cpus: cpusFromOptions(options), - cmdline: kernelCmdline( - options.cmdline, - options.bootArgs, - [network.kernelIpArg, network.kernelNetArgs].filter(Boolean).join(" "), - undefined, - fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), - ), - timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), - consoleLimit: options.consoleLimit, - interactive, - netTapName: network.tapName, - netGuestMac: network.guestMac, - }, - { signal: options.signal }, + attachedDisks = resolveAttachedDisks(defaults.cwd, options.attachDisks); + await validateAttachedDiskPaths(attachedDisks); + await validateRootfsRuntimePathForCapabilities("boot", options, resolvedRootfs, capabilities); + const bootProfile = backendBootArgs(capabilities, interactive); + const bootVmConfig = { + kernelPath: resolvePath(defaults.cwd, kernelPath), + rootfsPath: resolvedRootfs, + overlayPath, + memMiB: memoryFromOptions(options), + cpus: cpusFromOptions(options, capabilities), + cmdline: kernelCmdline( + options.cmdline, + options.bootArgs, + [ + network.mode !== "none" && capabilities.backend !== "hvf" + ? virtioNetKernelArg(attachedDisks.length, capabilities.backend) + : undefined, + network.kernelIpArg, + network.kernelNetArgs, + ] + .filter(Boolean) + .join(" "), + capabilities.backend === "hvf" ? undefined : virtioExtraBlkKernelArgs(attachedDisks.length, capabilities.backend), + undefined, + fastExitBootArg(options.fastExit === true && Boolean(overlayPath) && !interactive), + undefined, + bootProfile.interactiveArg, + bootProfile.windowsConsoleArg, + bootProfile.windowsConsoleRouteArg, + bootProfile.windowsConsoleSizeArg, + bootProfile.backend, + ), + timeoutMs: timeoutForMode(options.timeoutMs, interactive, (network.ports?.length ?? 0) > 0), + consoleLimit: options.consoleLimit, + interactive, + attachDisks: attachedDisks.map((disk) => ({ path: disk.path, readonly: disk.readonly })), + netTapName: network.tapName, + netGuestMac: network.guestMac, + netHostIp: network.hostIp, + netGuestIp: network.guestIp, + netNetmask: network.netmask, + netDns: network.dns, + netPortForwards: network.ports, + netSlirpEnabled: network.mode === "slirp", + netSlirpHostFwds: network.hostFwds, + }; + const nativeRunOptions = { + signal: options.signal, + onConsoleOutput: interactive && defaults.progress + ? () => defaults.progress?.({ type: "guest-console-ready", id }) + : undefined, + }; + const kvm = await withInteractiveSignalGuard(interactive, async () => + capabilities.backend === "hvf" + ? runHvfVmAsync(bootVmConfig, nativeRunOptions) + : runKvmVmAsync(bootVmConfig, nativeRunOptions), ); return createResult({ id, rootfsPath: resolvedRootfs, overlayPath, + attachedDisks, restored: Boolean(overlayPath), builtRootfs: false, network, @@ -976,6 +1761,9 @@ export async function createSnapshot( clientOptions: NodeVmmClientOptions = {}, ): Promise<SdkRunResult> { options.signal?.throwIfAborted(); + const capabilities = hostCapabilities(); + validateRootfsOptionsForCapabilities("snapshot create", options, capabilities); + validateVmOptions(options, capabilities); const defaults = withDefaults(clientOptions); const id = options.id || randomId("snapshot"); const kernelPath = await kernelPathFromOptions(defaults, options); @@ -1011,18 +1799,20 @@ export async function createSnapshot( signal: options.signal, }); } + await validateRootfsRuntimePathForCapabilities("snapshot create", options, rootfsPath, capabilities); const manifest = await writeSnapshotBundle({ snapshotDir, rootfsPath, kernelPath, memory: memoryFromOptions(options), - cpus: cpusFromOptions(options), + cpus: cpusFromOptions(options, capabilities), }); rootfsPath = path.join(snapshotDir, manifest.rootfs); return { id, rootfsPath, + attachedDisks: [], restored: false, builtRootfs, network: { mode: "none" }, @@ -1070,6 +1860,9 @@ export async function prepareSandbox( clientOptions: NodeVmmClientOptions = {}, ): Promise<PreparedSandbox> { options.signal?.throwIfAborted(); + const capabilities = hostCapabilities(); + validateRootfsOptionsForCapabilities("prepare", options, capabilities); + validateVmOptions(options, capabilities); const defaults = withDefaults(clientOptions); const id = options.id || randomId("template"); const tempDir = options.tempDir ? resolvePath(defaults.cwd, options.tempDir) : await makeTempDir(`${PRODUCT_NAME}-template-${id}-`); @@ -1157,8 +1950,29 @@ export function features(): string[] { export async function doctor(): Promise<DoctorResult> { const checks: DoctorCheck[] = []; - if (hostBackend() === "whp") { - checks.push({ name: "platform", ok: true, label: "Windows host" }); + const capabilities = hostCapabilities(); + if (capabilities.backend === "whp") { + checks.push({ name: "platform", ok: true, label: "Windows x64 host" }); + if (!(await commandExists("wsl.exe"))) { + checks.push({ name: "rootfs-builder", ok: false, label: "WSL2 is required for Windows OCI rootfs builds" }); + } else { + const wslTools = ["truncate", "mkfs.ext4", "mount", "umount", "python3"]; + const wslCheck = await runCommand( + "wsl.exe", + ["-u", "root", "sh", "-lc", wslTools.map((command) => `command -v ${shellQuote(command)} >/dev/null`).join(" && ")], + { capture: true, allowFailure: true }, + ); + checks.push({ + name: "rootfs-builder", + ok: wslCheck.code === 0, + label: + wslCheck.code === 0 + ? "WSL2 OCI rootfs builder ready" + : `WSL2 missing rootfs tool(s): ${wslTools.join(", ")}`, + }); + } + checks.push({ name: "network", ok: true, label: "WHP virtio-net/libslirp networking and TCP publish are available" }); + checks.push({ name: "smp", ok: true, label: `WHP vCPU range ${capabilities.minCpus}-${capabilities.rootfsMaxCpus}` }); try { const probe = probeWhp(); checks.push({ @@ -1178,8 +1992,28 @@ export async function doctor(): Promise<DoctorResult> { } return { ok: checks.every((check) => check.ok), checks }; } - if (hostBackend() === "unsupported") { - checks.push({ name: "platform", ok: false, label: `${process.platform}/${process.arch} is not supported yet` }); + if (capabilities.backend === "hvf") { + checks.push({ name: "platform", ok: true, label: "macOS/arm64 host" }); + try { + const probe = probeHvf(); + checks.push({ + name: "hvf-api", + ok: probe.available, + label: probe.available + ? "Hypervisor.framework ready" + : probe.reason || "Hypervisor.framework not available", + }); + } catch (error) { + checks.push({ name: "hvf-api", ok: false, label: error instanceof Error ? error.message : String(error) }); + } + for (const command of ["mkfs.ext4", "python3", "make", "g++"]) { + checks.push({ name: command, ok: await commandExists(command), label: `command ${command}` }); + } + checks.push({ name: "network", ok: true, label: "HVF virtio-net/libslirp networking and TCP publish are available" }); + return { ok: checks.every((check) => check.ok), checks }; + } + if (capabilities.backend === "unsupported") { + checks.push({ name: "platform", ok: false, label: `${capabilities.platform}/${capabilities.arch} is not supported yet` }); return { ok: false, checks }; } for (const command of [ diff --git a/src/kernel.ts b/src/kernel.ts index 5da7e8b..4ad44b1 100644 --- a/src/kernel.ts +++ b/src/kernel.ts @@ -8,23 +8,33 @@ import { gunzipSync } from "node:zlib"; import { NodeVmmError } from "./utils.js"; -export const DEFAULT_GOCRACKER_KERNEL = "gocracker-guest-standard-vmlinux"; -export const GOCRACKER_KERNEL_BASE_URL = +export const DEFAULT_NODE_VMM_KERNEL = "gocracker-guest-standard-vmlinux"; +export const DEFAULT_NODE_VMM_ARM64_KERNEL = "gocracker-guest-standard-arm64-Image"; +export const LEGACY_NODE_VMM_ARM64_KERNEL = "gocracker-guest-minimal-arm64-Image"; +export const NODE_VMM_KERNEL_BASE_URL = "https://raw.githubusercontent.com/misaelzapata/gocracker/main/artifacts/kernels"; export const NODE_VMM_KERNEL_ENV = "NODE_VMM_KERNEL"; export const NODE_VMM_KERNEL_CACHE_DIR_ENV = "NODE_VMM_KERNEL_CACHE_DIR"; export const NODE_VMM_KERNEL_REPO_ENV = "NODE_VMM_KERNEL_REPO"; -export const NODE_VMM_GOCRACKER_REPO_DIR_ENV = "NODE_VMM_GOCRACKER_REPO_DIR"; +export const NODE_VMM_KERNEL_REPO_DIR_ENV = "NODE_VMM_KERNEL_REPO_DIR"; export const NODE_VMM_KERNEL_SHA256_ENV = "NODE_VMM_KERNEL_SHA256"; export const NODE_VMM_KERNEL_MAX_GZIP_BYTES_ENV = "NODE_VMM_KERNEL_MAX_GZIP_BYTES"; export const NODE_VMM_KERNEL_MAX_BYTES_ENV = "NODE_VMM_KERNEL_MAX_BYTES"; +export const DEFAULT_GOCRACKER_KERNEL = DEFAULT_NODE_VMM_KERNEL; +export const DEFAULT_GOCRACKER_ARM64_KERNEL = DEFAULT_NODE_VMM_ARM64_KERNEL; +export const LEGACY_GOCRACKER_ARM64_KERNEL = LEGACY_NODE_VMM_ARM64_KERNEL; +export const GOCRACKER_KERNEL_BASE_URL = NODE_VMM_KERNEL_BASE_URL; +export const NODE_VMM_GOCRACKER_REPO_DIR_ENV = "NODE_VMM_GOCRACKER_REPO_DIR"; + const DEFAULT_KERNEL_MAX_GZIP_BYTES = 256 * 1024 * 1024; const DEFAULT_KERNEL_MAX_BYTES = 1024 * 1024 * 1024; const MAX_KERNEL_SHA256_SIDECAR_BYTES = 64 * 1024; const DEFAULT_KERNEL_SHA256: Record<string, string> = { - [DEFAULT_GOCRACKER_KERNEL]: "d211c41e571a2f262796cd1631e1c69e6e5ca6345248a6c628a9868f05371ff3", + [DEFAULT_NODE_VMM_KERNEL]: "d211c41e571a2f262796cd1631e1c69e6e5ca6345248a6c628a9868f05371ff3", + [DEFAULT_NODE_VMM_ARM64_KERNEL]: "88fd13179b8d86afb817cfc41a305ad6d42642a2e27bb461da3a81024aaf715b", + [LEGACY_NODE_VMM_ARM64_KERNEL]: "9bdcf296dcaf7742e3cfdc44e9e7dd52b4af4ebd920be5bf49074772e00537f0", }; interface FetchResponseLike { @@ -89,25 +99,47 @@ export function defaultKernelCacheDir(env: NodeJS.ProcessEnv = process.env): str return env[NODE_VMM_KERNEL_CACHE_DIR_ENV] || path.join(os.homedir(), ".cache", "node-vmm", "kernels"); } -export function gocrackerKernelUrl( - name = DEFAULT_GOCRACKER_KERNEL, +export function nodeVmmKernelUrl( + name = DEFAULT_NODE_VMM_KERNEL, env: NodeJS.ProcessEnv = process.env, ): string { - const baseUrl = env[NODE_VMM_KERNEL_REPO_ENV] || GOCRACKER_KERNEL_BASE_URL; + const baseUrl = env[NODE_VMM_KERNEL_REPO_ENV] || NODE_VMM_KERNEL_BASE_URL; return `${baseUrl.replace(/\/+$/, "")}/${name}.gz`; } +export const gocrackerKernelUrl = nodeVmmKernelUrl; + +export function defaultArm64KernelName(): string { + return DEFAULT_NODE_VMM_ARM64_KERNEL; +} + +export function defaultKernelNameForPlatform(platform: NodeJS.Platform = process.platform): string { + return platform === "darwin" ? DEFAULT_NODE_VMM_ARM64_KERNEL : DEFAULT_NODE_VMM_KERNEL; +} + +export function defaultKernelNamesForPlatform(platform: NodeJS.Platform = process.platform): string[] { + return platform === "darwin" + ? [DEFAULT_NODE_VMM_ARM64_KERNEL, LEGACY_NODE_VMM_ARM64_KERNEL] + : [DEFAULT_NODE_VMM_KERNEL]; +} + export function defaultKernelCandidates(options: KernelLookupOptions = {}): string[] { const cwd = options.cwd || process.cwd(); const env = options.env || process.env; - const name = options.name || DEFAULT_GOCRACKER_KERNEL; - const repoDir = env[NODE_VMM_GOCRACKER_REPO_DIR_ENV] || path.resolve(cwd, "../gocracker"); - return unique([ - path.join(defaultKernelCacheDir(env), name), - path.resolve(cwd, ".node-vmm", "kernels", name), - path.resolve(cwd, "artifacts", "kernels", name), - path.join(resolvePath(cwd, repoDir), "artifacts", "kernels", name), - ]); + const names = options.name ? [options.name] : defaultKernelNamesForPlatform(); + const repoDirs = unique([ + env[NODE_VMM_KERNEL_REPO_DIR_ENV], + env[NODE_VMM_GOCRACKER_REPO_DIR_ENV], + path.resolve(cwd, "../node-vmm-kernel"), + path.resolve(cwd, "../gocracker"), + ].filter((item): item is string => Boolean(item))); + const locations = [ + (name: string) => path.join(defaultKernelCacheDir(env), name), + (name: string) => path.resolve(cwd, ".node-vmm", "kernels", name), + (name: string) => path.resolve(cwd, "artifacts", "kernels", name), + ...repoDirs.map((repoDir) => (name: string) => path.join(resolvePath(cwd, repoDir), "artifacts", "kernels", name)), + ]; + return unique(names.flatMap((name) => locations.map((location) => location(name)))); } export async function findDefaultKernel(options: KernelLookupOptions = {}): Promise<string | undefined> { @@ -240,14 +272,14 @@ async function expectedKernelSha256(options: KernelFetchOptions, name: string, u ); } -export async function fetchGocrackerKernel(options: KernelFetchOptions = {}): Promise<KernelFetchResult> { +export async function fetchNodeVmmKernel(options: KernelFetchOptions = {}): Promise<KernelFetchResult> { options.signal?.throwIfAborted(); const cwd = options.cwd || process.cwd(); const env = options.env || process.env; - const name = options.name || DEFAULT_GOCRACKER_KERNEL; + const name = options.name || defaultKernelNameForPlatform(); const outputDir = resolvePath(cwd, options.outputDir || defaultKernelCacheDir(env)); const outputPath = path.join(outputDir, name); - const url = gocrackerKernelUrl(name, env); + const url = nodeVmmKernelUrl(name, env); validateKernelDownloadUrl(url); if (!options.force && (await exists(outputPath))) { return { path: outputPath, url, downloaded: false, bytes: 0, sha256: "" }; @@ -283,3 +315,5 @@ export async function fetchGocrackerKernel(options: KernelFetchOptions = {}): Pr /* c8 ignore stop */ return { path: outputPath, url, downloaded: true, bytes: kernel.byteLength, sha256: actual }; } + +export const fetchGocrackerKernel = fetchNodeVmmKernel; diff --git a/src/kvm.ts b/src/kvm.ts index d609deb..6961cd5 100644 --- a/src/kvm.ts +++ b/src/kvm.ts @@ -1,6 +1,11 @@ import { Worker } from "node:worker_threads"; import type { + HvfDeviceSmokeResult, + HvfFdtSmokeResult, + HvfPl011SmokeResult, + HvfProbeResult, + HvfRunConfig, KvmProbeResult, KvmRunConfig, KvmRunResult, @@ -17,6 +22,8 @@ import { NodeVmmError } from "./utils.js"; const CONTROL_COMMAND = 0; const CONTROL_STATE = 1; +const CONTROL_CONSOLE = 2; +const CONTROL_WORDS = 3; const CONTROL_RUN = 0; const CONTROL_PAUSE = 1; const CONTROL_STOP = 2; @@ -36,14 +43,58 @@ export function probeWhp(): WhpProbeResult { return native.probeWhp(); } -export function smokeHlt(): KvmSmokeResult { - return native.smokeHlt(); +export function probeHvf(): HvfProbeResult { + return native.probeHvf(); +} + +export function hvfFdtSmoke(): HvfFdtSmokeResult { + return native.hvfFdtSmoke(); +} + +export function hvfPl011Smoke(): HvfPl011SmokeResult { + return native.hvfPl011Smoke(); +} + +export function hvfDeviceSmoke(): HvfDeviceSmokeResult { + return native.hvfDeviceSmoke(); +} + +export function runHvfVm(config: HvfRunConfig): KvmRunResult { + return native.runVm(config as Parameters<typeof native.runVm>[0]); +} + +export function runHvfVmAsync(config: HvfRunConfig, options: KvmRunAsyncOptions = {}): Promise<KvmRunResult> { + return runKvmVmAsync(config as KvmRunConfig, options); +} + +export function runHvfVmControlled(config: HvfRunConfig, options: KvmRunAsyncOptions = {}): KvmVmHandle { + return runKvmVmControlled(config as KvmRunConfig, options); +} + +export function hvfDefaultKernelCmdline(): string { + return [ + "console=ttyAMA0,115200", + "reboot=k", + "panic=1", + "nomodule", + "loglevel=4", + "root=/dev/vda", + "rootfstype=ext4", + "rootwait", + "rw", + "init=/init", + ].join(" "); } export function whpSmokeHlt(): WhpSmokeResult { return native.whpSmokeHlt(); } +/* c8 ignore start - KVM-only native smoke wrappers are covered by native.test on KVM hosts, not the cross-platform TS gate. */ +export function smokeHlt(): KvmSmokeResult { + return native.smokeHlt(); +} + export function uartSmoke(): KvmSmokeResult { return native.uartSmoke(); } @@ -51,21 +102,58 @@ export function uartSmoke(): KvmSmokeResult { export function guestExitSmoke(): KvmSmokeResult { return native.guestExitSmoke(); } +/* c8 ignore stop */ export function ramSnapshotSmoke(config: RamSnapshotSmokeConfig): RamSnapshotSmokeResult { + if (!config.snapshotDir) { + throw new NodeVmmError("snapshotDir is required"); + } + /* c8 ignore start - native snapshot execution is covered by native/e2e tests on KVM hosts. */ return native.ramSnapshotSmoke(config); } +/* c8 ignore stop */ export function dirtyRamSnapshotSmoke(config: DirtyRamSnapshotSmokeConfig): DirtyRamSnapshotSmokeResult { + if (!config.snapshotDir) { + throw new NodeVmmError("snapshotDir is required"); + } + /* c8 ignore start - native dirty snapshot execution is covered by native/e2e tests on KVM hosts. */ return native.dirtyRamSnapshotSmoke(config); } +/* c8 ignore stop */ + +function validateRunConfig(config: KvmRunConfig): void { + if (!config.kernelPath) { + throw new NodeVmmError("kernelPath is required"); + } + if (!config.rootfsPath) { + throw new NodeVmmError("rootfsPath is required"); + } + for (const [index, disk] of (config.attachDisks ?? []).entries()) { + if (!disk.path) { + throw new NodeVmmError(`attachDisks[${index}].path is required`); + } + } + if (!config.cmdline) { + throw new NodeVmmError("cmdline is required"); + } + const cpus = config.cpus ?? 1; + if (!Number.isInteger(cpus) || cpus < 1 || cpus > 64) { + throw new NodeVmmError("cpus must be between 1 and 64"); + } + if (config.netTapName && !config.netGuestMac) { + throw new NodeVmmError("netGuestMac is required when netTapName is set"); + } +} export function runKvmVm(config: KvmRunConfig): KvmRunResult { + validateRunConfig(config); return native.runVm(config); } export interface KvmRunAsyncOptions { signal?: AbortSignal; + onConsoleOutput?: () => void; } export interface KvmVmHandle { @@ -103,12 +191,57 @@ export function runKvmVmAsync(config: KvmRunConfig, options: KvmRunAsyncOptions if (options.signal?.aborted) { return Promise.reject(new NodeVmmError("native KVM worker aborted before start")); } + try { + validateRunConfig(config); + } catch (error) { + return Promise.reject(error); + } return new Promise((resolve, reject) => { - const worker = new Worker(new URL("./kvm-worker.js", import.meta.url), { workerData: config }); + /* c8 ignore start - console notification control needs live guest output; e2e/manual KVM runs cover it. */ + const control = options.onConsoleOutput + ? config.control && config.control.length >= CONTROL_WORDS + ? config.control + : new Int32Array(new SharedArrayBuffer(CONTROL_WORDS * Int32Array.BYTES_PER_ELEMENT)) + : undefined; + if (control) { + Atomics.store(control, CONTROL_COMMAND, CONTROL_RUN); + Atomics.store(control, CONTROL_STATE, STATE_STARTING); + Atomics.store(control, CONTROL_CONSOLE, 0); + } + const workerConfig = control ? { ...config, control } : config; + /* c8 ignore stop */ + const worker = new Worker(new URL("./kvm-worker.js", import.meta.url), { workerData: workerConfig }); let settled = false; + /* c8 ignore start - console notification control needs live guest output; e2e/manual KVM runs cover it. */ + let consoleNotified = false; + let consolePoll: ReturnType<typeof setInterval> | undefined; + const notifyConsole = (): void => { + if (consoleNotified) { + return; + } + consoleNotified = true; + if (consolePoll) { + clearInterval(consolePoll); + consolePoll = undefined; + } + options.onConsoleOutput?.(); + }; + if (control) { + consolePoll = setInterval(() => { + if (Atomics.load(control, CONTROL_CONSOLE) > 0) { + notifyConsole(); + } + }, 25); + consolePoll.unref?.(); + } const cleanup = (): void => { options.signal?.removeEventListener("abort", handleAbort); + if (consolePoll) { + clearInterval(consolePoll); + consolePoll = undefined; + } }; + /* c8 ignore stop */ const settle = (fn: () => void): void => { if (settled) { return; @@ -145,9 +278,10 @@ export function runKvmVmAsync(config: KvmRunConfig, options: KvmRunAsyncOptions /* c8 ignore start - controlled pause/resume requires a live KVM guest; unit tests cover validation and e2e/manual app runs cover behavior. */ export function runKvmVmControlled(config: KvmRunConfig, options: KvmRunAsyncOptions = {}): KvmVmHandle { - const control = new Int32Array(new SharedArrayBuffer(8)); + const control = new Int32Array(new SharedArrayBuffer(CONTROL_WORDS * Int32Array.BYTES_PER_ELEMENT)); Atomics.store(control, CONTROL_COMMAND, CONTROL_RUN); Atomics.store(control, CONTROL_STATE, STATE_STARTING); + Atomics.store(control, CONTROL_CONSOLE, 0); let settled = false; const running = runKvmVmAsync({ ...config, control }, options).finally(() => { settled = true; @@ -198,7 +332,8 @@ export function runKvmVmControlled(config: KvmRunConfig, options: KvmRunAsyncOpt export function defaultKernelCmdline(): string { return [ - "console=ttyS0", + "console=ttyS0,115200n8", + "8250.nr_uarts=1", "reboot=k", "panic=1", "nomodule", @@ -206,13 +341,53 @@ export function defaultKernelCmdline(): string { "i8042.nomux", "i8042.dumbkbd", "swiotlb=noforce", - "loglevel=4", + "quiet", + "loglevel=3", "pci=off", + "tsc=reliable", + "lpj=10000000", + "no_timer_check", + "noapictimer", "root=/dev/vda", "rootfstype=ext4", "rootwait", "rw", "init=/init", - "virtio_mmio.device=0x1000@0xd0000000:5", + // virtio-mmio devices are registered through ACPI/DSDT (see + // BuildVirtioMmioDevice in native/whp/backend.cc); the cmdline override + // here is kept as belt-and-suspenders for kernels that don't enable + // CONFIG_ACPI_VIRTIO_MMIO. Layout matches QEMU microvm: + // qemu/hw/i386/microvm.c:362-366. + "virtio_mmio.device=512@0xd0000000:5", ].join(" "); } + +function virtioMmioLayout(backend: "kvm" | "whp" | string): { base: number; stride: number; irqBase: number } { + return backend === "whp" + ? { base: 0xd0000000, stride: 0x200, irqBase: 5 } + : { base: 0xd0000000, stride: 0x1000, irqBase: 5 }; +} + +export function virtioExtraBlkKernelArgs(count: number, backend: "kvm" | "whp" | string = "kvm"): string { + if (!Number.isInteger(count) || count < 0) { + throw new NodeVmmError("attached disk count must be a non-negative integer"); + } + const layout = virtioMmioLayout(backend); + const args: string[] = []; + for (let index = 0; index < count; index += 1) { + const deviceIndex = index + 1; + args.push( + `virtio_mmio.device=512@0x${(layout.base + deviceIndex * layout.stride).toString(16)}:${layout.irqBase + deviceIndex}`, + ); + } + return args.join(" "); +} + +export function virtioNetKernelArg(attachedDiskCount = 0, backend: "kvm" | "whp" | string = "kvm"): string { + if (!Number.isInteger(attachedDiskCount) || attachedDiskCount < 0) { + throw new NodeVmmError("attached disk count must be a non-negative integer"); + } + const layout = virtioMmioLayout(backend); + const deviceIndex = attachedDiskCount + 1; + return `virtio_mmio.device=512@0x${(layout.base + deviceIndex * layout.stride).toString(16)}:${layout.irqBase + deviceIndex}`; +} diff --git a/src/native.ts b/src/native.ts index 748bcae..22fb22c 100644 --- a/src/native.ts +++ b/src/native.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { NodeVmmError } from "./utils.js"; +import type { PortForward } from "./types.js"; export interface KvmProbeResult { apiVersion: number; @@ -91,10 +92,123 @@ export interface DirtyRamSnapshotSmokeResult { privateRamMapping: boolean; } -export interface KvmRunConfig { +export interface HvfProbeResult { + backend: "hvf"; + arch: "arm64"; + available: boolean; + reason?: string; +} + +export interface HvfFdtSmokeResult { + backend: "hvf"; + dtbBytes: number; + rootInterruptParent: number; + rootDmaCoherent: boolean; + gicPhandle: number; + cpuCount: number; + cpuEnableMethod: string; + gicRedistributorBytes: number; + timerInterrupts: number; + timerIrqFlags: number; + chosenRandomness: boolean; + serial0Alias: string; + stdoutPath: string; + uartBase: number; + uartSpi: number; + uartClockPhandles: number; + rtcBase: number; + rtcSpi: number; + rtcIntid: number; + gpioBase: number; + gpioSpi: number; + gpioIntid: number; + gpioPhandle: number; + gpioPowerKeyCode: number; + fwCfgBase: number; + fwCfgSize: number; + fwCfgDmaCoherent: boolean; + virtioBase: number; + virtioStride: number; + virtioCount: number; + virtioDmaCoherent: boolean; + virtioFirstSpi: number; + virtioBlkBase: number; + virtioBlkIntid: number; + virtioNetBase: number; + virtioNetIntid: number; + pcieBase: number; + pcieMmioSize: number; + pciePioBase: number; + pcieEcamBase: number; + pcieEcamSize: number; + pcieFirstSpi: number; + pcieIntxCount: number; + pcieInterruptMapEntries: number; + pcieInterruptMapMaskDevfn: number; + pcieInterruptMapMaskPin: number; + pcieBusCount: number; + pcieDmaCoherent: boolean; + emptyTransportMagic: number; + emptyTransportDeviceId: number; +} + +export interface HvfPl011SmokeResult { + backend: "hvf"; + console: string; + cursorResponse: string; + rxByte: number; + frEmpty: number; + frWithRx: number; + risBeforeClear: number; + misBeforeClear: number; + risAfterClear: number; + ibrd: number; + fbrd: number; + lcrH: number; + cr: number; + ifls: number; + dmacr: number; + peripheralId0: number; + primeCellId0: number; +} + +export interface HvfDeviceSmokeResult { + backend: "hvf"; + rtcNow: number; + rtcLoaded: number; + rtcPeripheralId0: number; + rtcPrimeCellId0: number; + gpioData: number; + gpioDirection: number; + gpioPeripheralId0: number; + gpioPrimeCellId0: number; + fwCfgSignature: string; + fwCfgId: number; + fwCfgCpus: number; + fwCfgFileCount: number; + emptyVirtioMagic: number; + emptyVirtioDeviceId: number; + emptyVirtioVendor: number; + pcieEmptyVendor: number; +} + +export interface NativeSlirpHostFwd { + udp: boolean; + hostAddr: string; + hostPort: number; + guestPort: number; +} + +export interface NativeAttachedDisk { + path: string; + readonly?: boolean; +} + +export interface NativeRunConfig { kernelPath: string; rootfsPath: string; overlayPath?: string; + attachDisks?: NativeAttachedDisk[]; memMiB: number; cpus?: number; cmdline: string; @@ -103,42 +217,288 @@ export interface KvmRunConfig { interactive?: boolean; netTapName?: string; netGuestMac?: string; + netHostIp?: string; + netGuestIp?: string; + netNetmask?: string; + netDns?: string; + netPortForwards?: PortForward[]; + netSlirpEnabled?: boolean; + netSlirpHostFwds?: NativeSlirpHostFwd[]; control?: Int32Array; } -export interface KvmRunResult { +export type KvmRunConfig = NativeRunConfig; +export type WhpRunConfig = NativeRunConfig; +export type HvfRunConfig = NativeRunConfig; + +export interface NativeRunResult { exitReason: string; exitReasonCode: number; runs: number; console: string; } -export interface NativeKvmBackend { +export type KvmRunResult = NativeRunResult; +export type WhpRunResult = NativeRunResult; + +export interface NativeVmRunner { + runVm(config: NativeRunConfig): NativeRunResult; +} + +export interface NativeKvmBackend extends NativeVmRunner { probeKvm(): KvmProbeResult; smokeHlt(): KvmSmokeResult; uartSmoke(): KvmSmokeResult; guestExitSmoke(): KvmSmokeResult; ramSnapshotSmoke(config: RamSnapshotSmokeConfig): RamSnapshotSmokeResult; dirtyRamSnapshotSmoke(config: DirtyRamSnapshotSmokeConfig): DirtyRamSnapshotSmokeResult; - runVm(config: KvmRunConfig): KvmRunResult; } -export interface NativeWhpBackend { +export interface WhpHostConsoleSize { + cols: number; + rows: number; +} + +export interface WhpElfLoaderResult { + entry: bigint; + kernelEnd: bigint; +} + +export interface WhpPageTablesSmokeResult { + base: bigint; + pml4_0: bigint; + pdpt_0: bigint; + pdpt_1: bigint; + pdpt_2: bigint; + pdpt_3: bigint; + pd0_0: bigint; + pd0_1: bigint; + pd3_511: bigint; +} + +export interface WhpBootParamsSmokeResult { + e820_entries: number; + vid_mode: number; + boot_sig: number; + kernel_sig: number; + type_of_loader: number; + loadflags: number; + cmd_line_ptr: number; + cmd_line_size: number; + e820_0_addr_lo: number; + e820_0_size_lo: number; + e820_0_type: number; + mp_signature_offset: number; + mp_signature_found: boolean; +} + +export interface WhpIrqStateSmokeConfig { + // Bit 9 of RFLAGS is IF; everything else is ignored. Default 0x202 (IF=1). + rflags?: number; + // STI / MOV-SS shadow blocking interrupts. + interruptShadow?: boolean; + // VpContext.ExecutionState.InterruptionPending — vCPU is mid-event-injection. + interruptionPending?: boolean; + // Set by InterruptWindow exit handler; cleared on inject. + readyForPic?: boolean; + // Set by timer/UART thread when an external interrupt should be delivered. + extIntPending?: boolean; + // Vector to inject (low 8 bits used). Defaults to 0x20 (PIT). + extIntVector?: number; + // Initial state for ArmInterruptWindow idempotence test. + windowRegistered?: boolean; +} + +export interface WhpIrqStateSmokeResult { + interruptable: boolean; + interruptFlag: boolean; + interruptionPending: boolean; + readyForPic: boolean; + windowRegistered: boolean; + // What TryDeliverPendingExtInt would do given the state. Mirrors the + // InjectDecision enum in native/whp/irq.h. + decision: "noPending" | "inject" | "armWindow"; + extIntVector: number; + extIntPending: boolean; +} + +export interface WhpPicSmokeConfig { + // Vector base programmed via ICW2. Linux's init_8259A picks 0x30; the + // BIOS reset value the Pic starts with is 0x20. + vectorBase?: number; + // OCW1 mask byte written after the ICW1-4 sequence completes. + maskAfterInit?: number; +} + +export interface WhpPicSmokeResult { + initializedBeforeIcw: boolean; + initializedAfterIcw: boolean; + vectorForIrq0Before: number; + vectorForIrq0After: number; + vectorForIrq3After: number; + maskRead: number; + irq0Unmasked: boolean; + irq2Unmasked: boolean; +} + +export interface WhpPitSmokeConfig { + // PIT reload value for channel 2. Tiny values let the OUT pin trip on + // realistic test sleeps. Default 10 (≈ 8.4 µs). + reload?: number; + // Sleep duration in milliseconds. Default 5 ms. + waitMs?: number; +} + +export interface WhpPitSmokeResult { + channel2OutBeforeGate: boolean; + channel2GatedBeforeGate: boolean; + channel2GatedAfterGate: boolean; + channel2OutAfterWait: boolean; + channel2OutAfterUngate: boolean; + reload: number; + waitMs: number; +} + +export interface WhpUartCrlfSmokeConfig { + // Bytes to feed through `Uart::NormalizeCrlf`. Default "a\nb\n". + input?: string; + // Initial value for the chained `last_byte` (lets callers verify the + // no-double-CR property when the previous chunk already ended in \r). + // Pass the byte value as a number (0-255). Default 0. + lastByte?: number; +} + +export interface WhpUartCrlfSmokeResult { + input: string; + normalized: string; + lastByte: number; +} + +export interface WhpConsoleWriterSmokeConfig { + input?: string; +} + +export interface WhpConsoleWriterSmokeResult { + input: string; + normalized: string; + containsDecSaveRestore: boolean; + hidesCursor: boolean; + showsCursor: boolean; +} + +export interface WhpUartRegisterSmokeResult { + initialLsr: number; + acceptedOne: number; + iirAfterOne: number; + acceptedFill: number; + iirAtTrigger: number; + firstRx: number; + acceptedOverflow: number; + overrunLsr: number; + overrunLsrAfterClear: number; + txIir: number; + txLsr: number; + irqCount: number; +} + +export interface WhpVirtioBlkSmokeResult { + // virtio-mmio v2 magic 0x74726976 ("virt") in little-endian. + magicValue: number; + // virtio-mmio version. v2 = 2. + version: number; + // virtio-blk device id (per spec) = 2. + deviceId: number; + // Vendor id. We report "QEMU" (0x554D4551) for compatibility. + vendorId: number; + // Maximum supported queue size; we expose 256. + queueNumMax: number; + // Sanity probe: no IRQ should be raised by reads alone. Always false. + irqRaisedBeforeWrite: boolean; +} + +export interface NativeWhpBackend extends NativeVmRunner { probeWhp(): WhpProbeResult; whpSmokeHlt(): WhpSmokeResult; + // Direct GetConsoleScreenBufferInfo query. Returns {cols:0, rows:0} when + // stdout isn't attached to a Windows console (piped, redirected, ssh, etc). + // More reliable than process.stdout.columns which is undefined in those + // cases and falls back to 80x24, breaking apk progress-bar wrapping. + whpHostConsoleSize?(): WhpHostConsoleSize; + // Loads `path` as an ELF64 vmlinux into a 256 MiB host scratch buffer and + // returns the entry point + kernel-end paddr. Used to unit-test the ELF + // loader without spinning up a full guest. Throws on bad ELFs (mismatched + // magic, wrong class/endianness/machine, truncated headers, segments + // that exceed the scratch buffer). Available only on Windows builds. + whpElfLoaderSmoke?(config: { path: string }): WhpElfLoaderResult; + // Builds an identity-map page table at base 0x9000 of a fresh 64 MiB + // scratch buffer and returns a few key entries so the JS side can + // assert the layout (PML4 / PDPT / PD with huge pages). Windows-only. + whpPageTablesSmoke?(): WhpPageTablesSmokeResult; + // Builds boot_params + e820 + MP table into a 16 MiB scratch buffer so + // the JS side can verify the on-the-wire layout without spinning up a + // guest. Windows-only. + whpBootParamsSmoke?(config?: { cmdline?: string; cpus?: number }): WhpBootParamsSmokeResult; + // Drives every branch of the IRQ delivery state machine without touching + // WHP. Used to unit-test UpdateVcpuFromExit + EvaluateInjectDecision (the + // can_inject decision used by TryDeliverPendingExtInt). Windows-only. + whpIrqStateSmoke?(config: WhpIrqStateSmokeConfig): WhpIrqStateSmokeResult; + // Drives the master 8259 PIC through the ICW1-4 reprogramming sequence + // and returns the post-init state. Windows-only. + whpPicSmoke?(config?: WhpPicSmokeConfig): WhpPicSmokeResult; + // Drives the i8254 PIT channel 2 OUT pin via gate + reload + sleep. + // Windows-only. + whpPitSmoke?(config?: WhpPitSmokeConfig): WhpPitSmokeResult; + // Pure CRLF normalization test for the UART. Lets us assert the + // apk-progress-bar regression fix at module level. Windows-only. + whpUartCrlfSmoke?(config?: WhpUartCrlfSmokeConfig): WhpUartCrlfSmokeResult; + // Host console normalizer test for apk progress redraw frames. Windows-only. + whpConsoleWriterSmoke?(config?: WhpConsoleWriterSmokeConfig): WhpConsoleWriterSmokeResult; + // Register-level UART test for FIFO/IIR/LSR behavior. Windows-only. + whpUartRegisterSmoke?(): WhpUartRegisterSmokeResult; + // Reads the virtio-blk MMIO identification registers (magic, version, + // device id, vendor id, queue-num-max). Constructs a 512-byte temp + // file as the rootfs to satisfy the constructor; no partition runs. + // Windows-only. + whpVirtioBlkSmoke?(): WhpVirtioBlkSmokeResult; +} + +export interface NativeHvfBackend { + probeHvf(): HvfProbeResult; + hvfFdtSmoke(): HvfFdtSmokeResult; + hvfPl011Smoke(): HvfPl011SmokeResult; + hvfDeviceSmoke(): HvfDeviceSmokeResult; + runVm(config: HvfRunConfig): KvmRunResult; } -export type NativeBackend = NativeKvmBackend & NativeWhpBackend; +export type NativeBackend = NativeKvmBackend & NativeWhpBackend & NativeHvfBackend; const require = createRequire(import.meta.url); const here = path.dirname(fileURLToPath(import.meta.url)); const addonPaths = [ - path.resolve(here, "../../prebuilds", `${process.platform}-${process.arch}`, "node_vmm_native.node"), path.resolve(here, "../../build/Release/node_vmm_native.node"), + path.resolve(here, "../../prebuilds", `${process.platform}-${process.arch}`, "node_vmm_native.node"), ]; let loadedNative: Partial<NativeBackend> | undefined; +// On Windows the native addon depends on libslirp + glib + iconv DLLs that +// ship alongside it (in prebuilds/win32-x64/ for installed packages, in +// build/Release/ for source builds). Windows does not search the .node +// file's own directory for dependent DLLs by default, so we prepend the +// addon directory to PATH before require()ing it. process.dlopen would be +// the more surgical fix but isn't exposed via createRequire. +function ensureWin32DllSearchPath(addonPath: string): void { + if (process.platform !== "win32") { + return; + } + const dir = path.dirname(addonPath); + const sep = ";"; + const current = process.env.PATH ?? ""; + if (!current.split(sep).some((p) => p.toLowerCase() === dir.toLowerCase())) { + process.env.PATH = current.length > 0 ? `${dir}${sep}${current}` : dir; + } +} + function loadNative(): Partial<NativeBackend> { if (loadedNative) { return loadedNative; @@ -149,6 +509,7 @@ function loadNative(): Partial<NativeBackend> { continue; } try { + ensureWin32DllSearchPath(addonPath); loadedNative = require(addonPath) as Partial<NativeBackend>; return loadedNative; } catch (error) { @@ -179,11 +540,123 @@ function requireNativeMethod<K extends keyof NativeBackend>(name: K): NativeBack export const native: NativeBackend = { probeKvm: () => requireNativeMethod("probeKvm")(), probeWhp: () => requireNativeMethod("probeWhp")(), + probeHvf: () => requireNativeMethod("probeHvf")(), + hvfFdtSmoke: () => requireNativeMethod("hvfFdtSmoke")(), + hvfPl011Smoke: () => requireNativeMethod("hvfPl011Smoke")(), + hvfDeviceSmoke: () => requireNativeMethod("hvfDeviceSmoke")(), smokeHlt: () => requireNativeMethod("smokeHlt")(), whpSmokeHlt: () => requireNativeMethod("whpSmokeHlt")(), uartSmoke: () => requireNativeMethod("uartSmoke")(), guestExitSmoke: () => requireNativeMethod("guestExitSmoke")(), ramSnapshotSmoke: (config) => requireNativeMethod("ramSnapshotSmoke")(config), dirtyRamSnapshotSmoke: (config) => requireNativeMethod("dirtyRamSnapshotSmoke")(config), + whpHostConsoleSize: () => { + const backend = loadNative(); + const method = backend.whpHostConsoleSize; + if (typeof method !== "function") { + return { cols: 0, rows: 0 }; + } + return method(); + }, + whpElfLoaderSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpElfLoaderSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpElfLoaderSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpPageTablesSmoke: () => { + const backend = loadNative(); + const method = backend.whpPageTablesSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpPageTablesSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(); + }, + whpBootParamsSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpBootParamsSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpBootParamsSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpIrqStateSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpIrqStateSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpIrqStateSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpPicSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpPicSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpPicSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpPitSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpPitSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpPitSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpUartCrlfSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpUartCrlfSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpUartCrlfSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpConsoleWriterSmoke: (config) => { + const backend = loadNative(); + const method = backend.whpConsoleWriterSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpConsoleWriterSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(config); + }, + whpUartRegisterSmoke: () => { + const backend = loadNative(); + const method = backend.whpUartRegisterSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpUartRegisterSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(); + }, + whpVirtioBlkSmoke: () => { + const backend = loadNative(); + const method = backend.whpVirtioBlkSmoke; + if (typeof method !== "function") { + throw new NodeVmmError( + `whpVirtioBlkSmoke is unavailable on ${process.platform}/${process.arch} (Windows-only)`, + ); + } + return method(); + }, runVm: (config) => requireNativeMethod("runVm")(config), }; diff --git a/src/net.ts b/src/net.ts index ec0999c..44926a2 100644 --- a/src/net.ts +++ b/src/net.ts @@ -1,7 +1,8 @@ +import { existsSync } from "node:fs"; import net from "node:net"; import { requireCommands, runCommand } from "./process.js"; -import type { NetworkConfig, PortForward, PortForwardInput } from "./types.js"; +import type { NetworkConfig, PortForward, PortForwardInput, SlirpHostFwd } from "./types.js"; import { NodeVmmError, compactIdForTap, deterministicMac, requireRoot } from "./utils.js"; export interface SetupNetworkOptions { @@ -19,6 +20,28 @@ function randomSubnetOctet(id: string): number { return 80 + sum; } +function socketVmnetPath(): string | undefined { + const configured = process.env.NODE_VMM_SOCKET_VMNET; + if (configured) { + return configured; + } + const candidates = [ + "/opt/socket_vmnet/socket_vmnet", + "/var/run/socket_vmnet", + "/opt/homebrew/var/run/socket_vmnet", + "/usr/local/var/run/socket_vmnet", + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +function macNetworkBackend(): "slirp" | "socket_vmnet" | "vmnet" { + const backend = (process.env.NODE_VMM_HVF_NET_BACKEND || process.env.NODE_VMM_MAC_NET || "slirp").trim(); + if (backend === "socket_vmnet" || backend === "vmnet" || backend === "slirp") { + return backend; + } + throw new NodeVmmError(`unsupported macOS HVF network backend: ${backend}`); +} + function validatePort(port: number, label: string, options: { allowZero?: boolean } = {}): number { const min = options.allowZero ? 0 : 1; if (!Number.isInteger(port) || port < min || port > 65535) { @@ -114,6 +137,18 @@ async function closeServer(server: net.Server): Promise<void> { }); } +async function resolveHostPort(host: string, port: number): Promise<number> { + if (port !== 0) { + return port; + } + const server = net.createServer(); + try { + return await listen(server, host, 0); + } finally { + await closeServer(server); + } +} + async function setupPortForwards( inputs: PortForwardInput[] | undefined, guestIp: string, @@ -169,10 +204,38 @@ async function setupPortForwards( }; } +async function allocatePortForwards(inputs: PortForwardInput[] | undefined): Promise<PortForward[]> { + const requested = (inputs ?? []).map(parsePortForward); + const active: PortForward[] = []; + for (const mapping of requested) { + const host = mapping.host || "127.0.0.1"; + if (mapping.hostPort !== 0) { + active.push({ host, hostPort: mapping.hostPort, guestPort: mapping.guestPort }); + continue; + } + const server = net.createServer(); + try { + const hostPort = await listen(server, host, 0); + active.push({ host, hostPort, guestPort: mapping.guestPort }); + } finally { + await closeServer(server); + } + } + return active; +} + export async function setupNetwork(options: SetupNetworkOptions): Promise<NetworkConfig> { - const mode = options.mode || (options.tapName ? "tap" : "none"); + let mode = options.mode || (options.tapName ? "tap" : "none"); + // On Windows/WHP and macOS/HVF, "auto" resolves to libslirp user-mode + // networking. On Linux/KVM, "auto" stays as the TAP+NAT path further below. + if (mode === "auto" && process.platform === "win32") { + mode = "slirp"; + } + if (mode === "auto" && process.platform === "darwin" && macNetworkBackend() === "slirp") { + mode = "slirp"; + } if (mode === "none" && (options.ports?.length ?? 0) > 0) { - throw new NodeVmmError("--publish requires --net auto"); + throw new NodeVmmError("--publish requires --net auto or --net slirp"); } if (mode === "none") { return { mode: "none" }; @@ -180,11 +243,14 @@ export async function setupNetwork(options: SetupNetworkOptions): Promise<Networ if (mode === "tap") { if ((options.ports?.length ?? 0) > 0) { - throw new NodeVmmError("--publish requires --net auto"); + throw new NodeVmmError("--publish requires --net auto or --net slirp"); } if (!options.tapName) { throw new NodeVmmError("--net tap requires --tap NAME"); } + if (process.platform === "darwin") { + throw new NodeVmmError("macOS/HVF does not support arbitrary --net tap devices; use --net auto for slirp networking"); + } return { mode: "tap", ifaceId: "eth0", @@ -193,10 +259,85 @@ export async function setupNetwork(options: SetupNetworkOptions): Promise<Networ }; } + if (mode === "slirp") { + // libslirp uses a fixed 10.0.2.0/24 layout; the guest gets 10.0.2.15, + // host is reachable at 10.0.2.2, DNS at 10.0.2.3. Port forwarding goes + // through libslirp's hostfwd, so the host-side TCP proxy used for `auto` + // mode is not required. + const hostFwds: SlirpHostFwd[] = []; + for (const p of (options.ports ?? []).map(parsePortForward)) { + const hostAddr = p.host || "127.0.0.1"; + hostFwds.push({ + udp: false, + hostAddr, + hostPort: await resolveHostPort(hostAddr, p.hostPort), + guestPort: p.guestPort, + }); + } + return { + mode: "slirp", + ifaceId: "eth0", + tapName: process.platform === "darwin" ? "slirp" : undefined, + guestMac: deterministicMac(options.id), + guestIp: "10.0.2.15", + hostIp: "10.0.2.2", + netmask: "255.255.255.0", + cidrPrefix: 24, + dns: "10.0.2.3", + cidr: "10.0.2.15/24", + // Skip the kernel-side ip= autoconf because it forces the kernel to + // wait for the eth0 carrier; the userspace init script in src/rootfs.ts + // brings up the interface itself once it sees node_vmm.iface=... + kernelNetArgs: + "node_vmm.iface=eth0 node_vmm.ip=10.0.2.15/24 node_vmm.gw=10.0.2.2 node_vmm.dns=10.0.2.3", + ports: hostFwds.map((h) => ({ + host: h.hostAddr, + hostPort: h.hostPort, + guestPort: h.guestPort, + })), + hostFwds, + cleanup: async () => {}, + }; + } + if (mode !== "auto") { throw new NodeVmmError(`unsupported network mode: ${mode}`); } + // macOS: prefer QEMU-style user networking through libslirp. vmnet/socket_vmnet + // remain available through NODE_VMM_HVF_NET_BACKEND for privileged setups. + if (process.platform === "darwin") { + const backend = macNetworkBackend(); + const vmnetSocket = backend === "socket_vmnet" ? socketVmnetPath() : undefined; + if (backend === "socket_vmnet" && !vmnetSocket) { + throw new NodeVmmError("NODE_VMM_HVF_NET_BACKEND=socket_vmnet requires NODE_VMM_SOCKET_VMNET or a socket_vmnet socket"); + } + const slirp = backend === "slirp"; + const octet = randomSubnetOctet(options.id); + const hostIp = slirp ? "10.0.2.2" : `172.31.${octet}.1`; + const guestIp = slirp ? "10.0.2.15" : `172.31.${octet}.2`; + const netmask = slirp ? "255.255.255.0" : "255.255.255.252"; + const cidrPrefix = slirp ? 24 : 30; + const dns = process.env.NODE_VMM_GUEST_DNS || (slirp ? "10.0.2.3" : "1.1.1.1"); + const portForwards = slirp ? undefined : await setupPortForwards(options.ports, guestIp); + const ports = slirp ? await allocatePortForwards(options.ports) : portForwards?.ports; + return { + mode: slirp ? "slirp" : "tap", + ifaceId: "eth0", + tapName: slirp ? "slirp" : (vmnetSocket ? `socket_vmnet:${vmnetSocket}` : "vmnet:shared"), + guestMac: deterministicMac(options.id), + hostIp, + guestIp, + netmask, + cidrPrefix, + dns, + kernelIpArg: `ip=${guestIp}::${hostIp}:${netmask}:node-vmm:eth0:off`, + kernelNetArgs: `node_vmm.iface=eth0 node_vmm.ip=${guestIp}/${cidrPrefix} node_vmm.gw=${hostIp} node_vmm.dns=${dns}`, + ports, + cleanup: portForwards?.cleanup ?? (async () => undefined), + }; + } + requireRoot("--net auto"); await requireCommands(["ip", "sysctl", "iptables"]); @@ -258,7 +399,6 @@ export async function setupNetwork(options: SetupNetworkOptions): Promise<Networ netmask, cidrPrefix: 30, dns, - kernelIpArg: `ip=${guestIp}::${hostIp}:${netmask}:node-vmm:eth0:off`, kernelNetArgs: `node_vmm.iface=eth0 node_vmm.ip=${guestIp}/30 node_vmm.gw=${hostIp} node_vmm.dns=${dns}`, ports: portForwards.ports, cleanup: cleanupAuto, diff --git a/src/oci.ts b/src/oci.ts index a5f5dfd..ac43180 100644 --- a/src/oci.ts +++ b/src/oci.ts @@ -154,6 +154,14 @@ function parseBearerChallenge(header: string | null): Record<string, string> | n return params; } +interface BlobDownloadProgress { + downloaded: number; + total?: number; + done: boolean; +} + +type BlobDownloadProgressCallback = (progress: BlobDownloadProgress) => void; + class RegistryClient { private token = ""; @@ -164,7 +172,11 @@ class RegistryClient { return (await response.json()) as T; } - async downloadBlob(descriptor: Descriptor, output: string): Promise<void> { + async downloadBlob( + descriptor: Descriptor, + output: string, + progress?: BlobDownloadProgressCallback, + ): Promise<void> { const maxBytes = ociByteLimit(OCI_MAX_BLOB_BYTES_ENV, DEFAULT_OCI_MAX_BLOB_BYTES); if (descriptor.size !== undefined && descriptor.size > maxBytes) { throw new NodeVmmError(`OCI blob is too large: ${descriptor.digest} ${descriptor.size} bytes exceeds ${maxBytes}`); @@ -177,11 +189,16 @@ class RegistryClient { if (contentLength !== undefined && contentLength > maxBytes) { throw new NodeVmmError(`OCI blob is too large: ${descriptor.digest} ${contentLength} bytes exceeds ${maxBytes}`); } + const total = contentLength ?? descriptor.size; + const progressTransform = progress ? new BlobProgressTransform(total, progress) : undefined; + progress?.({ downloaded: 0, total, done: false }); await pipeline( Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]), new ByteLimitTransform(maxBytes, `OCI blob ${descriptor.digest}`), + ...(progressTransform ? [progressTransform] : []), createWriteStream(output), ); + progress?.({ downloaded: progressTransform?.bytes ?? contentLength ?? descriptor.size ?? 0, total, done: true }); } private async fetchAuthorized(apiPath: string, accept: string): Promise<Response> { @@ -275,6 +292,34 @@ class ByteLimitTransform extends Transform { } } +class BlobProgressTransform extends Transform { + private downloaded = 0; + private lastReportBytes = 0; + private lastReportMs = Date.now(); + + constructor(private readonly total: number | undefined, private readonly progress: BlobDownloadProgressCallback) { + super(); + } + + get bytes(): number { + return this.downloaded; + } + + override _transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void { + this.downloaded += chunk.byteLength; + const now = Date.now(); + if ( + now - this.lastReportMs >= 1000 || + this.downloaded - this.lastReportBytes >= 8 * 1024 * 1024 + ) { + this.lastReportMs = now; + this.lastReportBytes = this.downloaded; + this.progress({ downloaded: this.downloaded, total: this.total, done: false }); + } + callback(null, chunk); + } +} + function ociByteLimit(envName: string, fallback: number): number { const raw = process.env[envName]; if (!raw) { @@ -357,6 +402,51 @@ async function verifyDigestFile(file: string, digest: string): Promise<void> { } } +function formatOciBytes(bytes: number): string { + const units = ["B", "KiB", "MiB", "GiB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const digits = unit === 0 || value >= 10 ? 0 : 1; + return `${value.toFixed(digits)} ${units[unit]}`; +} + +function formatOciDownloadProgress(progress: BlobDownloadProgress): string { + if (progress.total !== undefined && progress.total > 0) { + const percent = Math.min(100, Math.floor((progress.downloaded / progress.total) * 100)); + return `${formatOciBytes(progress.downloaded)} / ${formatOciBytes(progress.total)} (${percent}%)`; + } + return formatOciBytes(progress.downloaded); +} + +function createBlobProgressReporter(descriptor: Descriptor): BlobDownloadProgressCallback { + const digest = descriptor.digest.slice(0, 19); + let started = false; + let lastText = ""; + return (progress) => { + const text = formatOciDownloadProgress(progress); + if (progress.done) { + if (text !== lastText) { + process.stdout.write(`[oci] downloaded ${digest} ${text}\n`); + } + return; + } + if (!started) { + started = true; + const totalText = progress.total !== undefined ? ` (${formatOciBytes(progress.total)})` : ""; + process.stdout.write(`[oci] downloading ${digest}${totalText}\n`); + return; + } + if (text !== lastText) { + lastText = text; + process.stdout.write(`[oci] downloaded ${digest} ${text}\n`); + } + }; +} + async function downloadBlobCached(client: RegistryClient, cacheDir: string, descriptor: Descriptor): Promise<string> { const output = await cachedBlobPath(cacheDir, descriptor.digest); if (await pathExists(output)) { @@ -365,7 +455,7 @@ async function downloadBlobCached(client: RegistryClient, cacheDir: string, desc } const tmp = `${output}.tmp-${process.pid}`; try { - await client.downloadBlob(descriptor, tmp); + await client.downloadBlob(descriptor, tmp, createBlobProgressReporter(descriptor)); await verifyDigestFile(tmp, descriptor.digest); await rename(tmp, output); } catch (error) { diff --git a/src/prebuilt-rootfs.ts b/src/prebuilt-rootfs.ts new file mode 100644 index 0000000..41f56fb --- /dev/null +++ b/src/prebuilt-rootfs.ts @@ -0,0 +1,460 @@ +// Optional fetch path for prebuilt ext4 rootfs files published as +// GitHub Release assets by .github/workflows/prebuilt-rootfs.yml. +// +// On Windows, building a rootfs from an OCI image requires WSL2. For the +// most common images we publish prebuilt ext4 files alongside each tagged +// release. If a user runs `node-vmm run --image alpine:3.20` and the +// prebuilt is available for the package's version, we download + gunzip it +// instead of going through WSL2. +// +// Falls back silently to the WSL2 path on any error (network, 404, +// checksum mismatch, etc). Best effort: never the sole boot path. + +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { readFile, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PassThrough, Readable, Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip, constants as zlibConstants } from "node:zlib"; + +import { NodeVmmError } from "./utils.js"; + +export interface PrebuiltRootfsImageMapping { + image: string; + slug: string; + diskMiB: number; + aliases?: readonly string[]; +} + +export const PREBUILT_ROOTFS_IMAGES: readonly PrebuiltRootfsImageMapping[] = [ + { image: "alpine:3.20", slug: "alpine-3.20", diskMiB: 256, aliases: ["library/alpine:3.20"] }, + { image: "node:20-alpine", slug: "node-20-alpine", diskMiB: 1024, aliases: ["library/node:20-alpine"] }, + { image: "node:22-alpine", slug: "node-22-alpine", diskMiB: 1024, aliases: ["library/node:22-alpine"] }, + { image: "oven/bun:1-alpine", slug: "oven-bun-1-alpine", diskMiB: 1024 }, +]; + +export const PREBUILT_ROOTFS_MANIFEST_KIND = "node-vmm-prebuilt-rootfs"; +export const PREBUILT_ROOTFS_MANIFEST_VERSION = 1; + +export interface PrebuiltRootfsFileMetadata { + name: string; + sizeBytes: number; + sha256: string; +} + +export interface PrebuiltRootfsManifest { + kind: typeof PREBUILT_ROOTFS_MANIFEST_KIND; + version: typeof PREBUILT_ROOTFS_MANIFEST_VERSION; + image: string; + slug: string; + diskMiB: number; + platform: "linux"; + arch: "x86_64"; + createdAt: string; + rootfs: PrebuiltRootfsFileMetadata; + gzip: PrebuiltRootfsFileMetadata; +} + +export interface PrebuiltRootfsAssetNames { + rootfs: string; + gzip: string; + manifest: string; +} + +export interface PrebuiltRootfsAssetUrls extends PrebuiltRootfsAssetNames { + rootfsUrl: string; + gzipUrl: string; + manifestUrl: string; +} + +export interface PrebuiltRootfsFetchOptions { + image: string; + destPath: string; + // Package version used as the release tag (e.g. "0.1.4" -> tag "v0.1.4"). + packageVersion: string; + // Optional override for the GitHub repo (owner/name). Defaults to the + // upstream node-vmm repo. + repo?: string; + signal?: AbortSignal; + // Logger. Same shape as defaults.logger elsewhere in the codebase. + logger?: (message: string) => void; +} + +export interface PrebuiltRootfsFetchResult { + fetched: boolean; + reason?: string; + manifest?: PrebuiltRootfsManifest; +} + +export interface CreatePrebuiltRootfsManifestOptions { + image: string; + rootfsPath: string; + gzipPath: string; + diskMiB: number; + slug?: string; + createdAt?: string | Date; +} + +interface DownloadVerification { + rootfsSha256: string; + rootfsSizeBytes: number; + gzipSha256: string; + gzipSizeBytes: number; +} + +const DEFAULT_REPO = "misaelzapata/node-vmm"; +const PRODUCT_NAME = "node-vmm"; +const SHA256_HEX = /^[0-9a-f]{64}$/; +const ASSET_NAME = /^[A-Za-z0-9._-]+$/; + +function normalizeImageReference(image: string): string { + let normalized = image.trim().toLowerCase(); + normalized = normalized.replace(/^https?:\/\//, ""); + if (normalized.startsWith("index.docker.io/")) { + normalized = `docker.io/${normalized.slice("index.docker.io/".length)}`; + } + if (normalized.startsWith("docker.io/library/")) { + normalized = normalized.slice("docker.io/library/".length); + } else if (normalized.startsWith("docker.io/")) { + normalized = normalized.slice("docker.io/".length); + } + if (normalized.startsWith("library/")) { + normalized = normalized.slice("library/".length); + } + return normalized; +} + +function releaseTagForPackageVersion(version: string): string { + const trimmed = version.trim(); + return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; +} + +function releaseAssetUrl(repo: string, version: string, assetName: string): string { + return `https://github.com/${repo}/releases/download/${releaseTagForPackageVersion(version)}/${assetName}`; +} + +function validateAssetName(name: string, label: string): void { + if (!ASSET_NAME.test(name)) { + throw new NodeVmmError(`bad ${label} asset name: ${name}`); + } +} + +function assertFileMetadata(value: unknown, label: string): PrebuiltRootfsFileMetadata { + if (!value || typeof value !== "object") { + throw new NodeVmmError(`manifest ${label} metadata is missing`); + } + const record = value as Partial<PrebuiltRootfsFileMetadata>; + if (typeof record.name !== "string" || record.name.length === 0) { + throw new NodeVmmError(`manifest ${label}.name is invalid`); + } + validateAssetName(record.name, `${label}.name`); + const sizeBytes = record.sizeBytes; + if (typeof sizeBytes !== "number" || !Number.isSafeInteger(sizeBytes) || sizeBytes < 0) { + throw new NodeVmmError(`manifest ${label}.sizeBytes is invalid`); + } + if (typeof record.sha256 !== "string" || !SHA256_HEX.test(record.sha256.toLowerCase())) { + throw new NodeVmmError(`manifest ${label}.sha256 is invalid`); + } + return { + name: record.name, + sizeBytes, + sha256: record.sha256.toLowerCase(), + }; +} + +export function validatePrebuiltRootfsManifest(value: unknown): PrebuiltRootfsManifest { + if (!value || typeof value !== "object") { + throw new NodeVmmError("manifest is not an object"); + } + const record = value as Partial<PrebuiltRootfsManifest>; + if (record.kind !== PREBUILT_ROOTFS_MANIFEST_KIND) { + throw new NodeVmmError("manifest kind is invalid"); + } + if (record.version !== PREBUILT_ROOTFS_MANIFEST_VERSION) { + throw new NodeVmmError("manifest version is invalid"); + } + if (typeof record.image !== "string" || record.image.length === 0) { + throw new NodeVmmError("manifest image is invalid"); + } + if (typeof record.slug !== "string" || record.slug.length === 0) { + throw new NodeVmmError("manifest slug is invalid"); + } + validateAssetName(record.slug, "slug"); + const diskMiB = record.diskMiB; + if (typeof diskMiB !== "number" || !Number.isSafeInteger(diskMiB) || diskMiB <= 0) { + throw new NodeVmmError("manifest diskMiB is invalid"); + } + if (record.platform !== "linux") { + throw new NodeVmmError("manifest platform is invalid"); + } + if (record.arch !== "x86_64") { + throw new NodeVmmError("manifest arch is invalid"); + } + if (typeof record.createdAt !== "string" || Number.isNaN(Date.parse(record.createdAt))) { + throw new NodeVmmError("manifest createdAt is invalid"); + } + return { + kind: PREBUILT_ROOTFS_MANIFEST_KIND, + version: PREBUILT_ROOTFS_MANIFEST_VERSION, + image: record.image, + slug: record.slug, + diskMiB, + platform: "linux", + arch: "x86_64", + createdAt: record.createdAt, + rootfs: assertFileMetadata(record.rootfs, "rootfs"), + gzip: assertFileMetadata(record.gzip, "gzip"), + }; +} + +export function prebuiltRootfsImageForImage(image: string): PrebuiltRootfsImageMapping | null { + const normalized = normalizeImageReference(image); + return ( + PREBUILT_ROOTFS_IMAGES.find((mapping) => { + if (normalizeImageReference(mapping.image) === normalized) { + return true; + } + return mapping.aliases?.some((alias) => normalizeImageReference(alias) === normalized) ?? false; + }) ?? null + ); +} + +// Map an OCI image reference to the slug used in the release asset name. +// Slugs MUST match scripts/build-prebuilt-rootfs.mjs + .github/workflows/ +// prebuilt-rootfs.yml. Returning null means "no prebuilt available; fall +// back to the WSL2 path". +export function prebuiltSlugForImage(image: string): string | null { + return prebuiltRootfsImageForImage(image)?.slug ?? null; +} + +export function prebuiltRootfsAssetNames(slug: string): PrebuiltRootfsAssetNames { + validateAssetName(slug, "slug"); + const rootfs = `${slug}.ext4`; + return { + rootfs, + gzip: `${rootfs}.gz`, + manifest: `${rootfs}.manifest.json`, + }; +} + +export function prebuiltRootfsAssetUrls(repo: string, version: string, slug: string): PrebuiltRootfsAssetUrls { + const names = prebuiltRootfsAssetNames(slug); + return { + ...names, + rootfsUrl: releaseAssetUrl(repo, version, names.rootfs), + gzipUrl: releaseAssetUrl(repo, version, names.gzip), + manifestUrl: releaseAssetUrl(repo, version, names.manifest), + }; +} + +export async function sha256File(filePath: string): Promise<string> { + const hash = createHash("sha256"); + await pipeline( + createReadStream(filePath), + new Writable({ + write(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + callback(); + }, + }), + ); + return hash.digest("hex"); +} + +export async function fileSizeBytes(filePath: string): Promise<number> { + return (await stat(filePath)).size; +} + +export async function fileMetadata(filePath: string, name = path.basename(filePath)): Promise<PrebuiltRootfsFileMetadata> { + validateAssetName(name, "metadata"); + const [sizeBytes, sha256] = await Promise.all([fileSizeBytes(filePath), sha256File(filePath)]); + return { name, sizeBytes, sha256 }; +} + +export async function gzipPrebuiltRootfs(rootfsPath: string, gzipPath = `${rootfsPath}.gz`): Promise<string> { + await pipeline(createReadStream(rootfsPath), createGzip({ level: zlibConstants.Z_BEST_COMPRESSION }), createWriteStream(gzipPath)); + return gzipPath; +} + +export async function createPrebuiltRootfsManifest( + options: CreatePrebuiltRootfsManifestOptions, +): Promise<PrebuiltRootfsManifest> { + const slug = options.slug ?? prebuiltSlugForImage(options.image); + if (!slug) { + throw new NodeVmmError(`no prebuilt mapping for ${options.image}`); + } + validateAssetName(slug, "slug"); + const names = prebuiltRootfsAssetNames(slug); + const createdAt = + options.createdAt instanceof Date + ? options.createdAt.toISOString() + : (options.createdAt ?? new Date().toISOString()); + const manifest = { + kind: PREBUILT_ROOTFS_MANIFEST_KIND, + version: PREBUILT_ROOTFS_MANIFEST_VERSION, + image: options.image, + slug, + diskMiB: options.diskMiB, + platform: "linux", + arch: "x86_64", + createdAt, + rootfs: await fileMetadata(options.rootfsPath, names.rootfs), + gzip: await fileMetadata(options.gzipPath, names.gzip), + }; + return validatePrebuiltRootfsManifest(manifest); +} + +export async function writePrebuiltRootfsManifest(filePath: string, manifest: PrebuiltRootfsManifest): Promise<void> { + await writeFile(filePath, `${JSON.stringify(validatePrebuiltRootfsManifest(manifest), null, 2)}\n`, "utf8"); +} + +async function fetchPrebuiltRootfsManifest(manifestUrl: string, signal?: AbortSignal): Promise<PrebuiltRootfsManifest> { + const resp = await fetch(manifestUrl, { signal }); + if (!resp.ok) { + throw new NodeVmmError(`failed to fetch manifest: ${resp.status} ${resp.statusText}`); + } + return validatePrebuiltRootfsManifest(await resp.json()); +} + +function assertDownloadedMetadata(label: string, actualSha256: string, actualSizeBytes: number, expected: PrebuiltRootfsFileMetadata): void { + if (actualSha256.toLowerCase() !== expected.sha256.toLowerCase()) { + throw new NodeVmmError(`${label} checksum mismatch: expected ${expected.sha256}, got ${actualSha256}`); + } + if (actualSizeBytes !== expected.sizeBytes) { + throw new NodeVmmError(`${label} size mismatch: expected ${expected.sizeBytes}, got ${actualSizeBytes}`); + } +} + +// Streams the gzipped asset into destPath, decompressing on the fly. +// Verifies the gzip and extracted rootfs checksums/sizes against the manifest. +async function fetchAndExtract( + gzipUrl: string, + manifest: PrebuiltRootfsManifest, + destPath: string, + signal?: AbortSignal, +): Promise<DownloadVerification> { + const gzResp = await fetch(gzipUrl, { signal }); + if (!gzResp.ok) { + throw new NodeVmmError(`failed to fetch rootfs: ${gzResp.status} ${gzResp.statusText}`); + } + if (!gzResp.body) { + throw new NodeVmmError("rootfs response has no body"); + } + + const gzipHash = createHash("sha256"); + const rootfsHash = createHash("sha256"); + let gzipSizeBytes = 0; + let rootfsSizeBytes = 0; + + const gzipTap = new PassThrough(); + gzipTap.on("data", (chunk: Buffer) => { + gzipHash.update(chunk); + gzipSizeBytes += chunk.byteLength; + }); + + const rootfsTap = new PassThrough(); + rootfsTap.on("data", (chunk: Buffer) => { + rootfsHash.update(chunk); + rootfsSizeBytes += chunk.byteLength; + }); + + const source = Readable.fromWeb(gzResp.body as never); + const gunzip = createGunzip(); + const file = createWriteStream(destPath); + await pipeline(source, gzipTap, gunzip, rootfsTap, file); + + const verification = { + gzipSha256: gzipHash.digest("hex").toLowerCase(), + gzipSizeBytes, + rootfsSha256: rootfsHash.digest("hex").toLowerCase(), + rootfsSizeBytes, + }; + try { + assertDownloadedMetadata("prebuilt gzip", verification.gzipSha256, verification.gzipSizeBytes, manifest.gzip); + assertDownloadedMetadata("prebuilt rootfs", verification.rootfsSha256, verification.rootfsSizeBytes, manifest.rootfs); + } catch (error) { + await rm(destPath, { force: true }); + throw error; + } + return verification; +} + +// Tries to fetch a prebuilt rootfs for `image` and place it at `destPath`. +// Returns { fetched: true } on success, { fetched: false, reason: "..." } +// on any non-fatal miss (no prebuilt mapping, 404, checksum mismatch, +// network error). Caller should fall through to the WSL2 build path on +// fetched: false. +export async function tryFetchPrebuiltRootfs( + options: PrebuiltRootfsFetchOptions, +): Promise<PrebuiltRootfsFetchResult> { + const mapping = prebuiltRootfsImageForImage(options.image); + if (!mapping) { + return { fetched: false, reason: `no prebuilt mapping for ${options.image}` }; + } + const repo = options.repo ?? DEFAULT_REPO; + const urls = prebuiltRootfsAssetUrls(repo, options.packageVersion, mapping.slug); + options.logger?.(`${PRODUCT_NAME} fetching prebuilt rootfs manifest: ${urls.manifestUrl}`); + try { + const manifest = await fetchPrebuiltRootfsManifest(urls.manifestUrl, options.signal); + if (manifest.slug !== mapping.slug) { + throw new NodeVmmError(`manifest slug mismatch: expected ${mapping.slug}, got ${manifest.slug}`); + } + if (manifest.gzip.name !== urls.gzip) { + throw new NodeVmmError(`manifest gzip asset mismatch: expected ${urls.gzip}, got ${manifest.gzip.name}`); + } + const gzipUrl = releaseAssetUrl(repo, options.packageVersion, manifest.gzip.name); + options.logger?.(`${PRODUCT_NAME} fetching prebuilt rootfs: ${gzipUrl}`); + await fetchAndExtract(gzipUrl, manifest, options.destPath, options.signal); + options.logger?.(`${PRODUCT_NAME} prebuilt rootfs ready: ${options.destPath}`); + return { fetched: true, manifest }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + // Make sure we don't leave a half-written file behind that could + // confuse the cache layer above us. + await rm(options.destPath, { force: true }).catch(() => {}); + options.logger?.(`${PRODUCT_NAME} prebuilt rootfs unavailable: ${reason}`); + return { fetched: false, reason }; + } +} + +// Reads the package version from package.json so the SDK can pin its +// asset fetches to the right release tag without the caller plumbing it +// through every API. +let cachedPackageVersion: string | null | undefined; + +export async function readPackageVersion(): Promise<string | null> { + if (cachedPackageVersion !== undefined) { + return cachedPackageVersion; + } + try { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + const packageJsonPath = path.join(dir, "package.json"); + try { + const parsed = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: string; name?: string }; + if (parsed.name === "@misaelzapata/node-vmm" && typeof parsed.version === "string") { + cachedPackageVersion = parsed.version; + return cachedPackageVersion; + } + } catch { + // Keep walking up until the filesystem root. + } + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + } catch { + // fall through + } + cachedPackageVersion = null; + return null; +} + +// Test-only override. Lets unit tests reset the cache between assertions. +export function __resetPackageVersionCacheForTests(): void { + cachedPackageVersion = undefined; +} diff --git a/src/process.ts b/src/process.ts index 2248823..95845d9 100644 --- a/src/process.ts +++ b/src/process.ts @@ -55,6 +55,7 @@ export async function runCommand( return; } try { + /* c8 ignore next 3 - detached process-group signaling is POSIX-specific; timeout and abort tests still cover cleanup behavior. */ if (detached) { process.kill(-child.pid, signal); } else { @@ -83,10 +84,12 @@ export async function runCommand( }; /* c8 ignore stop */ + /* c8 ignore start - host signal forwarding is covered on POSIX; Windows cannot safely self-signal in local coverage. */ const handleSignal = (signal: NodeJS.Signals): void => { forwardedSignal = signal; signalChild(signal); }; + /* c8 ignore stop */ const handleAbort = (): void => { aborted = true; @@ -154,6 +157,7 @@ export async function runCommand( if (timedOut && !options.allowFailure) { throw new NodeVmmError(`command timed out after ${options.timeoutMs}ms: ${command} ${args.join(" ")}`); } + /* c8 ignore next 3 - paired with the POSIX-only host signal forwarding path above. */ if (forwardedSignal && !options.allowFailure) { throw new NodeVmmError(`command interrupted by ${forwardedSignal}: ${command} ${args.join(" ")}`); } @@ -165,7 +169,9 @@ export async function runCommand( } export async function commandExists(command: string): Promise<boolean> { - const result = await runCommand("which", [command], { capture: true, allowFailure: true }); + /* c8 ignore next - one host covers either Windows or POSIX finder selection; commandExists behavior is tested for present/missing commands. */ + const finder = process.platform === "win32" ? "where.exe" : "which"; + const result = await runCommand(finder, [command], { capture: true, allowFailure: true }); return result.code === 0; } diff --git a/src/rootfs-darwin.ts b/src/rootfs-darwin.ts new file mode 100644 index 0000000..a94ca77 --- /dev/null +++ b/src/rootfs-darwin.ts @@ -0,0 +1,104 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { extractOciImageToDir, pullOciImage } from "./oci.js"; +import { runCommand } from "./process.js"; +import type { ImageConfig, RootfsBuildOptions, StringMap } from "./types.js"; +import { + NodeVmmError, + commandLineFromImage, + pathExists, + renderEnvFile, + workdirFromImage, +} from "./utils.js"; + +export interface DarwinRootfsDeps { + buildDockerfileRootfs(options: RootfsBuildOptions, targetDir: string): Promise<ImageConfig>; + ensureGuestCaCertificates(rootfs: string): Promise<void>; + mergeEnv(imageConfig: ImageConfig, userEnv: StringMap): StringMap; + renderInitScript(options: { commandLine: string; workdir: string; mode?: "batch" | "interactive" }): string; + emptyImageConfig: ImageConfig; +} + +export async function findDarwinMkfsExt4(): Promise<string> { + const inPath = await runCommand("which", ["mkfs.ext4"], { capture: true, allowFailure: true }); + if (inPath.code === 0 && inPath.stdout.trim()) { + return inPath.stdout.trim(); + } + for (const prefix of ["/opt/homebrew", "/usr/local"]) { + for (const sub of ["opt/e2fsprogs/sbin", "sbin"]) { + const candidate = path.join(prefix, sub, "mkfs.ext4"); + if (await pathExists(candidate)) { + return candidate; + } + } + } + const brewResult = await runCommand("brew", ["--prefix", "e2fsprogs"], { capture: true, allowFailure: true }); + if (brewResult.code === 0 && brewResult.stdout.trim()) { + const candidate = path.join(brewResult.stdout.trim(), "sbin", "mkfs.ext4"); + if (await pathExists(candidate)) { + return candidate; + } + } + throw new NodeVmmError( + "mkfs.ext4 not found. Install e2fsprogs via Homebrew:\n" + + " brew install e2fsprogs\n" + + 'Then add to PATH: export PATH="$(brew --prefix e2fsprogs)/sbin:$PATH"', + ); +} + +export async function rejectDarwinDockerfileRunInstruction(): Promise<void> { + throw new NodeVmmError( + "Dockerfile RUN instructions are not supported on macOS (requires chroot/unshare). " + + "Use an OCI image directly with --image instead.", + ); +} + +export async function buildRootfsDarwin(options: RootfsBuildOptions, deps: DarwinRootfsDeps): Promise<void> { + if (!options.image && !options.dockerfile) { + throw new NodeVmmError("build requires --image or --dockerfile"); + } + + const mkfsExt4 = await findDarwinMkfsExt4(); + const contentDir = path.join(options.tempDir, "rootfs-content"); + await mkdir(contentDir, { recursive: true }); + + options.signal?.throwIfAborted(); + let imageConfig: ImageConfig; + if (options.dockerfile) { + await rejectDarwinDockerfileRunInstruction(); + imageConfig = deps.emptyImageConfig; + } else { + const pulled = await pullOciImage({ + image: options.image || "", + platformOS: "linux", + platformArch: options.platformArch || "arm64", + cacheDir: options.cacheDir, + signal: options.signal, + }); + imageConfig = pulled.config || deps.emptyImageConfig; + await extractOciImageToDir(pulled, contentDir); + } + await deps.ensureGuestCaCertificates(contentDir); + + const nodeVmmDir = path.join(contentDir, "node-vmm"); + await mkdir(nodeVmmDir, { recursive: true }); + const envFile = renderEnvFile(deps.mergeEnv(imageConfig, options.env)); + await writeFile(path.join(nodeVmmDir, "env"), envFile, { mode: 0o600 }); + + const initScript = deps.renderInitScript({ + commandLine: commandLineFromImage(imageConfig, { + cmd: options.cmd, + entrypoint: options.entrypoint, + }), + workdir: workdirFromImage(imageConfig, options.workdir), + mode: options.initMode, + }); + const initPath = path.join(contentDir, "init"); + await writeFile(initPath, initScript, { mode: 0o755 }); + await chmod(initPath, 0o755); + + options.signal?.throwIfAborted(); + await runCommand("/usr/bin/truncate", ["-s", `${options.diskMiB}m`, options.output], { signal: options.signal }); + await runCommand(mkfsExt4, ["-F", "-L", "rootfs", "-d", contentDir, options.output], { signal: options.signal }); +} diff --git a/src/rootfs-linux.ts b/src/rootfs-linux.ts new file mode 100644 index 0000000..bf82e84 --- /dev/null +++ b/src/rootfs-linux.ts @@ -0,0 +1,190 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { extractOciImageToDir, hostArchToOci, pullOciImage } from "./oci.js"; +import { requireCommands, runCommand } from "./process.js"; +import type { ImageConfig, RootfsBuildOptions, StringMap } from "./types.js"; +import { + NodeVmmError, + commandLineFromImage, + quoteArgv, + renderEnvFile, + requireRoot, + shellQuote, + workdirFromImage, +} from "./utils.js"; + +export interface DockerfileRunStage { + rootfs: string; + env: StringMap; + workdir: string; + shell: string[]; +} + +export interface LinuxRootfsDeps { + buildDockerfileRootfs(options: RootfsBuildOptions, targetDir: string): Promise<ImageConfig>; + ensureGuestCaCertificates(rootfs: string): Promise<void>; + mergeEnv(imageConfig: ImageConfig, userEnv: StringMap): StringMap; + renderInitScript(options: { commandLine: string; workdir: string; mode?: "batch" | "interactive" }): string; + emptyImageConfig: ImageConfig; +} + +async function mountRootfs(imagePath: string, mountDir: string, signal?: AbortSignal): Promise<void> { + await mkdir(mountDir, { recursive: true }); + await runCommand("mount", ["-o", "loop", imagePath, mountDir], { signal }); +} + +async function unmountRootfs(mountDir: string): Promise<void> { + await runCommand("sync", [], { allowFailure: true }); + const first = await runCommand("umount", [mountDir], { allowFailure: true, capture: true }); + if (first.code !== 0) { + await runCommand("umount", ["-l", mountDir], { capture: true }); + } +} + +function projectRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +} + +async function installConsoleHelper(mountDir: string, tempDir: string): Promise<void> { + const helperSource = path.join(projectRoot(), "guest", "node-vmm-console.cc"); + const helperBin = path.join(tempDir, "node-vmm-console"); + await runCommand("g++", ["-static", "-Os", "-s", "-o", helperBin, helperSource, "-lutil"]); + const target = path.join(mountDir, "node-vmm", "console"); + await runCommand("install", ["-m", "0755", helperBin, target]); +} + +function isolatedRunScript(rootfs: string, shellCommand: string[]): string { + const root = shellQuote(rootfs); + const chrootCommand = quoteArgv(["chroot", rootfs, ...shellCommand]); + return `set -eu +root=${root} + +cleanup() { + umount -l "$root/dev/pts" 2>/dev/null || true + umount -l "$root/dev/shm" 2>/dev/null || true + umount -l "$root/dev" 2>/dev/null || true + umount -l "$root/sys" 2>/dev/null || true + umount -l "$root/proc" 2>/dev/null || true +} +trap cleanup EXIT INT TERM HUP + +mkdir -p "$root/proc" "$root/sys" "$root/dev" "$root/etc" "$root/run" "$root/tmp" +chmod 1777 "$root/tmp" 2>/dev/null || true +mount -t proc -o nosuid,nodev,noexec proc "$root/proc" +mount -t sysfs -o ro,nosuid,nodev,noexec sysfs "$root/sys" 2>/dev/null || true +mount -t tmpfs -o mode=0755,size=16m,nosuid,noexec tmpfs "$root/dev" +mkdir -p "$root/dev/pts" "$root/dev/shm" +if ! mount -t devpts -o newinstance,gid=5,mode=620,ptmxmode=666 devpts "$root/dev/pts" 2>/dev/null; then + mount -t devpts devpts "$root/dev/pts" +fi +mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs "$root/dev/shm" + +make_dev() { + rm -f "$1" + mknod "$1" c "$2" "$3" + chmod "$4" "$1" +} +make_dev "$root/dev/null" 1 3 666 +make_dev "$root/dev/zero" 1 5 666 +make_dev "$root/dev/full" 1 7 666 +make_dev "$root/dev/random" 1 8 666 +make_dev "$root/dev/urandom" 1 9 666 +make_dev "$root/dev/tty" 5 0 666 +ln -sf /dev/pts/ptmx "$root/dev/ptmx" +ln -sf /proc/self/fd "$root/dev/fd" +ln -sf /proc/self/fd/0 "$root/dev/stdin" +ln -sf /proc/self/fd/1 "$root/dev/stdout" +ln -sf /proc/self/fd/2 "$root/dev/stderr" + +${chrootCommand} +`; +} + +export async function runLinuxDockerfileInstruction( + stage: DockerfileRunStage, + command: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise<void> { + const envPrefix = Object.entries(stage.env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" "); + const script = `cd ${shellQuote(stage.workdir)} && ${command}`; + const shell = stage.shell.length > 0 ? stage.shell : ["/bin/sh", "-c"]; + const shellCommand = shell.length >= 2 && shell[shell.length - 1] === "-c" + ? [...shell.slice(0, -1), "-c", `${envPrefix ? `${envPrefix} ` : ""}${script}`] + : ["/bin/sh", "-lc", `${envPrefix ? `${envPrefix} ` : ""}${script}`]; + await runCommand("unshare", ["--mount", "--propagation", "private", "/bin/sh", "-c", isolatedRunScript(stage.rootfs, shellCommand)], { + timeoutMs, + killTree: true, + signal, + }); +} + +export async function buildRootfsLinux(options: RootfsBuildOptions, deps: LinuxRootfsDeps): Promise<void> { + requireRoot("building a rootfs image"); + const requiredCommands = ["truncate", "mkfs.ext4", "mount", "umount"]; + if (options.dockerfile) { + requiredCommands.push("unshare", "chroot", "cp", "mknod", "chmod", "ln", "rm", "mkdir"); + } + if (options.initMode === "interactive") { + requiredCommands.push("g++", "install"); + } + await requireCommands(requiredCommands); + + if (!options.image && !options.dockerfile) { + throw new NodeVmmError("build requires --image or --dockerfile"); + } + + const mountDir = path.join(options.tempDir, "mnt"); + + options.signal?.throwIfAborted(); + await runCommand("truncate", ["-s", `${options.diskMiB}M`, options.output], { signal: options.signal }); + await runCommand("mkfs.ext4", ["-F", "-L", "rootfs", options.output], { signal: options.signal }); + + let mounted = false; + try { + options.signal?.throwIfAborted(); + await mountRootfs(options.output, mountDir, options.signal); + mounted = true; + let imageConfig: ImageConfig; + if (options.dockerfile) { + imageConfig = await deps.buildDockerfileRootfs(options, mountDir); + } else { + const pulled = await pullOciImage({ + image: options.image || "", + platformOS: "linux", + platformArch: options.platformArch || hostArchToOci(), + cacheDir: options.cacheDir, + signal: options.signal, + }); + imageConfig = pulled.config || deps.emptyImageConfig; + await extractOciImageToDir(pulled, mountDir); + } + await deps.ensureGuestCaCertificates(mountDir); + + const nodeVmmDir = path.join(mountDir, "node-vmm"); + await mkdir(nodeVmmDir, { recursive: true }); + const envFile = renderEnvFile(deps.mergeEnv(imageConfig, options.env)); + await writeFile(path.join(nodeVmmDir, "env"), envFile, { mode: 0o600 }); + if (options.initMode === "interactive") { + await installConsoleHelper(mountDir, options.tempDir); + } + + const initScript = deps.renderInitScript({ + commandLine: commandLineFromImage(imageConfig, { + cmd: options.cmd, + entrypoint: options.entrypoint, + }), + workdir: workdirFromImage(imageConfig, options.workdir), + mode: options.initMode, + }); + const initPath = path.join(mountDir, "init"); + await writeFile(initPath, initScript, { mode: 0o755 }); + await chmod(initPath, 0o755); + } finally { + if (mounted) { + await unmountRootfs(mountDir); + } + } +} diff --git a/src/rootfs-win32.ts b/src/rootfs-win32.ts new file mode 100644 index 0000000..c8a22b3 --- /dev/null +++ b/src/rootfs-win32.ts @@ -0,0 +1,364 @@ +import { existsSync, statSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { hostArchToOci, pullOciImage } from "./oci.js"; +import { runCommand } from "./process.js"; +import type { ImageConfig, RootfsBuildOptions, StringMap } from "./types.js"; +import { + NodeVmmError, + commandLineFromImage, + renderEnvFile, + shellQuote, + workdirFromImage, +} from "./utils.js"; + +const OCI_MAX_EXTRACT_BYTES_ENV = "NODE_VMM_OCI_MAX_EXTRACT_BYTES"; +const DEFAULT_OCI_MAX_EXTRACT_BYTES = 8 * 1024 * 1024 * 1024; + +const WSL_OCI_EXTRACT_SCRIPT = String.raw` +import os +import posixpath +import shutil +import stat +import sys +import tarfile + +layer_path = sys.argv[1] +root = os.path.realpath(sys.argv[2]) +max_bytes = int(sys.argv[3]) +total = 0 + +def clean_entry(name): + cleaned = posixpath.normpath("/" + name.strip()).lstrip("/") + if cleaned in ("", "."): + return None + parts = cleaned.split("/") + if any(part == ".." for part in parts): + raise RuntimeError("layer entry escapes rootfs: " + name) + return cleaned + +def inside_root(path): + real = os.path.realpath(path) + if real != root and not real.startswith(root + os.sep): + raise RuntimeError("layer path resolves outside rootfs: " + path) + return real + +def target_for(rel): + return os.path.join(root, *rel.split("/")) + +def parent_inside(path): + parent = inside_root(os.path.dirname(path)) + os.makedirs(parent, exist_ok=True) + return parent + +def remove_path(path): + if os.path.islink(path) or os.path.isfile(path): + os.unlink(path) + elif os.path.isdir(path): + shutil.rmtree(path) + +def safe_mode(member): + return (member.mode or 0o755) & ~0o6000 + +def repair_link(rel, link): + if not link.startswith("/"): + return link + parent = posixpath.dirname("/" + rel) + repaired = posixpath.relpath(posixpath.normpath(link), parent) + return "." if repaired == "" else repaired + +with tarfile.open(layer_path, "r:*") as archive: + members = archive.getmembers() + + for member in members: + rel = clean_entry(member.name) + if not rel: + continue + base = posixpath.basename(rel) + if not base.startswith(".wh."): + continue + parent_rel = posixpath.dirname(rel) + parent = target_for(parent_rel) if parent_rel and parent_rel != "." else root + if not os.path.isdir(parent): + continue + parent = inside_root(parent) + if base == ".wh..wh..opq": + for child in os.listdir(parent): + remove_path(os.path.join(parent, child)) + else: + victim = inside_root(os.path.join(parent, base[len(".wh."):])) + if os.path.lexists(victim): + remove_path(victim) + + for member in members: + rel = clean_entry(member.name) + if not rel: + continue + if posixpath.basename(rel).startswith(".wh."): + continue + if not (member.isfile() or member.isdir() or member.issym() or member.islnk()): + raise RuntimeError("OCI layer entry type is not allowed: " + member.name) + total += member.size or 0 + if total > max_bytes: + raise RuntimeError("OCI layer extraction is too large: exceeds " + str(max_bytes) + " bytes") + + target = target_for(rel) + parent_inside(target) + if member.isdir(): + if os.path.lexists(target) and not os.path.isdir(target): + remove_path(target) + os.makedirs(target, exist_ok=True) + os.chmod(target, safe_mode(member)) + elif member.issym(): + if os.path.lexists(target): + remove_path(target) + os.symlink(repair_link(rel, member.linkname or ""), target) + elif member.islnk(): + link = member.linkname or "" + if link.startswith("/") or ".." in posixpath.normpath(link).split("/"): + raise RuntimeError("OCI hardlink target is not allowed: " + member.name + " -> " + link) + source = inside_root(target_for(clean_entry(link) or "")) + if os.path.lexists(target): + remove_path(target) + os.link(source, target) + else: + if os.path.lexists(target): + remove_path(target) + with archive.extractfile(member) as source, open(target, "wb") as dest: + if source is None: + raise RuntimeError("could not read layer file: " + member.name) + shutil.copyfileobj(source, dest) + os.chmod(target, safe_mode(member)) +`; + +const WSL_WRITE_TEXT_SCRIPT = String.raw` +import os +import sys +import tempfile + +target = sys.argv[1] +mode = int(sys.argv[2], 8) +parent = os.path.dirname(target) +os.makedirs(parent, exist_ok=True) +fd, tmp = tempfile.mkstemp(prefix=".node-vmm.", dir=parent) +try: + with os.fdopen(fd, "wb") as out: + out.write(sys.stdin.buffer.read()) + os.chmod(tmp, mode) + os.replace(tmp, target) + os.chmod(target, mode) +except Exception: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise +`; + +export interface Win32RootfsDeps { + mergeEnv(imageConfig: ImageConfig, userEnv: StringMap): StringMap; + renderInitScript(options: { commandLine: string; workdir: string; mode?: "batch" | "interactive" }): string; + emptyImageConfig: ImageConfig; +} + +function projectRoot(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +} + +function consoleHelperPrebuiltPath(): string | null { + const candidate = path.join(projectRoot(), "prebuilds", "linux-x64", "node-vmm-console"); + if (existsSync(candidate)) { + const source = path.join(projectRoot(), "guest", "node-vmm-console.cc"); + try { + if (statSync(candidate).mtimeMs >= statSync(source).mtimeMs) { + return candidate; + } + } catch { + return candidate; + } + } + return null; +} + +function ociExtractByteLimit(): number { + const raw = process.env[OCI_MAX_EXTRACT_BYTES_ENV]; + if (!raw) { + return DEFAULT_OCI_MAX_EXTRACT_BYTES; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new NodeVmmError(`${OCI_MAX_EXTRACT_BYTES_ENV} must be a non-negative byte count`); + } + return parsed; +} + +function windowsPathToWslPath(target: string): string { + const resolved = path.resolve(target); + const match = /^([A-Za-z]):[\\/](.*)$/.exec(resolved); + if (!match) { + throw new NodeVmmError(`WSL2 rootfs builder requires a local drive path, got: ${target}`); + } + return `/mnt/${match[1].toLowerCase()}/${match[2].replace(/\\/g, "/")}`; +} + +async function runWslRoot(script: string, options: Parameters<typeof runCommand>[2] = {}) { + return runCommand("wsl.exe", ["-u", "root", "sh", "-lc", script], options); +} + +async function requireWslRootfsBuilder(options: RootfsBuildOptions): Promise<void> { + const required = ["truncate", "mkfs.ext4", "mount", "umount", "python3"]; + if (!consoleHelperPrebuiltPath()) { + required.push("g++", "install"); + } + const checks = required.map((command) => `command -v ${shellQuote(command)} >/dev/null`).join(" && "); + const result = await runWslRoot(checks, { capture: true, allowFailure: true, signal: options.signal }); + if (result.code !== 0) { + const details = (result.stderr || result.stdout).trim(); + throw new NodeVmmError( + `Windows rootfs builds require WSL2 with Linux filesystem tools (${required.join(", ")}).\n` + + (details ? `${details}\n` : "") + + `Install once as root in your WSL distro, e.g. on Debian/Ubuntu:\n` + + ` apt-get update && apt-get install -y ${required.join(" ")}`, + ); + } +} + +function wslBuildDir(): string { + const suffix = `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`; + return `/tmp/node-vmm-build-${suffix}`; +} + +async function mountWslRootfs(options: RootfsBuildOptions, wslBase: string, mountDir: string): Promise<void> { + const output = windowsPathToWslPath(options.output); + await runWslRoot( + [ + "set -eu", + `mkdir -p ${shellQuote(wslBase)} ${shellQuote(mountDir)}`, + `truncate -s ${shellQuote(`${options.diskMiB}M`)} ${shellQuote(output)}`, + `mkfs.ext4 -q -F -L rootfs ${shellQuote(output)}`, + `mount -o loop ${shellQuote(output)} ${shellQuote(mountDir)}`, + ].join("\n"), + { signal: options.signal }, + ); +} + +async function unmountWslRootfs(wslBase: string, mountDir: string): Promise<void> { + await runWslRoot( + [ + "set +e", + "sync", + `umount ${shellQuote(mountDir)} 2>/dev/null || umount -l ${shellQuote(mountDir)} 2>/dev/null || true`, + `rm -rf -- ${shellQuote(wslBase)}`, + ].join("\n"), + { capture: true, allowFailure: true }, + ); +} + +async function extractOciImageToWslMount( + image: Awaited<ReturnType<typeof pullOciImage>>, + mountDir: string, + signal?: AbortSignal, +): Promise<void> { + process.stdout.write(`[oci] extracting ${image.layers.length} layers to ${mountDir} through WSL2\n`); + const maxBytes = ociExtractByteLimit(); + for (let index = 0; index < image.layers.length; index += 1) { + const layer = image.layers[index]; + process.stdout.write(`[oci] extracting ${index + 1}/${image.layers.length}\n`); + await runCommand( + "wsl.exe", + ["-u", "root", "python3", "-c", WSL_OCI_EXTRACT_SCRIPT, windowsPathToWslPath(layer.path), mountDir, String(maxBytes)], + { signal }, + ); + } +} + +async function installWslText(target: string, content: string, mode: "0600" | "0755", signal?: AbortSignal): Promise<void> { + await runCommand("wsl.exe", ["-u", "root", "python3", "-c", WSL_WRITE_TEXT_SCRIPT, target, mode], { + input: content, + signal, + }); +} + +async function installWslConsoleHelper(mountDir: string, signal?: AbortSignal): Promise<void> { + const targetWsl = `${mountDir}/node-vmm/console`; + const prebuiltWindows = consoleHelperPrebuiltPath(); + if (prebuiltWindows) { + const prebuiltWsl = windowsPathToWslPath(prebuiltWindows); + await runWslRoot( + `install -m 0755 ${shellQuote(prebuiltWsl)} ${shellQuote(targetWsl)}`, + { signal }, + ); + return; + } + const prebuildDir = path.join(projectRoot(), "prebuilds", "linux-x64"); + await mkdir(prebuildDir, { recursive: true }); + const cacheWindows = path.join(prebuildDir, "node-vmm-console"); + const cacheWsl = windowsPathToWslPath(cacheWindows); + const helperSource = windowsPathToWslPath(path.join(projectRoot(), "guest", "node-vmm-console.cc")); + await runWslRoot( + [ + "set -eu", + `g++ -static -Os -s -o ${shellQuote(cacheWsl)} ${shellQuote(helperSource)} -lutil`, + `install -m 0755 ${shellQuote(cacheWsl)} ${shellQuote(targetWsl)}`, + ].join("\n"), + { signal }, + ); +} + +async function writeWslRootfsMetadata( + options: RootfsBuildOptions, + deps: Win32RootfsDeps, + imageConfig: ImageConfig, + mountDir: string, +): Promise<void> { + const nodeVmmDir = `${mountDir}/node-vmm`; + await runWslRoot(`mkdir -p ${shellQuote(nodeVmmDir)}`, { signal: options.signal }); + await installWslText(`${nodeVmmDir}/env`, renderEnvFile(deps.mergeEnv(imageConfig, options.env)), "0600", options.signal); + await installWslConsoleHelper(mountDir, options.signal); + const initScript = deps.renderInitScript({ + commandLine: commandLineFromImage(imageConfig, { + cmd: options.cmd, + entrypoint: options.entrypoint, + }), + workdir: workdirFromImage(imageConfig, options.workdir), + mode: options.initMode, + }); + await installWslText(`${mountDir}/init`, initScript, "0755", options.signal); +} + +export async function buildRootfsWin32(options: RootfsBuildOptions, deps: Win32RootfsDeps): Promise<void> { + if (options.dockerfile) { + throw new NodeVmmError("Windows/WHP can build OCI image rootfs images through WSL2, but Dockerfile and repo builds still require Linux for now"); + } + if (!options.image) { + throw new NodeVmmError("build requires --image on Windows/WHP"); + } + await requireWslRootfsBuilder(options); + + const wslBase = wslBuildDir(); + const mountDir = `${wslBase}/mnt`; + let mounted = false; + try { + options.signal?.throwIfAborted(); + await mountWslRootfs(options, wslBase, mountDir); + mounted = true; + const pulled = await pullOciImage({ + image: options.image, + platformOS: "linux", + platformArch: options.platformArch || hostArchToOci(), + cacheDir: options.cacheDir, + signal: options.signal, + }); + const imageConfig = pulled.config || deps.emptyImageConfig; + await extractOciImageToWslMount(pulled, mountDir, options.signal); + await writeWslRootfsMetadata(options, deps, imageConfig, mountDir); + } finally { + if (mounted) { + await unmountWslRootfs(wslBase, mountDir); + } else { + await runWslRoot(`rm -rf -- ${shellQuote(wslBase)}`, { capture: true, allowFailure: true }); + } + } +} diff --git a/src/rootfs.ts b/src/rootfs.ts index c417c1b..9194704 100644 --- a/src/rootfs.ts +++ b/src/rootfs.ts @@ -1,22 +1,20 @@ -import { chmod, cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { DockerfileParser, type Instruction } from "dockerfile-ast"; import { extractOciImageToDir, hostArchToOci, pullOciImage } from "./oci.js"; -import { requireCommands, runCommand } from "./process.js"; +import { runCommand } from "./process.js"; +import { buildRootfsDarwin } from "./rootfs-darwin.js"; +import { buildRootfsLinux } from "./rootfs-linux.js"; +import { buildRootfsWin32 } from "./rootfs-win32.js"; import type { ImageConfig, RootfsBuildOptions, StringMap } from "./types.js"; import { NodeVmmError, - commandLineFromImage, imageEnvToMap, - renderEnvFile, - requireRoot, shellQuote, quoteArgv, - workdirFromImage, } from "./utils.js"; const EMPTY_IMAGE_CONFIG: ImageConfig = { @@ -29,122 +27,220 @@ const EMPTY_IMAGE_CONFIG: ImageConfig = { }; const DEFAULT_DOCKERFILE_RUN_TIMEOUT_MS = 300_000; +function renderInteractiveLoginScript(): string { + return ` node_vmm_login=/tmp/node-vmm-login +cat > "$node_vmm_login" <<'NODE_VMM_LOGIN' +#!/bin/sh +: > /tmp/node-vmm-login-started 2>/dev/null || true +if [ -n "$NODE_VMM_TTY_ROWS" ] && [ -n "$NODE_VMM_TTY_COLS" ]; then + stty rows "$NODE_VMM_TTY_ROWS" cols "$NODE_VMM_TTY_COLS" 2>/dev/null || true + export LINES="$NODE_VMM_TTY_ROWS" + export COLUMNS="$NODE_VMM_TTY_COLS" +fi +case "$NODE_VMM_COMMAND" in + /bin/sh|sh|"") + exec /bin/sh -i + ;; + *) + exec /bin/sh -lc "$NODE_VMM_COMMAND" + ;; +esac +NODE_VMM_LOGIN + chmod +x "$node_vmm_login" 2>/dev/null || true +`; +} + +function renderWindowsConsoleInteractiveBlock(): string { + return ` node_vmm_apply_tty_size() { + case "$NODE_VMM_TTY_ROWS:$NODE_VMM_TTY_COLS" in + *[!0-9:]*|:|*:|:*) return ;; + esac + stty -F "$1" rows "$NODE_VMM_TTY_ROWS" cols "$NODE_VMM_TTY_COLS" 2>/dev/null || true + } + if [ "$NODE_VMM_WINDOWS_CONSOLE" = "1" ] && [ "$NODE_VMM_WHP_CONSOLE_ROUTE" != "pty" ] && command -v getty >/dev/null 2>&1; then + node_vmm_log "[node-vmm] interactive: using getty" + rm -f /tmp/node-vmm-login-started 2>/dev/null || true + # Belt-and-suspenders for the bare-LF rendering bug: 'sane' should + # set ONLCR + OPOST but busybox getty/agetty have been observed to + # drop them before exec-ing the login shell. Force them on so apk + # progress-bar refresh does not leave leading garbage on every line. + stty -F /dev/ttyS0 115200 sane clocal -hupcl onlcr opost 2>/dev/null || true + node_vmm_apply_tty_size /dev/ttyS0 + node_vmm_getty_status=0 + getty -L -n -l "$node_vmm_login" 115200 ttyS0 xterm-256color || node_vmm_getty_status=$? + if [ "$node_vmm_getty_status" -ne 0 ] && [ ! -e /tmp/node-vmm-login-started ] && [ -x /node-vmm/console ]; then + node_vmm_log "[node-vmm] getty failed before login; using pty helper" + /node-vmm/console "$node_vmm_login" 2>/node-vmm/console.err + else + (exit "$node_vmm_getty_status") + fi + elif [ "$NODE_VMM_WINDOWS_CONSOLE" = "1" ] && [ "$NODE_VMM_WHP_CONSOLE_ROUTE" != "pty" ] && command -v agetty >/dev/null 2>&1; then + node_vmm_log "[node-vmm] interactive: using agetty" + rm -f /tmp/node-vmm-login-started 2>/dev/null || true + # Belt-and-suspenders for the bare-LF rendering bug: 'sane' should + # set ONLCR + OPOST but busybox getty/agetty have been observed to + # drop them before exec-ing the login shell. Force them on so apk + # progress-bar refresh does not leave leading garbage on every line. + stty -F /dev/ttyS0 115200 sane clocal -hupcl onlcr opost 2>/dev/null || true + node_vmm_apply_tty_size /dev/ttyS0 + node_vmm_getty_status=0 + agetty -L -n -l "$node_vmm_login" ttyS0 115200 xterm-256color || node_vmm_getty_status=$? + if [ "$node_vmm_getty_status" -ne 0 ] && [ ! -e /tmp/node-vmm-login-started ] && [ -x /node-vmm/console ]; then + node_vmm_log "[node-vmm] agetty failed before login; using pty helper" + /node-vmm/console "$node_vmm_login" 2>/node-vmm/console.err + else + (exit "$node_vmm_getty_status") + fi + elif [ "$NODE_VMM_WINDOWS_CONSOLE" = "1" ] && [ "$NODE_VMM_WHP_CONSOLE_ROUTE" != "pty" ] && [ -x /node-vmm/console ] && [ -c /dev/ttyS0 ]; then + node_vmm_log "[node-vmm] interactive: using ttyS0" + rm -f /tmp/node-vmm-login-started 2>/dev/null || true + # Belt-and-suspenders for the bare-LF rendering bug: 'sane' should + # set ONLCR + OPOST but busybox getty/agetty have been observed to + # drop them before exec-ing the login shell. Force them on so apk + # progress-bar refresh does not leave leading garbage on every line. + stty -F /dev/ttyS0 115200 sane clocal -hupcl onlcr opost 2>/dev/null || true + node_vmm_apply_tty_size /dev/ttyS0 + node_vmm_tty_status=0 + /node-vmm/console --tty /dev/ttyS0 "$node_vmm_login" || node_vmm_tty_status=$? + if [ "$node_vmm_tty_status" -ne 0 ] && [ ! -e /tmp/node-vmm-login-started ] && [ -x /node-vmm/console ]; then + node_vmm_log "[node-vmm] ttyS0 failed before login; using pty helper" + /node-vmm/console "$node_vmm_login" 2>/node-vmm/console.err + else + (exit "$node_vmm_tty_status") + fi + elif [ "$NODE_VMM_WINDOWS_CONSOLE" = "1" ] && [ -x /node-vmm/console ]; then + node_vmm_log "[node-vmm] interactive: using pty helper" + /node-vmm/console "$node_vmm_login" 2>/node-vmm/console.err +`; +} + +function renderLinuxPtyInteractiveBlock(): string { + return ` elif [ -x /node-vmm/console ]; then + case "$NODE_VMM_COMMAND" in + /bin/sh|sh|"") + /node-vmm/console /bin/sh -i 2>/node-vmm/console.err + node_vmm_log "[node-vmm] shim returned rc=$?" + if [ -s /node-vmm/console.err ]; then + node_vmm_log "[node-vmm] shim stderr:" + node_vmm_cat_console /node-vmm/console.err + fi + ;; + *) + /node-vmm/console /bin/sh -lc "$NODE_VMM_COMMAND" 2>/node-vmm/console.err + ;; + esac +`; +} + +function renderSerialGettyInteractiveBlock(): string { + return ` elif [ "$NODE_VMM_WINDOWS_CONSOLE" != "1" ] && [ -n "$NODE_VMM_CONSOLE" ] && [ -c "$NODE_VMM_CONSOLE" ] && command -v getty >/dev/null 2>&1; then + node_vmm_log "[node-vmm] interactive: using serial getty" + rm -f /tmp/node-vmm-login-started 2>/dev/null || true + node_vmm_tty_name="\${NODE_VMM_CONSOLE#/dev/}" + stty -F "$NODE_VMM_CONSOLE" 115200 sane clocal -hupcl onlcr opost 2>/dev/null || true + node_vmm_apply_tty_size "$NODE_VMM_CONSOLE" + getty -L -n -l "$node_vmm_login" 115200 "$node_vmm_tty_name" xterm-256color +`; +} + +function renderFallbackInteractiveBlock(): string { + return ` else + case "$NODE_VMM_COMMAND" in + /bin/sh|sh|"") + /bin/sh -i + ;; + *) + /bin/sh -lc "$NODE_VMM_COMMAND" + ;; + esac + fi +`; +} + export function renderInitScript(options: { commandLine: string; workdir: string; mode?: "batch" | "interactive"; }): string { + // The init script ships both batch and interactive run-blocks; the actual + // mode is picked up from NODE_VMM_INTERACTIVE on the kernel cmdline so the + // same rootfs can be reused across `--interactive` and `--cmd` invocations. const command = shellQuote(options.commandLine); const workdir = shellQuote(options.workdir); - const interactive = options.mode === "interactive"; - const runBlock = interactive - ? `node_vmm_log "[node-vmm] interactive: $NODE_VMM_COMMAND" -if [ -x /node-vmm/console ]; then - /node-vmm/console /bin/sh -lc "$NODE_VMM_COMMAND" 2>/node-vmm/console.err + const runBlock = `if [ "$NODE_VMM_INTERACTIVE" = "1" ]; then + node_vmm_log "[node-vmm] interactive: $NODE_VMM_COMMAND" + export NODE_VMM_COMMAND + # The PTY shim execs argv[1..] verbatim. We MUST exec something that stays + # alive on a fresh tty: 'sh -lc cmd' hands its stdin to the wrapper and the + # inner shell exits with status 0 (no -i, no input pending). When the user + # asked for the default interactive shell, exec '/bin/sh -i' so the shell + # blocks on read. Otherwise honour the explicit command (htop, vim, etc). +${renderInteractiveLoginScript()}${renderWindowsConsoleInteractiveBlock()}${renderLinuxPtyInteractiveBlock()}${renderSerialGettyInteractiveBlock()}${renderFallbackInteractiveBlock()} # End backend-specific interactive console selection. + status=$? + printf '%s\\n' "$status" > /node-vmm/status 2>/dev/null || true + node_vmm_log "[node-vmm] command exited with status $status" else - /bin/sh -lc "$NODE_VMM_COMMAND" -fi -status=$? -printf '%s\\n' "$status" > /node-vmm/status 2>/dev/null || true -node_vmm_log "[node-vmm] command exited with status $status" -` - : `node_vmm_log "[node-vmm] running: $NODE_VMM_COMMAND" -/bin/sh -lc "$NODE_VMM_COMMAND" >/tmp/node-vmm-command.out 2>&1 -status=$? -cp /tmp/node-vmm-command.out /node-vmm/command.out 2>/dev/null || true -printf '%s\\n' "$status" > /node-vmm/status 2>/dev/null || true -if [ -f /tmp/node-vmm-command.out ]; then - node_vmm_cat_console /tmp/node-vmm-command.out - if [ -s /tmp/node-vmm-command.out ]; then - last_byte="$(tail -c 1 /tmp/node-vmm-command.out 2>/dev/null || true)" - if [ -n "$last_byte" ]; then - node_vmm_console "" + node_vmm_log "[node-vmm] running: $NODE_VMM_COMMAND" + /bin/sh -lc "$NODE_VMM_COMMAND" >/tmp/node-vmm-command.out 2>&1 + status=$? + cp /tmp/node-vmm-command.out /node-vmm/command.out 2>/dev/null || true + printf '%s\\n' "$status" > /node-vmm/status 2>/dev/null || true + if [ -f /tmp/node-vmm-command.out ]; then + node_vmm_cat_console /tmp/node-vmm-command.out + if [ -s /tmp/node-vmm-command.out ]; then + last_byte="$(tail -c 1 /tmp/node-vmm-command.out 2>/dev/null || true)" + if [ -n "$last_byte" ]; then + node_vmm_console "" + fi fi fi + node_vmm_log "[node-vmm] command exited with status $status" fi -node_vmm_log "[node-vmm] command exited with status $status" `; + void options.mode; return `#!/bin/sh set +e export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin -NODE_VMM_INTERACTIVE=${interactive ? "1" : "0"} - -mount -t proc proc /proc 2>/dev/null || true -mount -t sysfs sysfs /sys 2>/dev/null || true -mkdir -p /dev -mount -t devtmpfs devtmpfs /dev 2>/dev/null || mount -t tmpfs tmpfs /dev 2>/dev/null || true -mkdir -p /dev/pts /dev/shm /run /tmp -if [ -e /dev/console ] && [ ! -c /dev/console ]; then - rm -f /dev/console 2>/dev/null || true -fi -if [ ! -c /dev/console ]; then - mknod /dev/console c 5 1 2>/dev/null || true -fi -if [ -e /dev/ttyS0 ] && [ ! -c /dev/ttyS0 ]; then - rm -f /dev/ttyS0 2>/dev/null || true -fi -if [ ! -c /dev/ttyS0 ]; then - mknod /dev/ttyS0 c 4 64 2>/dev/null || true -fi -if [ -e /dev/null ] && [ ! -c /dev/null ]; then - rm -f /dev/null 2>/dev/null || true -fi +NODE_VMM_INTERACTIVE=0 + +mount -t proc proc /proc 2>/dev/null +mount -t sysfs sysfs /sys 2>/dev/null +mount -t devtmpfs devtmpfs /dev 2>/dev/null +# devtmpfs (CONFIG_DEVTMPFS in our kernel) auto-creates /dev/{console,ttyS0, +# null,zero,random,urandom,port,...}. Only fall back to manual mknod if +# devtmpfs failed (tmpfs fallback path). if [ ! -c /dev/null ]; then - mknod /dev/null c 1 3 2>/dev/null || true -fi -if [ -e /dev/zero ] && [ ! -c /dev/zero ]; then - rm -f /dev/zero 2>/dev/null || true -fi -if [ ! -c /dev/zero ]; then - mknod /dev/zero c 1 5 2>/dev/null || true -fi -if [ -e /dev/full ] && [ ! -c /dev/full ]; then - rm -f /dev/full 2>/dev/null || true -fi -if [ ! -c /dev/full ]; then - mknod /dev/full c 1 7 2>/dev/null || true -fi -if [ -e /dev/random ] && [ ! -c /dev/random ]; then - rm -f /dev/random 2>/dev/null || true -fi -if [ ! -c /dev/random ]; then - mknod /dev/random c 1 8 2>/dev/null || true -fi -if [ -e /dev/urandom ] && [ ! -c /dev/urandom ]; then - rm -f /dev/urandom 2>/dev/null || true -fi -if [ ! -c /dev/urandom ]; then - mknod /dev/urandom c 1 9 2>/dev/null || true -fi -if [ -e /dev/port ] && [ ! -c /dev/port ]; then - rm -f /dev/port 2>/dev/null || true -fi -if [ ! -c /dev/port ]; then - mknod /dev/port c 1 4 2>/dev/null || true -fi -chmod 600 /dev/console /dev/ttyS0 2>/dev/null || true -chmod 666 /dev/null /dev/zero /dev/full /dev/random /dev/urandom 2>/dev/null || true -mount -t devpts devpts /dev/pts -o ptmxmode=0666,mode=0620 2>/dev/null || mount -t devpts devpts /dev/pts 2>/dev/null || true -if [ ! -e /dev/ptmx ]; then - ln -s pts/ptmx /dev/ptmx 2>/dev/null || mknod /dev/ptmx c 5 2 2>/dev/null || true + mount -t tmpfs tmpfs /dev 2>/dev/null + for entry in console:5:1 ttyS0:4:64 ttyAMA0:204:64 null:1:3 zero:1:5 full:1:7 random:1:8 urandom:1:9 port:1:4; do + name=\${entry%%:*}; rest=\${entry#*:}; major=\${rest%%:*}; minor=\${rest#*:} + mknod "/dev/$name" c "$major" "$minor" 2>/dev/null + done fi -chmod 666 /dev/ptmx 2>/dev/null || true -mount -t tmpfs tmpfs /dev/shm 2>/dev/null || true -chmod 1777 /tmp 2>/dev/null || true +[ -c /dev/ttyS0 ] || mknod /dev/ttyS0 c 4 64 2>/dev/null || true +[ -c /dev/ttyAMA0 ] || mknod /dev/ttyAMA0 c 204 64 2>/dev/null || true +mkdir -p /dev/pts /dev/shm /run /tmp +mount -t devpts devpts /dev/pts -o ptmxmode=0666,mode=0620 2>/dev/null +mount -t tmpfs tmpfs /dev/shm 2>/dev/null +[ -e /dev/ptmx ] || ln -s pts/ptmx /dev/ptmx 2>/dev/null +chmod 1777 /tmp 2>/dev/null NODE_VMM_CONSOLE=/dev/console NODE_VMM_FAST_EXIT=0 +NODE_VMM_RESIZE_ROOTFS=0 +NODE_VMM_WINDOWS_CONSOLE=0 NODE_VMM_IFACE= NODE_VMM_ADDR= NODE_VMM_GW= NODE_VMM_RUNTIME_DNS= +NODE_VMM_EPOCH= +NODE_VMM_UTC= +NODE_VMM_TTY_COLS=80 +NODE_VMM_TTY_ROWS=24 +NODE_VMM_WHP_CONSOLE_ROUTE=getty if [ -c /dev/ttyS0 ]; then NODE_VMM_CONSOLE=/dev/ttyS0 -fi -if [ "$NODE_VMM_INTERACTIVE" != "1" ] && [ -c "$NODE_VMM_CONSOLE" ]; then - exec </dev/null >"$NODE_VMM_CONSOLE" 2>&1 -elif [ "$NODE_VMM_INTERACTIVE" != "1" ]; then - exec </dev/null +elif [ -c /dev/ttyAMA0 ]; then + NODE_VMM_CONSOLE=/dev/ttyAMA0 fi if [ -f /node-vmm/env ]; then @@ -152,6 +248,9 @@ if [ -f /node-vmm/env ]; then fi NODE_VMM_COMMAND=${command} +# Parse the kernel cmdline before any stdin/stdout redirect: the interactive +# branch needs to see node_vmm.interactive=1 to leave fd 0 attached to the +# serial console for the PTY helper. if [ -r /proc/cmdline ]; then for node_vmm_arg in $(cat /proc/cmdline); do case "$node_vmm_arg" in @@ -165,6 +264,43 @@ if [ -r /proc/cmdline ]; then node_vmm.fast_exit=1) NODE_VMM_FAST_EXIT=1 ;; + node_vmm.resize_rootfs=1) + NODE_VMM_RESIZE_ROOTFS=1 + ;; + node_vmm.interactive=1) + NODE_VMM_INTERACTIVE=1 + ;; + node_vmm.windows_console=1|node_vmm.getty=1) + NODE_VMM_WINDOWS_CONSOLE=1 + ;; + node_vmm.console_route=pty) + NODE_VMM_WHP_CONSOLE_ROUTE=pty + ;; + node_vmm.console_route=getty) + NODE_VMM_WHP_CONSOLE_ROUTE=getty + ;; + node_vmm.tty_cols=*) + NODE_VMM_TTY_COLS=\${node_vmm_arg#node_vmm.tty_cols=} + ;; + node_vmm.tty_rows=*) + NODE_VMM_TTY_ROWS=\${node_vmm_arg#node_vmm.tty_rows=} + ;; + node_vmm.term_cols=*) + NODE_VMM_TTY_COLS=\${node_vmm_arg#node_vmm.term_cols=} + ;; + node_vmm.term_rows=*) + NODE_VMM_TTY_ROWS=\${node_vmm_arg#node_vmm.term_rows=} + ;; + console=ttyAMA0*) + if [ -c /dev/ttyAMA0 ]; then + NODE_VMM_CONSOLE=/dev/ttyAMA0 + fi + ;; + console=ttyS0*) + if [ -c /dev/ttyS0 ]; then + NODE_VMM_CONSOLE=/dev/ttyS0 + fi + ;; node_vmm.iface=*) NODE_VMM_IFACE=\${node_vmm_arg#node_vmm.iface=} ;; @@ -177,12 +313,33 @@ if [ -r /proc/cmdline ]; then node_vmm.dns=*) NODE_VMM_RUNTIME_DNS=\${node_vmm_arg#node_vmm.dns=} ;; + node_vmm.epoch=*) + NODE_VMM_EPOCH=\${node_vmm_arg#node_vmm.epoch=} + ;; + node_vmm.utc=*) + NODE_VMM_UTC=\${node_vmm_arg#node_vmm.utc=} + ;; esac done fi +export NODE_VMM_TTY_COLS NODE_VMM_TTY_ROWS + +if [ -n "$NODE_VMM_UTC" ] && command -v date >/dev/null 2>&1; then + node_vmm_utc_text="$(printf '%s' "$NODE_VMM_UTC" | tr '_' ' ')" + date -u -s "$node_vmm_utc_text" >/dev/null 2>&1 || true +elif [ -n "$NODE_VMM_EPOCH" ] && command -v date >/dev/null 2>&1; then + date -u -s "@$NODE_VMM_EPOCH" >/dev/null 2>&1 || true +fi + +if [ "$NODE_VMM_INTERACTIVE" != "1" ] && [ -c "$NODE_VMM_CONSOLE" ]; then + exec </dev/null >"$NODE_VMM_CONSOLE" 2>&1 +elif [ "$NODE_VMM_INTERACTIVE" != "1" ]; then + exec </dev/null +fi + node_vmm_console() { - if [ -c /dev/port ]; then + if [ "$NODE_VMM_CONSOLE" = "/dev/ttyS0" ] && [ -c /dev/port ]; then node_vmm_port_text "$*" && return fi if [ -n "$NODE_VMM_CONSOLE" ] && [ -c "$NODE_VMM_CONSOLE" ]; then @@ -192,7 +349,7 @@ node_vmm_console() { } node_vmm_cat_console() { - if [ -c /dev/port ]; then + if [ "$NODE_VMM_CONSOLE" = "/dev/ttyS0" ] && [ -c /dev/port ]; then node_vmm_port_cat "$1" && return fi if [ -n "$NODE_VMM_CONSOLE" ] && [ -c "$NODE_VMM_CONSOLE" ]; then @@ -222,8 +379,11 @@ node_vmm_port_cat() { } node_vmm_exit_now() { - if [ -c /dev/port ]; then - printf '\\000' | dd of=/dev/port bs=1 seek=1281 count=1 conv=notrunc 2>/dev/null + exit_status="$\{1:-0\}" + if [ "$NODE_VMM_CONSOLE" = "/dev/ttyS0" ] && [ -c /dev/port ]; then + # IO port 0x501 (1281) is the node-vmm paravirt exit port. The status byte + # the host reads becomes the run result's "exitStatus". + printf "\\\\$(printf '%03o' "$exit_status")" | dd of=/dev/port bs=1 seek=1281 count=1 conv=notrunc 2>/dev/null fi } @@ -235,6 +395,15 @@ if [ -n "$NODE_VMM_RUNTIME_DNS" ]; then mkdir -p /etc printf 'nameserver %s\\n' "$NODE_VMM_RUNTIME_DNS" > /etc/resolv.conf 2>/dev/null || true fi +if [ "$NODE_VMM_RESIZE_ROOTFS" = "1" ] && command -v resize2fs >/dev/null 2>&1; then + resize2fs /dev/vda >/dev/null 2>&1 || true +fi +if command -v ip >/dev/null 2>&1; then + ip link set lo up 2>/dev/null || true + ip addr add 127.0.0.1/8 dev lo 2>/dev/null || true +else + ifconfig lo 127.0.0.1 netmask 255.0.0.0 up 2>/dev/null || true +fi if [ -n "$NODE_VMM_IFACE" ] && [ -n "$NODE_VMM_ADDR" ]; then if command -v ip >/dev/null 2>&1; then ip link set "$NODE_VMM_IFACE" up 2>/dev/null || true @@ -252,10 +421,14 @@ if [ -n "$NODE_VMM_IFACE" ] && [ -n "$NODE_VMM_ADDR" ]; then fi cd ${workdir} 2>/dev/null || cd / -${runBlock}if [ "$NODE_VMM_FAST_EXIT" = "1" ]; then - node_vmm_exit_now +${runBlock}if [ "$NODE_VMM_INTERACTIVE" = "1" ] || [ "$NODE_VMM_FAST_EXIT" = "1" ]; then + node_vmm_exit_now "$status" fi sync +# Always signal a clean shutdown to the host through the paravirt exit port +# before falling back to ACPI/reboot, so the runtime returns immediately +# instead of timing out when the kernel can't actually power down. +node_vmm_exit_now "$status" poweroff -f 2>/dev/null || reboot -f 2>/dev/null || true sync exit "$status" @@ -270,29 +443,9 @@ function mergeEnv(imageConfig: ImageConfig, userEnv: StringMap): StringMap { }; } -async function mountRootfs(imagePath: string, mountDir: string, signal?: AbortSignal): Promise<void> { - await mkdir(mountDir, { recursive: true }); - await runCommand("mount", ["-o", "loop", imagePath, mountDir], { signal }); -} - -async function unmountRootfs(mountDir: string): Promise<void> { - await runCommand("sync", [], { allowFailure: true }); - const first = await runCommand("umount", [mountDir], { allowFailure: true, capture: true }); - if (first.code !== 0) { - await runCommand("umount", ["-l", mountDir], { capture: true }); - } -} - -function projectRoot(): string { - return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); -} - -async function installConsoleHelper(mountDir: string, tempDir: string): Promise<void> { - const helperSource = path.join(projectRoot(), "guest", "node-vmm-console.cc"); - const helperBin = path.join(tempDir, "node-vmm-console"); - await runCommand("g++", ["-static", "-Os", "-s", "-o", helperBin, helperSource, "-lutil"]); - const target = path.join(mountDir, "node-vmm", "console"); - await runCommand("install", ["-m", "0755", helperBin, target]); +async function ensureGuestCaCertificates(_rootfs: string): Promise<void> { + // OCI images carry their own trust store; this hook keeps the split + // platform builders aligned without changing the guest filesystem. } interface DockerfileStage { @@ -842,68 +995,29 @@ async function buildDockerfileRootfs(options: RootfsBuildOptions, mountDir: stri } export async function buildRootfs(options: RootfsBuildOptions): Promise<void> { - requireRoot("building a rootfs image"); - const requiredCommands = ["truncate", "mkfs.ext4", "mount", "umount"]; - if (options.dockerfile) { - requiredCommands.push("unshare", "chroot", "cp", "mknod", "chmod", "ln", "rm", "mkdir"); - } - if (options.initMode === "interactive") { - requiredCommands.push("g++", "install"); - } - await requireCommands(requiredCommands); - - if (!options.image && !options.dockerfile) { - throw new NodeVmmError("build requires --image or --dockerfile"); + if (process.platform === "win32") { + await buildRootfsWin32(options, { + mergeEnv, + renderInitScript, + emptyImageConfig: EMPTY_IMAGE_CONFIG, + }); + return; } - - const mountDir = path.join(options.tempDir, "mnt"); - - options.signal?.throwIfAborted(); - await runCommand("truncate", ["-s", `${options.diskMiB}M`, options.output], { signal: options.signal }); - await runCommand("mkfs.ext4", ["-F", "-L", "rootfs", options.output], { signal: options.signal }); - - let mounted = false; - try { - options.signal?.throwIfAborted(); - await mountRootfs(options.output, mountDir, options.signal); - mounted = true; - let imageConfig: ImageConfig; - if (options.dockerfile) { - imageConfig = await buildDockerfileRootfs(options, mountDir); - } else { - const pulled = await pullOciImage({ - image: options.image || "", - platformOS: "linux", - platformArch: options.platformArch || hostArchToOci(), - cacheDir: options.cacheDir, - signal: options.signal, - }); - imageConfig = pulled.config || EMPTY_IMAGE_CONFIG; - await extractOciImageToDir(pulled, mountDir); - } - - const nodeVmmDir = path.join(mountDir, "node-vmm"); - await mkdir(nodeVmmDir, { recursive: true }); - const envFile = renderEnvFile(mergeEnv(imageConfig, options.env)); - await writeFile(path.join(nodeVmmDir, "env"), envFile, { mode: 0o600 }); - if (options.initMode === "interactive") { - await installConsoleHelper(mountDir, options.tempDir); - } - - const initScript = renderInitScript({ - commandLine: commandLineFromImage(imageConfig, { - cmd: options.cmd, - entrypoint: options.entrypoint, - }), - workdir: workdirFromImage(imageConfig, options.workdir), - mode: options.initMode, + if (process.platform === "darwin") { + await buildRootfsDarwin(options, { + buildDockerfileRootfs, + ensureGuestCaCertificates, + mergeEnv, + renderInitScript, + emptyImageConfig: EMPTY_IMAGE_CONFIG, }); - const initPath = path.join(mountDir, "init"); - await writeFile(initPath, initScript, { mode: 0o755 }); - await chmod(initPath, 0o755); - } finally { - if (mounted) { - await unmountRootfs(mountDir); - } + return; } + await buildRootfsLinux(options, { + buildDockerfileRootfs, + ensureGuestCaCertificates, + mergeEnv, + renderInitScript, + emptyImageConfig: EMPTY_IMAGE_CONFIG, + }); } diff --git a/src/types.ts b/src/types.ts index 0c9f46f..7bff5e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,30 @@ export type StringMap = Record<string, string>; -export type NetworkMode = "auto" | "none" | "tap"; +export type NetworkMode = "auto" | "none" | "tap" | "slirp"; + +export type HostBackend = "kvm" | "whp" | "hvf" | "unsupported"; + +export interface HostPlatformInfo { + platform: string; + arch: string; +} + +export interface HostCapabilities { + backend: HostBackend; + platform: string; + arch: string; + archLine: string; + vmRuntime: boolean; + rootfsBuild: boolean; + prebuiltRootfs: boolean; + defaultNetwork: NetworkMode; + networkModes: NetworkMode[]; + tapNetwork: boolean; + portForwarding: boolean; + minCpus: number; + maxCpus: number; + rootfsMaxCpus: number; +} export interface PortForward { host?: string; @@ -10,6 +34,19 @@ export interface PortForward { export type PortForwardInput = string | number | PortForward; +export type PrebuiltRootfsMode = "auto" | "off" | "require"; + +export interface AttachedDisk { + path: string; + readonly?: boolean; +} + +export interface ResolvedAttachedDisk { + path: string; + readonly: boolean; + device: string; +} + export interface ImageConfig { env: string[]; entrypoint: string[]; @@ -40,26 +77,41 @@ export interface RootfsBuildOptions { } export interface NetworkConfig { - mode: "none" | "tap"; + mode: "none" | "tap" | "slirp"; ifaceId?: string; tapName?: string; guestMac?: string; hostIp?: string; guestIp?: string; netmask?: string; + cidr?: string; cidrPrefix?: number; dns?: string; kernelIpArg?: string; kernelNetArgs?: string; ports?: PortForward[]; + hostFwds?: SlirpHostFwd[]; cleanup?: () => Promise<void>; } +export interface SlirpHostFwd { + udp: boolean; + hostAddr: string; + hostPort: number; + guestPort: number; +} + +export interface NodeVmmProgressEvent { + type: "guest-console-ready"; + id: string; +} + export interface NodeVmmClientOptions { cwd?: string; cacheDir?: string; tempDir?: string; logger?: (message: string) => void; + progress?: (event: NodeVmmProgressEvent) => void; } export interface SdkBuildOptions { @@ -72,6 +124,7 @@ export interface SdkBuildOptions { output: string; disk?: number; diskMiB?: number; + diskSizeMiB?: number; buildArgs?: StringMap; env?: StringMap; cmd?: string; @@ -111,6 +164,7 @@ export interface SdkVmOptions { overlayPath?: string; overlayDir?: string; keepOverlay?: boolean; + attachDisks?: AttachedDisk[]; snapshotOut?: string; signal?: AbortSignal; } @@ -129,8 +183,13 @@ export interface SdkRunOptions extends SdkVmOptions { subdir?: string; contextDir?: string; rootfsPath?: string; + diskPath?: string; disk?: number; diskMiB?: number; + diskSizeMiB?: number; + prebuilt?: PrebuiltRootfsMode; + persist?: string; + reset?: boolean; buildArgs?: StringMap; env?: StringMap; cmd?: string; @@ -161,6 +220,8 @@ export interface SdkPrepareOptions extends SdkVmOptions { rootfsPath?: string; disk?: number; diskMiB?: number; + diskSizeMiB?: number; + prebuilt?: PrebuiltRootfsMode; buildArgs?: StringMap; env?: StringMap; cmd?: string; @@ -190,6 +251,7 @@ export interface SdkRunResult { id: string; rootfsPath: string; overlayPath?: string; + attachedDisks: ResolvedAttachedDisk[]; restored: boolean; builtRootfs: boolean; network: NetworkConfig; @@ -208,6 +270,7 @@ export interface RunningVm { id: string; rootfsPath: string; overlayPath?: string; + attachedDisks: ResolvedAttachedDisk[]; restored: boolean; builtRootfs: boolean; network: NetworkConfig; diff --git a/test/e2e.test.ts b/test/e2e.test.ts index efc764e..1523acf 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import http from "node:http"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -30,6 +32,81 @@ async function removeTempTree(target: string): Promise<void> { } } +async function freeTcpPort(): Promise<number> { + const server = net.createServer(); + await new Promise<void>((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + await new Promise<void>((resolve) => server.close(() => resolve())); + assert(address && typeof address !== "string"); + return address.port; +} + +function requestText(port: number, timeoutMs = 1000): Promise<string> { + return new Promise((resolve, reject) => { + const request = http.request( + { host: "127.0.0.1", port, path: "/", method: "GET", agent: false, timeout: timeoutMs }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => resolve(body)); + }, + ); + request.on("timeout", () => request.destroy(new Error("request timed out"))); + request.on("error", reject); + request.end(); + }); +} + +async function waitHttpText(port: number, marker: string, timeoutMs = 120_000): Promise<string> { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const body = await requestText(port); + if (body.includes(marker)) { + return body; + } + lastError = new Error(`HTTP body did not include ${marker}: ${body.slice(0, 200)}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw lastError instanceof Error ? lastError : new Error(`HTTP did not become ready on ${port}`); +} + +function waitForChild(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<{ code: number | null; output: string }> { + return new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`child did not exit within ${timeoutMs}ms\n${output}`)); + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + output += chunk; + }); + child.stderr?.on("data", (chunk) => { + output += chunk; + }); + child.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ code, output }); + }); + }); +} + async function buildLocalBusyboxRootfs(tempDir: string, mode: "batch" | "interactive" = "interactive"): Promise<string> { assert.equal(existsSync(BUSYBOX), true, `busybox not found: ${BUSYBOX}`); const rootfs = path.join(tempDir, "e2e.ext4"); @@ -99,7 +176,7 @@ import sys import time cmd = sys.argv[1:] -send_input = os.environ.get("NODE_VMM_E2E_PTY_INPUT", "echo e2e-console-ok\nexit\n").encode() +send_input = os.environ.get("NODE_VMM_E2E_PTY_INPUT", "echo e2e-console-ok\r\nexit\r\n").encode() expect = os.environ.get("NODE_VMM_E2E_PTY_EXPECT", "e2e-console-ok").encode() master, slave = pty.openpty() proc = subprocess.Popen(cmd, stdin=slave, stdout=slave, stderr=slave, close_fds=True) @@ -200,6 +277,7 @@ test("e2e interactive shell accepts input and exits", { skip: !E2E_ENABLED }, as kernel, "--cmd", "/bin/sh", + "--interactive", "--mem", "256", "--net", @@ -317,3 +395,60 @@ test("e2e exposes requested vCPUs to Linux guests", { skip: !E2E_ENABLED }, asyn await removeTempTree(tempDir); } }); + +test("e2e KVM slirp publishes a guest HTTP port", { skip: !E2E_ENABLED }, async () => { + const kernel = await requireKernelPath(); + assert.equal(existsSync(kernel), true, `kernel not found: ${kernel}`); + const tempDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-e2e-slirp-")); + const hostPort = await freeTcpPort(); + const marker = "linux-slirp-port-ok"; + const command = + "node -e \"const http=require('node:http'); " + + "const server=http.createServer((_req,res)=>res.end('linux-slirp-port-ok\\n',()=>server.close(()=>process.exit(0)))); " + + "server.listen(3000,'0.0.0.0')\""; + let child: ReturnType<typeof spawn> | undefined; + try { + child = spawn( + "sudo", + [ + "-n", + "env", + `NODE_VMM_KERNEL=${kernel}`, + `PATH=${process.env.PATH || ""}`, + "node", + "dist/src/main.js", + "run", + "--image", + "node:22-alpine", + "--disk", + "768", + "--cache-dir", + path.join(tempDir, "oci-cache"), + "--kernel", + kernel, + "--cmd", + command, + "--mem", + "512", + "--net", + "slirp", + "--publish", + `${hostPort}:3000`, + "--timeout-ms", + "120000", + ], + { cwd: process.cwd(), stdio: ["ignore", "pipe", "pipe"] }, + ); + const childDone = waitForChild(child, 180_000); + const body = await waitHttpText(hostPort, marker); + assert.match(body, new RegExp(marker)); + const result = await childDone; + assert.equal(result.code, 0, result.output); + assert.match(result.output, /stopped:/); + } finally { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill("SIGTERM"); + } + await removeTempTree(tempDir); + } +}); diff --git a/test/ext4.test.ts b/test/ext4.test.ts new file mode 100644 index 0000000..eadc088 --- /dev/null +++ b/test/ext4.test.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + Ext4ImageWriter, + NotImplementedError, + Superblock, + Inode, + InodeTable, + DirectoryWriter, + Bitmap, + GroupDescriptorTable, + planBlockGroups, + FileType, + type EntryAttrs, + type Ext4WriterOptions, +} from "../src/ext4/index.js"; + +const ATTRS: EntryAttrs = { uid: 0, gid: 0, mode: 0o644 }; +const DIR_ATTRS: EntryAttrs = { uid: 0, gid: 0, mode: 0o755 }; +const OPTS: Ext4WriterOptions = { sizeBytes: 64 * 1024 * 1024 }; + +function expectNotImplemented(fn: () => unknown): void { + assert.throws(fn, (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /not implemented/i); + return true; + }); +} + +async function expectAsyncNotImplemented(promise: Promise<unknown>): Promise<void> { + await assert.rejects(promise, (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /not implemented/i); + return true; + }); +} + +test("Ext4ImageWriter constructor accepts an output path and options", () => { + const w = new Ext4ImageWriter("/tmp/scaffold.ext4", OPTS); + assert.equal(w.outputPath, "/tmp/scaffold.ext4"); + assert.equal(w.options.blockSize, 4096); + assert.equal(w.options.dirIndex, true); + assert.equal(w.pendingCount(), 0); +}); + +test("Ext4ImageWriter rejects empty output path", () => { + assert.throws(() => new Ext4ImageWriter("", OPTS), /outputPath is required/); +}); + +test("Ext4ImageWriter rejects non-4096 block sizes during scaffold phase", () => { + assert.throws(() => new Ext4ImageWriter("/tmp/x.ext4", { ...OPTS, blockSize: 1024 }), /blockSize/); +}); + +test("Ext4ImageWriter.addFile is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addFile("/etc/hostname", Buffer.from("vm\n"), ATTRS)); +}); + +test("Ext4ImageWriter.addDir is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addDir("/etc", DIR_ATTRS)); +}); + +test("Ext4ImageWriter.addSymlink is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addSymlink("/lib", { target: "usr/lib", attrs: ATTRS })); +}); + +test("Ext4ImageWriter.addHardlink is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addHardlink("/bin/sh", { existingPath: "/bin/busybox" })); +}); + +test("Ext4ImageWriter.addCharDevice is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addCharDevice("/dev/null", { major: 1, minor: 3, attrs: ATTRS })); +}); + +test("Ext4ImageWriter.addBlockDevice is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addBlockDevice("/dev/loop0", { major: 7, minor: 0, attrs: ATTRS })); +}); + +test("Ext4ImageWriter.addWhiteout is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.addWhiteout("/etc/.wh.deleted")); +}); + +test("Ext4ImageWriter.finalize is not yet implemented", async () => { + const w = new Ext4ImageWriter("/tmp/a.ext4", OPTS); + await expectAsyncNotImplemented(w.finalize()); +}); + +test("Ext4ImageWriter smoke: queue several adds, expect finalize() to throw not-implemented", async () => { + const w = new Ext4ImageWriter("/tmp/smoke.ext4", OPTS); + // Each add throws individually today; the contract test is that finalize() + // also reports not-implemented even on a freshly-constructed writer. + await expectAsyncNotImplemented(w.addDir("/", DIR_ATTRS)); + await expectAsyncNotImplemented(w.addFile("/hello", Buffer.from("hi"), ATTRS)); + await expectAsyncNotImplemented(w.addSymlink("/lnk", { target: "hello", attrs: ATTRS })); + await expectAsyncNotImplemented(w.finalize()); +}); + +test("Superblock stub methods throw NotImplementedError", () => { + const sb = new Superblock({ + sizeBytes: OPTS.sizeBytes, + blockSize: 4096, + inodesPerGroup: 8192, + label: "", + uuid: "", + dirIndex: true, + extents: true, + }); + expectNotImplemented(() => sb.plan()); + expectNotImplemented(() => sb.serialize()); + expectNotImplemented(() => Superblock.parse(Buffer.alloc(1024))); +}); + +test("Inode + InodeTable stubs throw NotImplementedError", () => { + const inode = new Inode(FileType.RegularFile, ATTRS); + expectNotImplemented(() => inode.composeMode()); + expectNotImplemented(() => inode.serialize(Buffer.alloc(256), 0)); + const table = new InodeTable(8192); + expectNotImplemented(() => table.allocate(inode)); + expectNotImplemented(() => table.get(11)); +}); + +test("DirectoryWriter stubs throw NotImplementedError", () => { + const dw = new DirectoryWriter(2, 2, 4096, true); + expectNotImplemented(() => dw.addEntry({ inode: 11, name: "etc", fileType: FileType.Directory })); + expectNotImplemented(() => dw.serialize()); + expectNotImplemented(() => DirectoryWriter.hashName("hello")); + // recordLength is implemented (pure math) — verify it as a sanity check. + assert.equal(DirectoryWriter.recordLength(3), 12); + assert.equal(DirectoryWriter.recordLength(8), 16); +}); + +test("Bitmap stubs throw NotImplementedError", () => { + const bm = new Bitmap(64); + assert.equal(bm.buffer.length, 8); + expectNotImplemented(() => bm.set(0)); + expectNotImplemented(() => bm.clear(0)); + expectNotImplemented(() => bm.test(0)); + expectNotImplemented(() => bm.findFirstClear()); + expectNotImplemented(() => bm.allocateRun(2)); + expectNotImplemented(() => bm.popcount()); +}); + +test("GroupDescriptorTable + planBlockGroups stubs throw NotImplementedError", () => { + expectNotImplemented(() => planBlockGroups(16384, 32768, 8192)); + const gdt = new GroupDescriptorTable([]); + assert.equal(gdt.byteSize(), 0); + expectNotImplemented(() => gdt.serialize()); + expectNotImplemented(() => gdt.backupGroupIndices()); +}); + +test("NotImplementedError carries an identifying name", () => { + const err = new NotImplementedError("scope.method"); + assert.equal(err.name, "NotImplementedError"); + assert.match(err.message, /scope\.method/); +}); diff --git a/test/native.test.ts b/test/native.test.ts index b55740f..67c70c8 100644 --- a/test/native.test.ts +++ b/test/native.test.ts @@ -1,19 +1,35 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { dirtyRamSnapshotSmoke, guestExitSmoke, + hvfDeviceSmoke, + hvfFdtSmoke, + hvfPl011Smoke, probeKvm, probeWhp, ramSnapshotSmoke, + runKvmVm, + runKvmVmControlled, smokeHlt, uartSmoke, whpSmokeHlt, } from "../src/kvm.js"; +import { runImage, startVm } from "../src/index.js"; +import { native, type WhpProbeResult } from "../src/native.js"; + +const WHP_BOOT_KERNEL_ENV = "NODE_VMM_WHP_E2E_KERNEL"; +const WHP_BOOT_ROOTFS_ENV = "NODE_VMM_WHP_E2E_ROOTFS"; +const WHP_FULL_E2E_ENV = "NODE_VMM_WHP_FULL_E2E"; +const WHP_ATTACH_DISKS_E2E_ENV = "NODE_VMM_WHP_ATTACH_DISKS_E2E"; +const MINIMAL_ELF_ENTRY = 0x100000; +const MINIMAL_ELF_CODE_OFFSET = 0x1000; +const LIFECYCLE_TIMEOUT_MS = 5000; function hasKvm(): boolean { try { @@ -24,26 +40,1190 @@ function hasKvm(): boolean { } } -test("probeWhp is surfaced for Windows and unavailable on non-Windows builds", (t) => { - if (process.platform === "win32") { - const probe = probeWhp(); - assert.equal(probe.backend, "whp"); - assert.equal(probe.arch, "x86_64"); - assert.equal(typeof probe.available, "boolean"); - if (!probe.available) { - t.skip(probe.reason || "WHP is not available on this runner"); +function assertWhpProbeShape(probe: WhpProbeResult): void { + assert.equal(probe.backend, "whp"); + assert.equal(probe.arch, "x86_64"); + assert.equal(typeof probe.available, "boolean"); + assert.equal(typeof probe.hypervisorPresent, "boolean"); + assert.equal(typeof probe.dirtyPageTracking, "boolean"); + assert.equal(typeof probe.queryDirtyBitmapExport, "boolean"); + assert.equal(typeof probe.partitionCreate, "boolean"); + assert.equal(typeof probe.partitionSetup, "boolean"); +} + +function whpUnavailableReason(probe: WhpProbeResult): string { + if (probe.reason) { + return probe.reason; + } + if (!probe.hypervisorPresent) { + return "WHP hypervisor is not present on this runner"; + } + if (!probe.partitionCreate) { + return "WHP partition creation is not available on this runner"; + } + if (!probe.partitionSetup) { + return "WHP partition setup is not available on this runner"; + } + return "WHP is not available on this runner"; +} + +function requireWhpAvailable(t: TestContext): WhpProbeResult | undefined { + if (process.platform !== "win32") { + t.skip("WHP tests only run on Windows"); + return undefined; + } + const probe = probeWhp(); + assertWhpProbeShape(probe); + if (!probe.available) { + t.skip(whpUnavailableReason(probe)); + return undefined; + } + return probe; +} + +type LifecycleState = "starting" | "running" | "paused" | "stopping" | "exited"; + +interface LifecycleHandle<Result> { + state(): LifecycleState; + pause(): Promise<void>; + resume(): Promise<void>; + stop(): Promise<Result>; + wait(): Promise<Result>; +} + +interface GeneratedWhpGuest { + kernelPath: string; + rootfsPath: string; +} + +function delay(ms: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<never>((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${LIFECYCLE_TIMEOUT_MS}ms`)), LIFECYCLE_TIMEOUT_MS); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +} + +async function waitForLifecycleState(handle: LifecycleHandle<unknown>, wanted: LifecycleState): Promise<void> { + const deadline = Date.now() + LIFECYCLE_TIMEOUT_MS; + let state = handle.state(); + while (Date.now() < deadline) { + state = handle.state(); + if (state === wanted) { return; } - const smoke = whpSmokeHlt(); - assert.equal(smoke.backend, "whp"); - assert.equal(smoke.exitReason, "hlt"); - assert.ok(smoke.dirtyPages >= 1); + if (state === "exited") { + const result = await handle.wait(); + assert.fail(`VM exited before ${wanted}: ${JSON.stringify(result)}`); + } + await delay(5); + } + assert.fail(`timed out waiting for ${wanted}; last state was ${state}`); +} + +function requireWhpFullE2e(t: TestContext): boolean { + const probe = requireWhpAvailable(t); + if (!probe) { + return false; + } + if (process.env[WHP_FULL_E2E_ENV] !== "1") { + t.skip(`set ${WHP_FULL_E2E_ENV}=1 to run Alpine WHP integration tests`); + return false; + } + return true; +} + +function requireWhpAttachDisksE2e(t: TestContext): boolean { + const probe = requireWhpAvailable(t); + if (!probe) { + return false; + } + if (process.env[WHP_ATTACH_DISKS_E2E_ENV] !== "1") { + t.skip(`set ${WHP_ATTACH_DISKS_E2E_ENV}=1 to run WHP attached disk integration tests`); + return false; + } + const missing = [WHP_BOOT_KERNEL_ENV, WHP_BOOT_ROOTFS_ENV].filter((name) => !process.env[name]); + if (missing.length > 0) { + t.skip(`WHP attached disk e2e skipped; missing ${missing.join(", ")} fixtures`); + return false; + } + return true; +} + +async function withGeneratedWhpGuest<T>( + prefix: string, + elf: Buffer, + run: (guest: GeneratedWhpGuest) => Promise<T> | T, +): Promise<T> { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); + try { + const kernelPath = path.join(dir, "guest.elf"); + const rootfsPath = path.join(dir, "rootfs.ext4"); + await writeFile(kernelPath, elf); + await writeFile(rootfsPath, Buffer.alloc(0)); + return await run({ kernelPath, rootfsPath }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function minimalWhpElf(code: Buffer): Buffer { + const elf = Buffer.alloc(MINIMAL_ELF_CODE_OFFSET + code.length); + elf.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01], 0); + elf.writeUInt16LE(2, 16); + elf.writeUInt16LE(0x3e, 18); + elf.writeUInt32LE(1, 20); + elf.writeBigUInt64LE(BigInt(MINIMAL_ELF_ENTRY), 24); + elf.writeBigUInt64LE(64n, 32); + elf.writeUInt16LE(64, 52); + elf.writeUInt16LE(56, 54); + elf.writeUInt16LE(1, 56); + + const ph = 64; + elf.writeUInt32LE(1, ph); + elf.writeUInt32LE(5, ph + 4); + elf.writeBigUInt64LE(BigInt(MINIMAL_ELF_CODE_OFFSET), ph + 8); + elf.writeBigUInt64LE(BigInt(MINIMAL_ELF_ENTRY), ph + 16); + elf.writeBigUInt64LE(BigInt(MINIMAL_ELF_ENTRY), ph + 24); + elf.writeBigUInt64LE(BigInt(code.length), ph + 32); + elf.writeBigUInt64LE(BigInt(code.length), ph + 40); + elf.writeBigUInt64LE(BigInt(MINIMAL_ELF_CODE_OFFSET), ph + 48); + code.copy(elf, MINIMAL_ELF_CODE_OFFSET); + return elf; +} + +function minimalWhpGuestExitElf(): Buffer { + return minimalWhpElf( + Buffer.from([ + 0x66, 0xba, 0x00, 0x06, 0xb0, 0x4f, 0xee, // out 0x600, 'O' + 0xb0, 0x4b, 0xee, // out 0x600, 'K' + 0x66, 0xba, 0x01, 0x05, 0xb0, 0x07, 0xee, // out 0x501, 7 + 0xf4, + ]), + ); +} + +function minimalWhpPauseLoopElf(): Buffer { + return minimalWhpElf(Buffer.from([0xf3, 0x90, 0xeb, 0xfc])); // pause; jmp -4 +} + +// Microguest that writes "a\nb\n" verbatim through the paravirt console +// port (0x600). The host UART writes those bytes through the host-stdout +// path: with the CRLF normalizer in Uart::write_stdout, bare LFs become +// CRLF on the host terminal. Without the normalizer, busybox getty paths +// drop ONLCR and the user sees "lines start at column N instead of 0" +// (the apk progress-bar redraw bug). The result.console field captures +// the RAW pre-normalization bytes so we can also verify the underlying +// stream still uses bare LF -- normalization happens at the host edge. +function minimalWhpBareLfElf(): Buffer { + return minimalWhpElf( + Buffer.from([ + 0x66, 0xba, 0x00, 0x06, // mov dx, 0x600 + 0xb0, 0x61, // mov al, 'a' + 0xee, // out dx, al + 0xb0, 0x0a, // mov al, 0x0a (\n) + 0xee, // out dx, al + 0xb0, 0x62, // mov al, 'b' + 0xee, // out dx, al + 0xb0, 0x0a, // mov al, 0x0a (\n) + 0xee, // out dx, al + 0x66, 0xba, 0x01, 0x05, // mov dx, 0x501 + 0xb0, 0x07, // mov al, 7 (exit code) + 0xee, // out dx, al + 0xf4, // hlt + ]), + ); +} + +function minimalWhpSmpHltElf(): Buffer { + return minimalWhpElf( + Buffer.from([ + 0x66, 0xba, 0x00, 0x06, // mov dx, 0x600 + 0x48, 0x89, 0xf8, // mov rax, rdi + 0x04, 0x30, // add al, '0' + 0xee, // out dx, al + 0xf4, // hlt + ]), + ); +} + +async function assertWhpLifecycleStop<Result extends { exitReason: string; runs: number }>( + handle: LifecycleHandle<Result>, +): Promise<Result> { + let stopped = false; + try { + await waitForLifecycleState(handle, "running"); + assert.equal(handle.state(), "running"); + + await withTimeout(handle.pause(), "pause"); + assert.equal(handle.state(), "paused"); + + await withTimeout(handle.resume(), "resume"); + assert.equal(handle.state(), "running"); + + const result = await withTimeout(handle.stop(), "stop"); + stopped = true; + assert.equal(result.exitReason, "host-stop"); + assert.ok(result.runs >= 1); + assert.equal(handle.state(), "exited"); + return result; + } finally { + if (!stopped && handle.state() !== "exited") { + await handle.stop().catch(() => undefined); + } + } +} + +test("probeWhp reports the current Windows WHP capability surface", (t) => { + if (process.platform !== "win32") { + assert.throws(() => probeWhp(), /probeWhp|native backend/); + return; + } + const probe = probeWhp(); + assertWhpProbeShape(probe); + assert.equal(probe.available, probe.hypervisorPresent && probe.partitionCreate && probe.partitionSetup); + if (probe.available) { + assert.equal(probe.hypervisorPresent, true); + assert.equal(probe.partitionCreate, true); + assert.equal(probe.partitionSetup, true); + return; + } + t.skip(whpUnavailableReason(probe)); +}); + +test("whpSmokeHlt exits through WHP halt and reports dirty pages", (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + if (!probe.queryDirtyBitmapExport || !probe.dirtyPageTracking) { + t.skip("WHP dirty-page tracking is required for the HLT smoke"); + return; + } + const smoke = whpSmokeHlt(); + assert.equal(smoke.backend, "whp"); + assert.equal(smoke.exitReason, "hlt"); + assert.equal(smoke.runs, 1); + assert.equal(smoke.dirtyTracking, true); + assert.ok(smoke.dirtyPages >= 1); + assert.ok(smoke.totalMs >= 0); +}); + +test("whpElfLoaderSmoke parses a minimal ELF64 vmlinux and reports entry+kernelEnd", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const elf = minimalWhpGuestExitElf(); + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-elf-ok-")); + const elfPath = path.join(dir, "kernel.elf"); + try { + await writeFile(elfPath, elf); + // The minimal ELF helpers in this file produce a single PT_LOAD + // segment placed at MINIMAL_ELF_ENTRY; entry point matches. + const result = native.whpElfLoaderSmoke?.({ path: elfPath }); + assert.ok(result, "whpElfLoaderSmoke returned undefined"); + assert.equal(result.entry, BigInt(MINIMAL_ELF_ENTRY)); + assert.ok(result.kernelEnd > result.entry, "kernelEnd should be > entry"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("whpElfLoaderSmoke rejects truncated ELF / bad magic / wrong class / wrong machine", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-elf-bad-")); + try { + // 1) Empty file -> "kernel is too small" + const empty = path.join(dir, "empty.elf"); + await writeFile(empty, Buffer.alloc(0)); + assert.throws(() => native.whpElfLoaderSmoke?.({ path: empty }), /too small/); + + // 2) File large enough but wrong magic -> "kernel must be an ELF vmlinux" + const badMagic = path.join(dir, "bad-magic.elf"); + await writeFile(badMagic, Buffer.alloc(64)); + assert.throws(() => native.whpElfLoaderSmoke?.({ path: badMagic }), /ELF vmlinux/); + + // 3) ELF magic but ELF32 class -> "kernel must be ELF64" + const elf32 = Buffer.alloc(64); + elf32.set([0x7f, 0x45, 0x4c, 0x46, 0x01 /* ELF32 */, 0x01, 0x01], 0); + elf32.writeUInt16LE(2, 16); + elf32.writeUInt16LE(0x3e, 18); + const elf32Path = path.join(dir, "elf32.elf"); + await writeFile(elf32Path, elf32); + assert.throws(() => native.whpElfLoaderSmoke?.({ path: elf32Path }), /ELF64/); + + // 4) ELF64 little-endian but ARM64 machine -> "kernel must be x86_64 ELF" + const arm = Buffer.alloc(64); + arm.set([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01], 0); + arm.writeUInt16LE(2, 16); + arm.writeUInt16LE(0xb7 /* EM_AARCH64 */, 18); + const armPath = path.join(dir, "arm.elf"); + await writeFile(armPath, arm); + assert.throws(() => native.whpElfLoaderSmoke?.({ path: armPath }), /x86_64/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("whpBootParamsSmoke writes correct boot_params + e820 + MP table", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpBootParamsSmoke?.({ cmdline: "console=ttyS0 root=/dev/vda", cpus: 2 }); + assert.ok(result, "whpBootParamsSmoke returned undefined"); + // Linux x86 boot protocol fixed offsets inside boot_params (zero page). + assert.equal(result.e820_entries, 4, "expected 4 e820 entries"); + assert.equal(result.vid_mode, 0xffff, "vid_mode should be 'ask BIOS' (0xFFFF)"); + assert.equal(result.boot_sig, 0xaa55, "boot signature 0xAA55 missing at +0x1FE"); + assert.equal(result.kernel_sig, 0x53726448, "kernel signature 'HdrS' missing at +0x202"); + assert.equal(result.type_of_loader, 0xff); + assert.equal(result.loadflags & 0x01, 0x01, "LOADED_HIGH (bit 0) should be set in loadflags"); + assert.equal(result.cmd_line_ptr, 0x20000, "cmd_line_ptr should point at kCmdlineAddr"); + assert.equal(result.cmd_line_size, "console=ttyS0 root=/dev/vda".length); + // First e820 entry: low RAM 0..0x9FC00, type 1 (RAM). + assert.equal(result.e820_0_addr_lo, 0x00000000); + assert.equal(result.e820_0_size_lo, 0x0009fc00); + assert.equal(result.e820_0_type, 1); + // MP table floating pointer should be present in low RAM under 0xA0000. + assert.equal(result.mp_signature_found, true, "MP '_MP_' floating pointer not found"); + assert.ok( + result.mp_signature_offset >= 0x9f800 && result.mp_signature_offset < 0xa0000, + `MP signature at unexpected offset 0x${result.mp_signature_offset.toString(16)}`, + ); +}); + +test("whpPageTablesSmoke builds a 4 GiB identity-map with 2 MiB huge pages", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpPageTablesSmoke?.(); + assert.ok(result, "whpPageTablesSmoke returned undefined"); + // Layout: kBase = 0x9000, PDPT at +0x1000, PD0..3 at +0x2000..0x5000. + assert.equal(result.base, 0x9000n); + // PML4[0] = 0x1000 page above + present|rw|user (0x07) = 0x9000+0x1000=0xa000 | 0x07 + assert.equal(result.pml4_0, 0xa000n | 0x07n); + // PDPT entries point to consecutive PD pages (0xb000, 0xc000, 0xd000, 0xe000) | 0x07. + assert.equal(result.pdpt_0, 0xb000n | 0x07n); + assert.equal(result.pdpt_1, 0xc000n | 0x07n); + assert.equal(result.pdpt_2, 0xd000n | 0x07n); + assert.equal(result.pdpt_3, 0xe000n | 0x07n); + // PD0[0] is identity at phys=0 with huge-page flags (P|RW|PS = 0x83). + assert.equal(result.pd0_0, 0n | 0x83n); + // PD0[1] maps phys=0x200000 (2 MiB page boundary). + assert.equal(result.pd0_1, 0x200000n | 0x83n); + // PD3[511] is the last entry: 4 GiB - 2 MiB = 0xFFE00000. + assert.equal(result.pd3_511, 0xffe00000n | 0x83n); +}); + +// PR-3 IRQ delivery state machine — exercises every branch of the +// can_inject decision used by TryDeliverPendingExtInt + the +// UpdateVcpuFromExit reflection of RFLAGS.IF / InterruptShadow / +// InterruptionPending. Pure state machine, no WHP partition required. + +test("whpIrqStateSmoke: no pending ExtInt → decision=noPending", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x202, + extIntPending: false, + readyForPic: true, + }); + assert.ok(result); + assert.equal(result.decision, "noPending"); + assert.equal(result.extIntPending, false); +}); + +test("whpIrqStateSmoke: ext_int_pending && all gates open → decision=inject", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x202, // IF=1 + interruptShadow: false, + interruptionPending: false, + readyForPic: true, + extIntPending: true, + extIntVector: 0x20, + }); + assert.ok(result); + assert.equal(result.interruptable, true); + assert.equal(result.interruptFlag, true); + assert.equal(result.interruptionPending, false); + assert.equal(result.readyForPic, true); + assert.equal(result.decision, "inject"); +}); + +test("whpIrqStateSmoke: IF=0 gates injection → decision=armWindow", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x002, // IF=0 (bit 9 cleared) + readyForPic: true, + extIntPending: true, + }); + assert.ok(result); + assert.equal(result.interruptFlag, false); + assert.equal(result.decision, "armWindow"); +}); + +test("whpIrqStateSmoke: STI/MOV-SS shadow gates injection → decision=armWindow", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x202, + interruptShadow: true, + readyForPic: true, + extIntPending: true, + }); + assert.ok(result); + assert.equal(result.interruptable, false); + assert.equal(result.decision, "armWindow"); +}); + +test("whpIrqStateSmoke: interruption-pending gates injection → decision=armWindow", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x202, + interruptionPending: true, + readyForPic: true, + extIntPending: true, + }); + assert.ok(result); + assert.equal(result.interruptionPending, true); + assert.equal(result.decision, "armWindow"); +}); + +test("whpIrqStateSmoke: ready_for_pic_interrupt=false (no InterruptWindow exit yet) → armWindow", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpIrqStateSmoke?.({ + rflags: 0x202, + readyForPic: false, // not yet signaled by an InterruptWindow exit + extIntPending: true, + }); + assert.ok(result); + assert.equal(result.readyForPic, false); + assert.equal(result.decision, "armWindow"); +}); + +test("whpIrqStateSmoke: UpdateVcpuFromExit reflects RFLAGS.IF / shadow / interruption pending", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + // Walk every interesting RFLAGS shape. + const ifSet = native.whpIrqStateSmoke?.({ rflags: 0x202 }); + assert.equal(ifSet?.interruptFlag, true); + const ifClear = native.whpIrqStateSmoke?.({ rflags: 0x000 }); + assert.equal(ifClear?.interruptFlag, false); + // Stray non-IF bits don't affect interrupt_flag. 0xFFFFFDFF has every + // RFLAGS bit set EXCEPT bit 9 (IF), so interruptFlag must be false. + const otherBits = native.whpIrqStateSmoke?.({ rflags: 0xFFFFFDFF }); + assert.equal(otherBits?.interruptFlag, false); +}); + +// PR-4 PIC unit test — exercises the ICW1-4 reprogramming sequence Linux +// runs in init_8259A. is_initialized() is the predicate the dispatcher +// uses to gate ExtInt delivery, so getting this right gates all timer IRQ +// delivery during boot. + +test("whpPicSmoke: ICW1-4 init sequence remaps master vector base and flips is_initialized", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpPicSmoke?.({ vectorBase: 0x30, maskAfterInit: 0xFB }); + assert.ok(result); + // Before ICW1: vector base is 0x20 (BIOS reset value), is_initialized() must + // be false because the dispatcher should NOT deliver ExtInts before the + // kernel reprograms the PIC. + assert.equal(result.initializedBeforeIcw, false); + assert.equal(result.vectorForIrq0Before, 0x20); + // After ICW1-4 with vectorBase=0x30: vector base updated, is_initialized() flips true. + assert.equal(result.initializedAfterIcw, true); + assert.equal(result.vectorForIrq0After, 0x30); + assert.equal(result.vectorForIrq3After, 0x33); + // OCW1 mask 0xFB unmasked IRQ2 only (slave cascade); IRQ0 stays masked, + // IRQ2 must now be unmasked. + assert.equal(result.maskRead, 0xFB); + assert.equal(result.irq0Unmasked, false); + assert.equal(result.irq2Unmasked, true); +}); + +test("whpPicSmoke: rejects vectorBase=0x20 (cannot stay at the BIOS reset sentinel)", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + // Programming the master with the same 0x20 it boots at means + // is_initialized() stays false because we use master_.vector != 0x20 as + // the sentinel. Documents the intentional gap (fine because Linux always + // remaps). + const result = native.whpPicSmoke?.({ vectorBase: 0x20, maskAfterInit: 0xFF }); + assert.ok(result); + assert.equal(result.initializedAfterIcw, false); + assert.equal(result.vectorForIrq0After, 0x20); +}); + +// PR-4 PIT unit test — exercises channel 2 OUT pin which the kernel reads +// through port 0x61 bit 5 to calibrate the TSC against the PIT +// (pit_hpet_ptimer_calibrate_cpu in arch/x86/kernel/tsc.c). If this is +// wrong, TSC calibration loops never converge and boot stalls. + +test("whpPitSmoke: channel 2 OUT pin is gated AND ticks-driven", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpPitSmoke?.({ reload: 10, waitMs: 5 }); + assert.ok(result); + // Before gating: OUT must be low and the gate must be off. + assert.equal(result.channel2OutBeforeGate, false); + assert.equal(result.channel2GatedBeforeGate, false); + // After gating: gate flag flips on. + assert.equal(result.channel2GatedAfterGate, true); + // After 5 ms with divisor=10: ticks ≈ 5957 ≫ divisor → OUT high. + assert.equal(result.channel2OutAfterWait, true); + // After ungating: OUT drops back low. + assert.equal(result.channel2OutAfterUngate, false); +}); + +test("whpPitSmoke: OUT stays low when ticks < divisor (large reload, no wait)", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + // Reload 0xFFFF (max divisor 65535) + zero wait: ticks ≈ 0 < 65535 → OUT low. + const result = native.whpPitSmoke?.({ reload: 0xFFFF, waitMs: 0 }); + assert.ok(result); + assert.equal(result.channel2GatedAfterGate, true); + assert.equal(result.channel2OutAfterWait, false); +}); + +// PR-6 module-level test for the apk-progress-bar redraw fix. Replaces +// the indirect microguest test with a direct exercise of the normalizer +// via Uart::NormalizeCrlf. If this regresses, lines on Windows console +// will start at the wrong column during apk progress bar redraws. + +test("whpUartCrlfSmoke: bare LF gets a synthesized CR prefix", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpUartCrlfSmoke?.({ input: "a\nb\n" }); + assert.ok(result); + assert.equal(result.normalized, "a\r\nb\r\n"); + assert.equal(result.lastByte, 0x0a); // last byte appended is \n +}); + +test("whpUartCrlfSmoke: preserves guest CRLF without adding double CR", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + // Bare LF still gets CR-normalized, but an existing guest CRLF pair is + // preserved so ConPTY/VS Code does not see doubled carriage returns. + const result = native.whpUartCrlfSmoke?.({ input: "c\r\nd\n" }); + assert.ok(result); + assert.equal(result.normalized, "c\r\nd\r\n"); +}); + +test("whpUartCrlfSmoke: chained calls keep no-double-CR state", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + // First chunk ends with \r (no following \n yet). + const first = native.whpUartCrlfSmoke?.({ input: "x\r" }); + assert.ok(first); + assert.equal(first.normalized, "x\r"); + assert.equal(first.lastByte, 0x0d); // \r + // The leading LF is not prefixed because the previous chunk ended with CR. + const second = native.whpUartCrlfSmoke?.({ input: "\ny", lastByte: first.lastByte }); + assert.ok(second); + assert.equal(second.normalized, "\ny"); +}); + +test("whpConsoleWriterSmoke: apk DEC redraw frames become ConPTY-safe line redraws", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpConsoleWriterSmoke?.({ + input: "\x1b7 33% #### \x1b8\x1b[0K\r", + }); + assert.ok(result); + assert.equal(result.containsDecSaveRestore, false); + assert.equal(result.hidesCursor, true); + assert.equal(result.showsCursor, true); + assert.equal(result.normalized, "\x1b[?25l\r\x1b[2K 33% ####\r\x1b[?25h"); +}); + +test("whpConsoleWriterSmoke: split apk redraw frames are rewritten before trailing clear arrives", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpConsoleWriterSmoke?.({ + input: "\x1b7 33% #### \x1b8", + }); + assert.ok(result); + assert.equal(result.containsDecSaveRestore, false); + assert.equal(result.normalized, "\x1b[?25l\r\x1b[2K 33% ####\r\x1b[?25h"); +}); + +test("whpUartRegisterSmoke: FIFO/IIR/LSR behavior follows 16550 priority", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpUartRegisterSmoke?.(); + assert.ok(result); + assert.equal(result.initialLsr & 0x60, 0x60); + assert.equal(result.acceptedOne, 1); + assert.equal(result.iirAfterOne & 0x0f, 0x0c); // CTI for below-trigger FIFO data. + assert.equal(result.acceptedFill, 13); + assert.equal(result.iirAtTrigger & 0x0f, 0x04); // RDI at trigger level. + assert.equal(result.firstRx, "a".charCodeAt(0)); + assert.equal(result.acceptedOverflow, 4096); + assert.equal(result.overrunLsr & 0x02, 0x02); + assert.equal(result.overrunLsrAfterClear & 0x02, 0); + assert.equal(result.txIir & 0x0f, 0x02); + assert.equal(result.txLsr & 0x60, 0x60); + assert.ok(result.irqCount > 0); +}); + +// PR-5b VirtioBlk MMIO identification registers (must match spec). +test("whpVirtioBlkSmoke: MagicValue / Version / DeviceId / VendorId match the virtio-mmio spec", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const result = native.whpVirtioBlkSmoke?.(); + assert.ok(result); + assert.equal(result.magicValue, 0x74726976); // "virt" + assert.equal(result.version, 2); + assert.equal(result.deviceId, 2); // virtio-blk + assert.equal(result.vendorId, 0x554d4551); // "QEMU" + assert.equal(result.queueNumMax, 256); + assert.equal(result.irqRaisedBeforeWrite, false); +}); + +test("UART captures bare LF in console buffer (regression: progress-bar redraw bug)", async (t) => { + // The bug: in `--tty` and `getty -L` interactive paths, busybox getty has + // been observed to drop ONLCR from the slave termios, so apk's progress + // bar updates emit bare \n which Windows console renders as "down one + // row, keep column" -- next line starts at the column where the previous + // line ended. Symptom: "ng c-ares (1.33.1-r0)" instead of "(7/11) + // Installing c-ares (1.33.1-r0)". + // + // Fix: Uart::write_stdout (native/whp/backend.cc) synthesizes a CR before + // any bare LF, gated on last_stdout_byte_ so guests that already emit + // CRLF (kernel ONLCR + PTY shim) don't get doubled CRs. + // + // This test exercises the underlying byte path: a microguest writes + // "a\nb\n" through the paravirt console port (0x600), which goes to + // emit_tx_locked -> console_ buffer (raw, unmodified). result.console + // therefore contains "a\nb\n" verbatim -- which proves the guest CAN + // emit bare LFs and the UART captures them. The CRLF synthesis happens + // at write_stdout (host edge) and is not reflected in result.console; + // a separate module-level test in PR-6 will exercise the normalizer + // directly. + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + await withGeneratedWhpGuest("node-vmm-whp-bare-lf-", minimalWhpBareLfElf(), ({ kernelPath, rootfsPath }) => { + const result = runKvmVm({ + kernelPath, + rootfsPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 1, + timeoutMs: 5000, + consoleLimit: 1024, + }); + assert.equal(result.exitReason, "guest-exit"); + assert.equal(result.exitReasonCode, 2); + assert.equal(result.console, "a\nb\n"); + }); +}); + +test("runKvmVm uses WHP runVm to boot a generated ELF and capture guest exit", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + await withGeneratedWhpGuest("node-vmm-whp-runvm-", minimalWhpGuestExitElf(), ({ kernelPath, rootfsPath }) => { + const result = runKvmVm({ + kernelPath, + rootfsPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 1, + timeoutMs: 5000, + consoleLimit: 1024, + }); + assert.deepEqual(result, { exitReason: "guest-exit", exitReasonCode: 2, runs: 3, console: "OK" }); + }); +}); + +test("runKvmVm runs a generated ELF on multiple WHP vCPUs", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + await withGeneratedWhpGuest("node-vmm-whp-smp-", minimalWhpSmpHltElf(), ({ kernelPath, rootfsPath }) => { + const result = runKvmVm({ + kernelPath, + rootfsPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 2, + timeoutMs: 5000, + consoleLimit: 1024, + }); + assert.equal(result.exitReason, "hlt"); + assert.ok(result.runs >= 2); + assert.equal([...result.console].sort().join(""), "01"); + }); +}); + +test("runKvmVm accepts cpus>1 with a non-empty rootfs on WHP", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-smp-rootfs-")); + try { + const kernelPath = path.join(dir, "guest.elf"); + const rootfsPath = path.join(dir, "rootfs.ext4"); + await writeFile(kernelPath, minimalWhpPauseLoopElf()); + await writeFile(rootfsPath, Buffer.alloc(512)); + // The native runtime no longer pre-rejects multi-vCPU rootfs-backed runs. + // A generated pause-loop ELF is allowed to either time out or complete + // depending on host scheduling; the important assertion is that the call + // enters the runner instead of throwing the legacy guard up front. + try { + runKvmVm({ + kernelPath, + rootfsPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 2, + timeoutMs: 1000, + consoleLimit: 1024, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + assert.doesNotMatch(message, /SMP requires AP startup/); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("runKvmVmControlled exposes WHP pause, resume, and stop over a generated guest", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + await withGeneratedWhpGuest("node-vmm-whp-controlled-", minimalWhpPauseLoopElf(), async ({ kernelPath, rootfsPath }) => { + const handle = runKvmVmControlled({ + kernelPath, + rootfsPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 2, + timeoutMs: 10000, + consoleLimit: 1024, + }); + + const result = await assertWhpLifecycleStop(handle); + assert.equal(result.console, ""); + }); +}); + +test("startVm exposes WHP pause, resume, and stop over a generated guest", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + await withGeneratedWhpGuest("node-vmm-whp-startvm-", minimalWhpPauseLoopElf(), async ({ kernelPath, rootfsPath }) => { + // Avoid probing the intentionally empty rootfs for batch output after host-stop. + const overlayPath = path.join(path.dirname(rootfsPath), "rootfs.overlay"); + const vm = await startVm({ + id: "whp-lifecycle", + kernelPath, + rootfsPath, + overlayPath, + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 2, + timeoutMs: 10000, + consoleLimit: 1024, + network: "none", + }); + + const result = await assertWhpLifecycleStop(vm); + assert.equal(result.rootfsPath, rootfsPath); + assert.equal(result.overlayPath, overlayPath); + assert.equal(result.restored, true); + assert.equal(result.builtRootfs, false); + assert.equal(result.exitReason, "host-stop"); + assert.equal(result.console, ""); + }); +}); + +test("whpSmokeHlt is unavailable from non-Windows native builds", () => { + if (process.platform === "win32") { return; } - assert.throws(() => probeWhp(), /probeWhp|native backend/); assert.throws(() => whpSmokeHlt(), /whpSmokeHlt|native backend/); }); +test("HVF FDT smoke reports QEMU virt-board probe nodes", () => { + if (process.platform !== "darwin" || process.arch !== "arm64") { + assert.throws(() => hvfFdtSmoke(), /hvfFdtSmoke|native backend/); + return; + } + + const fdt = hvfFdtSmoke(); + assert.equal(fdt.backend, "hvf"); + assert.ok(fdt.dtbBytes > 0); + assert.equal(fdt.rootInterruptParent, 1); + assert.equal(fdt.rootDmaCoherent, true); + assert.equal(fdt.gicPhandle, 1); + assert.equal(fdt.cpuCount, 2); + assert.equal(fdt.cpuEnableMethod, "psci"); + assert.equal(fdt.serial0Alias, "/pl011@9000000"); + assert.equal(fdt.stdoutPath, "/pl011@9000000"); + assert.equal(fdt.uartBase, 0x09000000); + assert.equal(fdt.virtioBase, 0x0a000000); + assert.equal(fdt.virtioStride, 0x200); + assert.equal(fdt.virtioCount, 32); + assert.equal(fdt.virtioBlkBase, 0x0a000000); + assert.equal(fdt.virtioBlkIntid, 48); + assert.equal(fdt.virtioNetBase, 0x0a000200); + assert.equal(fdt.virtioNetIntid, 49); + assert.equal((fdt.virtioBlkBase - fdt.virtioBase) / fdt.virtioStride, 0); + assert.equal((fdt.virtioNetBase - fdt.virtioBase) / fdt.virtioStride, 1); + assert.ok(fdt.pcieMmioSize > 0); + assert.equal(fdt.emptyTransportMagic, 0x74726976); + assert.equal(fdt.emptyTransportDeviceId, 0); +}); + +test("HVF PL011 smoke covers Linux-used registers and cursor fallback", () => { + if (process.platform !== "darwin" || process.arch !== "arm64") { + assert.throws(() => hvfPl011Smoke(), /hvfPl011Smoke|native backend/); + return; + } + + const uart = hvfPl011Smoke(); + assert.equal(uart.backend, "hvf"); + assert.equal(uart.console, "A"); + assert.equal(uart.cursorResponse, "\x1b[1;1R"); + assert.equal(uart.rxByte, "z".charCodeAt(0)); + assert.equal((uart.frEmpty & 0x10) !== 0, true); + assert.equal((uart.frWithRx & 0x10) === 0, true); + assert.equal(uart.risAfterClear & 0x50, 0); + assert.equal(uart.peripheralId0, 0x11); + assert.equal(uart.primeCellId0, 0x0d); +}); + +test("HVF probe devices answer RTC fw_cfg and empty virtio-mmio reads", () => { + if (process.platform !== "darwin" || process.arch !== "arm64") { + assert.throws(() => hvfDeviceSmoke(), /hvfDeviceSmoke|native backend/); + return; + } + + const devices = hvfDeviceSmoke(); + assert.equal(devices.backend, "hvf"); + assert.ok(devices.rtcNow > 0); + assert.ok(devices.rtcLoaded >= 12345); + assert.equal(devices.fwCfgSignature, "QEMU"); + assert.equal(devices.fwCfgId & 1, 1); + assert.equal(devices.emptyVirtioMagic, 0x74726976); + assert.equal(devices.emptyVirtioDeviceId, 0); + assert.equal(devices.emptyVirtioVendor, 0x554d4551); + assert.equal(devices.pcieEmptyVendor, 0xffffffff); +}); + +test("WHP runs a prebuilt rootfs fixture with node-vmm init", async (t) => { + const probe = requireWhpAvailable(t); + if (!probe) { + return; + } + const missing = [WHP_BOOT_KERNEL_ENV, WHP_BOOT_ROOTFS_ENV].filter((name) => !process.env[name]); + if (missing.length > 0) { + t.skip(`WHP boot e2e skipped; missing ${missing.join(", ")} fixtures`); + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-boot-")); + try { + const overlayPath = path.join(dir, "rootfs.overlay"); + const result = await runImage({ + id: "whp-rootfs-fixture", + kernelPath: process.env[WHP_BOOT_KERNEL_ENV], + rootfsPath: process.env[WHP_BOOT_ROOTFS_ENV], + overlayPath, + cmd: "echo whp-e2e-ok", + network: "none", + memMiB: 256, + cpus: 1, + timeoutMs: 60000, + consoleLimit: 1024 * 1024, + }); + assert.equal(result.builtRootfs, false); + assert.equal(result.restored, true); + assert.equal(result.overlayPath, overlayPath); + assert.equal(result.network.mode, "none"); + assert.equal(result.guestStatus, 0); + assert.equal(result.guestOutput, "whp-e2e-ok\n"); + assert.match(result.exitReason, /^(guest-exit|halted-console|hlt)$/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("WHP attachDisks maps a data disk after the root disk and persists raw writes", async (t) => { + if (!requireWhpAttachDisksE2e(t)) { + return; + } + const kernelPath = process.env[WHP_BOOT_KERNEL_ENV]; + const rootfsPath = process.env[WHP_BOOT_ROOTFS_ENV]; + assert.ok(kernelPath); + assert.ok(rootfsPath); + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-attach-disks-")); + try { + const dataDisk = path.join(dir, "data.raw"); + const marker = "NODE_VMM_ATTACH_DISK_OK"; + await writeFile(dataDisk, Buffer.alloc(1024 * 1024)); + const result = await runImage({ + id: "whp-attach-disk", + kernelPath, + rootfsPath, + network: "none", + memMiB: 256, + cpus: 1, + timeoutMs: 60000, + consoleLimit: 1024 * 1024, + attachDisks: [{ path: dataDisk }], + cmd: [ + "set -e", + "test -b /dev/vda", + "test -b /dev/vdb", + `printf '${marker}' | dd of=/dev/vdb bs=1 seek=4096 conv=notrunc >/dev/null 2>&1`, + "sync", + "echo ATTACH_DISK_OK", + ].join("; "), + } as Parameters<typeof runImage>[0] & { attachDisks: Array<{ path: string; readonly?: boolean }> }); + + assert.equal(result.guestStatus, 0, result.console); + assert.match(result.guestOutput, /ATTACH_DISK_OK/); + const disk = await readFile(dataDisk); + assert.equal(disk.subarray(4096, 4096 + marker.length).toString("utf8"), marker); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("WHP full Alpine run covers clock, RNG, slirp, and apk", async (t) => { + if (!requireWhpFullE2e(t)) { + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-full-")); + try { + const result = await runImage({ + image: "alpine:3.20", + cacheDir: path.join(dir, "oci-cache"), + sandbox: true, + network: "auto", + memMiB: 256, + cpus: 1, + timeoutMs: 120000, + consoleLimit: 1024 * 1024, + cmd: [ + "set -e", + "a=$(date +%s)", + "sleep 1", + "b=$(date +%s)", + "test $((b-a)) -ge 1", + "grep -q '^refined-jiffies$' /sys/devices/system/clocksource/clocksource0/current_clocksource", + "ip addr show lo | grep -q '127.0.0.1'", + "test -c /dev/hwrng", + "cat /sys/devices/virtual/misc/hw_random/rng_available | grep -q virtio_rng", + "timeout 3 dd if=/dev/hwrng bs=16 count=1 2>/tmp/rng.err | wc -c | grep -q '^16$'", + "ping -c 1 -W 3 google.com >/dev/null", + "apk add --no-cache htop >/tmp/apk.out 2>&1", + "htop --version | head -1", + "dmesg | grep -q 'No irq handler' && exit 66 || true", + "echo WHP_FULL_OK", + ].join("; "), + }); + assert.equal(result.guestStatus, 0, result.guestOutput); + assert.match(result.guestOutput, /htop /); + assert.match(result.guestOutput, /WHP_FULL_OK/); + assert.doesNotMatch(result.console, /__common_interrupt:.*No irq handler/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("WHP full Alpine startVm supports pause, resume, and stop", async (t) => { + if (!requireWhpFullE2e(t)) { + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-start-full-")); + try { + const vm = await startVm({ + image: "alpine:3.20", + cacheDir: path.join(dir, "oci-cache"), + sandbox: true, + network: "none", + memMiB: 256, + cpus: 1, + timeoutMs: 120000, + consoleLimit: 1024 * 1024, + cmd: "sleep 30", + }); + const result = await assertWhpLifecycleStop(vm); + assert.equal(result.exitReason, "host-stop"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("WHP full Alpine interactive console idles and keeps Ctrl-C inside the guest", async (t) => { + if (!requireWhpFullE2e(t)) { + return; + } + const dir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-whp-interactive-full-")); + try { + const cacheDir = path.join(dir, "oci-cache"); + const child = spawn( + process.execPath, + [ + "dist/src/main.js", + "run", + "--image", + "alpine:3.20", + "--cache-dir", + cacheDir, + "--sandbox", + "--interactive", + "--net", + "auto", + "--cpus", + "1", + "--mem", + "256", + ], + { + cwd: process.cwd(), + env: { ...process.env, NODE_VMM_ALLOW_NONTTY_INTERACTIVE: "1" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + let output = ""; + let state: "boot" | "measure" | "ping" | "ctrl" | "echo" | "exit" = "boot"; + let ctrlAt = -1; + let exitAt = -1; + const deadline = setTimeout(() => child.kill("SIGKILL"), 120000); + const append = (chunk: Buffer | string): void => { + output += chunk.toString(); + if (state === "boot" && /~ #/.test(output)) { + state = "measure"; + child.stdin.write( + "cat /sys/devices/system/clocksource/clocksource0/current_clocksource; pid=$(pidof console || true); echo PID:$pid; if [ -n \"$pid\" ]; then awk '{print \"STAT1:\" $14 \" \" $15}' /proc/$pid/stat; sleep 2; awk '{print \"STAT2:\" $14 \" \" $15}' /proc/$pid/stat; else echo 'STAT1:0 0'; sleep 2; echo 'STAT2:0 0'; fi; ping google.com\n", + ); + return; + } + if (state === "measure" && /64 bytes from/.test(output)) { + state = "ctrl"; + setTimeout(() => { + ctrlAt = output.length; + child.stdin.write("\x03"); + }, 200); + return; + } + if (state === "ctrl" && ctrlAt >= 0) { + const afterCtrl = output.slice(ctrlAt); + if (/--- google\.com ping statistics ---/.test(afterCtrl) || /\r?\n~ #/.test(afterCtrl)) { + state = "echo"; + setTimeout(() => child.stdin.write("echo AFTER_CTRL_C\n"), 200); + return; + } + } + if (state === "echo" && /AFTER_CTRL_C[\s\S]*~ #/.test(output)) { + state = "exit"; + setTimeout(() => { + exitAt = Date.now(); + child.stdin.end("exit\n"); + }, 200); + } + }; + child.stdout.on("data", append); + child.stderr.on("data", append); + + const { code, signal } = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("exit", (exitCode, exitSignal) => resolve({ code: exitCode, signal: exitSignal })); + }); + clearTimeout(deadline); + + const stat1 = output.match(/STAT1:(\d+) (\d+)/); + const stat2 = output.match(/STAT2:(\d+) (\d+)/); + assert.ok(stat1 && stat2, output); + const ticks = Number(stat2[1]) + Number(stat2[2]) - Number(stat1[1]) - Number(stat1[2]); + assert.equal(code, 0, output); + assert.equal(signal, null, output); + assert.ok(ticks < 20, `console helper used ${ticks} CPU ticks while idle\n${output}`); + assert.ok(exitAt > 0, output); + assert.ok(Date.now() - exitAt < 1500, `interactive exit took ${Date.now() - exitAt}ms\n${output}`); + assert.match(output, /interactive: using getty/); + assert.doesNotMatch(output, /using pty helper/); + assert.match(output, /refined-jiffies/); + assert.match(output.slice(ctrlAt), /--- google\.com ping statistics ---/); + assert.match(output, /AFTER_CTRL_C/); + assert.match(output, /stopped: guest-exit/); + assert.doesNotMatch(output, /__common_interrupt:.*No irq handler/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("probeKvm reports KVM API version 12", (t) => { if (!hasKvm()) { t.skip("/dev/kvm is not available to this user"); diff --git a/test/unit.test.ts b/test/unit.test.ts index d33bd9e..9d91c45 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -11,20 +11,29 @@ import nodeVmmDefault, { boot, bootRootfs, build, + buildOrReuseRootfs, buildRootfsImage, + capabilitiesForHost, createSandbox, createNodeVmmClient, createSnapshot, + DEFAULT_GOCRACKER_ARM64_KERNEL, DEFAULT_GOCRACKER_KERNEL, + defaultNetworkForCapabilities, dirtyRamSnapshotSmoke, doctor, envKernelPath, defaultKernelCacheDir, defaultKernelCandidates, + defaultArm64KernelName, + defaultKernelNameForPlatform, + defaultKernelNamesForPlatform, fetchGocrackerKernel, findDefaultKernel, features, gocrackerKernelUrl, + hostBackendForHost, + materializePersistentDisk, nodeVmm, parseOptions, prepare, @@ -33,15 +42,33 @@ import nodeVmmDefault, { requireKernelPath, restore, restoreSnapshot, + rootfsCacheKey, run, runCode, runImage, runKvmVmControlled, snapshot, + assertRootfsBuildSupportedForCapabilities, + validateRootfsOptionsForCapabilities, + validateRootfsRuntimeForCapabilities, + validateVmOptionsForCapabilities, type NodeVmmClient, } from "../src/index.js"; import { boolOption, intOption, keyValueOption, stringListOption, stringOption } from "../src/args.js"; -import { defaultKernelCmdline, runKvmVm, runKvmVmAsync } from "../src/kvm.js"; +import { + defaultKernelCmdline, + hvfDefaultKernelCmdline, + probeHvf, + runHvfVm, + runHvfVmAsync, + runHvfVmControlled, + runKvmVm, + runKvmVmAsync, + virtioExtraBlkKernelArgs, + virtioNetKernelArg, +} from "../src/kvm.js"; +import { main, networkOption } from "../src/cli.js"; +import type { NativeWhpBackend, WhpRunConfig, WhpRunResult } from "../src/native.js"; import { parsePortForward, setupNetwork } from "../src/net.js"; import { hostArchToOci, parseImageReference } from "../src/oci.js"; import { commandExists, requireCommands, runCommand } from "../src/process.js"; @@ -106,6 +133,18 @@ test("option helpers parse integers and key-value lists", () => { assert.throws(() => intOption(parseOptions(["--num", "-1"], new Set()), "num", 0), /non-negative integer/); }); +test("CLI network option preserves host backend defaults when omitted", () => { + const flags = new Set<string>(["net"]); + assert.equal(networkOption(parseOptions([], new Set<string>(), flags)), undefined); + assert.equal(networkOption(parseOptions(["--net", "none"], new Set<string>(), flags)), "none"); + assert.equal(networkOption(parseOptions(["--net=auto"], new Set<string>(), flags)), "auto"); + assert.equal(networkOption(parseOptions(["--net=slirp"], new Set<string>(), flags)), "slirp"); + assert.throws( + () => networkOption(parseOptions(["--net", "bad"], new Set<string>(), flags)), + /--net must be auto, none, tap, or slirp/, + ); +}); + test("parseKeyValueList supports comma-separated values", () => { assert.deepEqual(parseKeyValueList(["A=1,B=2", "C=three"]), { A: "1", @@ -152,7 +191,25 @@ test("defaultKernelCmdline includes the v1 root disk and virtio-mmio block devic assert.match(args, /init=\/init/); assert.match(args, /console=ttyS0/); assert.match(args, /rootwait/); - assert.match(args, /virtio_mmio\.device=0x1000@0xd0000000:5/); + assert.match(args, /virtio_mmio\.device=512@0xd0000000:5/); +}); + +test("virtio-mmio kernel args place attached disks and net devices after the root disk", () => { + assert.equal(virtioExtraBlkKernelArgs(0), ""); + assert.equal( + virtioExtraBlkKernelArgs(2), + "virtio_mmio.device=512@0xd0001000:6 virtio_mmio.device=512@0xd0002000:7", + ); + assert.equal( + virtioExtraBlkKernelArgs(2, "whp"), + "virtio_mmio.device=512@0xd0000200:6 virtio_mmio.device=512@0xd0000400:7", + ); + assert.equal(virtioNetKernelArg(2), "virtio_mmio.device=512@0xd0003000:8"); + assert.equal(virtioNetKernelArg(2, "whp"), "virtio_mmio.device=512@0xd0000600:8"); + assert.throws(() => virtioExtraBlkKernelArgs(-1), /attached disk count/); + assert.throws(() => virtioExtraBlkKernelArgs(1.5), /attached disk count/); + assert.throws(() => virtioNetKernelArg(-1), /attached disk count/); + assert.throws(() => virtioNetKernelArg(1.5), /attached disk count/); }); test("renderInitScript supports batch and interactive modes", () => { @@ -165,22 +222,43 @@ test("renderInitScript supports batch and interactive modes", () => { assert.match(batch, /node_vmm\.fast_exit=1/); assert.match(batch, /dd of=\/dev\/port bs=1 seek=1281/); assert.match(batch, /node_vmm_cat_console \/tmp\/node-vmm-command\.out/); - assert.match(batch, /\[ -e \/dev\/console \] && \[ ! -c \/dev\/console \]/); - assert.match(batch, /mkdir -p \/dev/); - assert.match(batch, /mknod \/dev\/ttyS0 c 4 64/); - assert.match(batch, /mknod \/dev\/random c 1 8/); - assert.match(batch, /mknod \/dev\/urandom c 1 9/); + // /dev population now prefers devtmpfs (auto-creates console/ttyS0/null/...) + // and only falls back to a manual mknod loop if devtmpfs failed. Tests pin + // both halves of that path so a regression is caught either way. + assert.match(batch, /mount -t devtmpfs devtmpfs \/dev/); + assert.match(batch, /console:5:1 ttyS0:4:64 ttyAMA0:204:64 null:1:3 zero:1:5 full:1:7 random:1:8 urandom:1:9 port:1:4/); + assert.match(batch, /mknod "\/dev\/\$name" c "\$major" "\$minor"/); assert.match(batch, /mount -t tmpfs tmpfs \/dev\/shm/); assert.match(batch, /ln -s pts\/ptmx \/dev\/ptmx/); assert.match(batch, /node_vmm\.iface=/); assert.match(batch, /node_vmm\.ip=/); assert.match(batch, /NODE_VMM_RUNTIME_DNS/); + assert.match(batch, /node_vmm\.epoch=\*/); + assert.match(batch, /node_vmm\.utc=\*/); + assert.match(batch, /date -u -s "\$node_vmm_utc_text"/); assert.match(batch, /ip addr add "\$NODE_VMM_ADDR" dev "\$NODE_VMM_IFACE"/); + // The init script now ships BOTH branches and switches based on + // node_vmm.interactive=1 from /proc/cmdline, so the rootfs cache is shared + // across batch/interactive runs. const interactive = renderInitScript({ commandLine: "/bin/sh", workdir: "/", mode: "interactive" }); assert.match(interactive, /interactive: \$NODE_VMM_COMMAND/); - assert.doesNotMatch(interactive, /node-vmm-command\.out/); + assert.match(interactive, /node-vmm-command\.out/); assert.match(interactive, /\/node-vmm\/console \/bin\/sh -lc "\$NODE_VMM_COMMAND"/); + assert.match(interactive, /node_vmm\.interactive=1\)\s+NODE_VMM_INTERACTIVE=1/); + assert.match(interactive, /NODE_VMM_WINDOWS_CONSOLE=0/); + assert.match(interactive, /NODE_VMM_TTY_COLS=80/); + assert.match(interactive, /NODE_VMM_TTY_ROWS=24/); + assert.match(interactive, /NODE_VMM_WHP_CONSOLE_ROUTE=getty/); + assert.match(interactive, /node_vmm\.windows_console=1\|node_vmm\.getty=1\)/); + assert.match(interactive, /node_vmm\.console_route=pty\)/); + assert.match(interactive, /node_vmm\.tty_cols=\*/); + assert.match(interactive, /node_vmm\.tty_rows=\*/); + assert.match(interactive, /export NODE_VMM_TTY_COLS NODE_VMM_TTY_ROWS/); + assert.match(interactive, /\$NODE_VMM_WINDOWS_CONSOLE" = "1" \] && \[ "\$NODE_VMM_WHP_CONSOLE_ROUTE" != "pty" \] && command -v getty/); + assert.match(interactive, /stty -F "\$1" rows "\$NODE_VMM_TTY_ROWS" cols "\$NODE_VMM_TTY_COLS"/); + assert.match(interactive, /export COLUMNS="\$NODE_VMM_TTY_COLS"/); + assert.equal(interactive, renderInitScript({ commandLine: "/bin/sh", workdir: "/" })); assert.match(renderInitScript({ commandLine: 'echo "$HOME"', workdir: "srv/app" }), /cd 'srv\/app'/); }); @@ -231,7 +309,8 @@ test("utility filesystem helpers work on temporary paths", async () => { test("kernel helpers resolve node-vmm env, cache, gocracker URLs, and downloads", async () => { const dir = await makeTempDir("node-vmm-kernel-unit-"); const cacheDir = path.join(dir, "kernels"); - const cachedKernel = path.join(cacheDir, DEFAULT_GOCRACKER_KERNEL); + const platformKernel = process.platform === "darwin" ? DEFAULT_GOCRACKER_ARM64_KERNEL : DEFAULT_GOCRACKER_KERNEL; + const cachedKernel = path.join(cacheDir, platformKernel); const env = { NODE_VMM_KERNEL_CACHE_DIR: cacheDir, NODE_VMM_KERNEL_REPO: "https://example.test/kernels/", @@ -242,11 +321,17 @@ test("kernel helpers resolve node-vmm env, cache, gocracker URLs, and downloads" assert.equal(envKernelPath({ NODE_VMM_KERNEL: "node" } as NodeJS.ProcessEnv), "node"); assert.equal(envKernelPath({} as NodeJS.ProcessEnv), undefined); assert.equal(defaultKernelCacheDir(env), cacheDir); - assert.match(defaultKernelCacheDir({} as NodeJS.ProcessEnv), /node-vmm\/kernels$/); + assert.equal(defaultKernelCacheDir({} as NodeJS.ProcessEnv).endsWith(path.join("node-vmm", "kernels")), true); + assert.equal(defaultArm64KernelName(), DEFAULT_GOCRACKER_ARM64_KERNEL); + assert.equal(defaultKernelNameForPlatform("darwin"), DEFAULT_GOCRACKER_ARM64_KERNEL); + assert.equal(defaultKernelNameForPlatform("linux"), DEFAULT_GOCRACKER_KERNEL); + assert.deepEqual(defaultKernelNamesForPlatform("darwin"), [DEFAULT_GOCRACKER_ARM64_KERNEL, "gocracker-guest-minimal-arm64-Image"]); + assert.deepEqual(defaultKernelNamesForPlatform("linux"), [DEFAULT_GOCRACKER_KERNEL]); assert.equal(gocrackerKernelUrl(DEFAULT_GOCRACKER_KERNEL, env), "https://example.test/kernels/gocracker-guest-standard-vmlinux.gz"); assert.match(gocrackerKernelUrl("custom", {} as NodeJS.ProcessEnv), /gocracker\/main\/artifacts\/kernels\/custom\.gz$/); assert.ok(defaultKernelCandidates({ cwd: dir, env }).includes(cachedKernel)); - assert.ok(defaultKernelCandidates({ env }).some((candidate) => candidate.endsWith(DEFAULT_GOCRACKER_KERNEL))); + assert.ok(defaultKernelCandidates({ cwd: dir, env, name: "custom-kernel" }).some((candidate) => candidate.endsWith("custom-kernel"))); + assert.ok(defaultKernelCandidates({ env }).some((candidate) => candidate.endsWith(platformKernel))); assert.equal(await requireKernelPath({ cwd: dir, env }), cachedKernel); assert.equal(await findDefaultKernel({ cwd: dir, env: { NODE_VMM_KERNEL: "rel/vmlinux" } as NodeJS.ProcessEnv }), path.join(dir, "rel/vmlinux")); assert.equal( @@ -270,14 +355,14 @@ test("kernel helpers resolve node-vmm env, cache, gocracker URLs, and downloads" arrayBuffer: async () => new Uint8Array(body).buffer, }); const server = createServer((request, response) => { - if (request.url === `/${DEFAULT_GOCRACKER_KERNEL}.gz`) { + if (request.url === `/${platformKernel}.gz`) { response.writeHead(200, { "content-type": "application/gzip" }); response.end(serverData); return; } - if (request.url === `/${DEFAULT_GOCRACKER_KERNEL}.gz.sha256`) { + if (request.url === `/${platformKernel}.gz.sha256`) { response.writeHead(200, { "content-type": "text/plain" }); - response.end(`${serverSha} ${DEFAULT_GOCRACKER_KERNEL}.gz\n`); + response.end(`${serverSha} ${platformKernel}.gz\n`); return; } if (request.url === "/custom-http.gz") { @@ -494,6 +579,21 @@ test("requireRoot reports non-root users", () => { } }); +test("requireRoot reports a simulated non-root process", () => { + const descriptor = Object.getOwnPropertyDescriptor(process, "getuid"); + const mutableProcess = process as typeof process & { getuid?: () => number }; + Object.defineProperty(process, "getuid", { configurable: true, value: () => 1000 }); + try { + assert.throws(() => requireRoot("simulated action"), /requires root privileges/); + } finally { + if (descriptor) { + Object.defineProperty(process, "getuid", descriptor); + } else { + delete mutableProcess.getuid; + } + } +}); + test("runCommand captures output, input, and failures", async () => { const abortedBeforeStart = new AbortController(); abortedBeforeStart.abort(); @@ -553,11 +653,13 @@ test("runCommand captures output, input, and failures", async () => { { timeoutMs: 10, killGraceMs: 20, killTree: true, allowFailure: true }, ); assert.equal(forceKilledTree.timedOut, true); - const interrupted = runCommand(process.execPath, ["-e", "setTimeout(() => {}, 1000)"], { - killTree: true, - }); - setTimeout(() => process.kill(process.pid, "SIGTERM"), 20).unref(); - await assert.rejects(() => interrupted, /command interrupted by SIGTERM/); + if (process.platform !== "win32") { + const interrupted = runCommand(process.execPath, ["-e", "setTimeout(() => {}, 1000)"], { + killTree: true, + }); + setTimeout(() => process.kill(process.pid, "SIGTERM"), 20).unref(); + await assert.rejects(() => interrupted, /command interrupted by SIGTERM/); + } const abortDuringRun = new AbortController(); const aborted = runCommand(process.execPath, ["-e", "setTimeout(() => {}, 1000)"], { signal: abortDuringRun.signal, @@ -576,12 +678,19 @@ test("commandExists and requireCommands report host command availability", async test("setupNetwork supports none and tap modes without mutating host network", async () => { assert.deepEqual(await setupNetwork({ id: "vm1", mode: "none" }), { mode: "none" }); - assert.deepEqual(await setupNetwork({ id: "vm1", mode: "tap", tapName: "tap-test" }), { - mode: "tap", - ifaceId: "eth0", - tapName: "tap-test", - guestMac: deterministicMac("vm1"), - }); + if (process.platform === "darwin") { + await assert.rejects( + () => setupNetwork({ id: "vm1", mode: "tap", tapName: "tap-test" }), + /macOS\/HVF does not support arbitrary --net tap devices/, + ); + } else { + assert.deepEqual(await setupNetwork({ id: "vm1", mode: "tap", tapName: "tap-test" }), { + mode: "tap", + ifaceId: "eth0", + tapName: "tap-test", + guestMac: deterministicMac("vm1"), + }); + } await assert.rejects(() => setupNetwork({ id: "vm1", mode: "tap" }), /requires --tap/); await assert.rejects(() => setupNetwork({ id: "vm1", mode: "none", ports: ["3000"] }), /requires --net auto/); await assert.rejects( @@ -591,6 +700,17 @@ test("setupNetwork supports none and tap modes without mutating host network", a await assert.rejects(() => setupNetwork({ id: "vm1", mode: "bad" }), /unsupported network mode/); }); +test("setupNetwork resolves random slirp host ports", async () => { + const network = await setupNetwork({ id: "vm1", mode: "slirp", ports: ["3000", "127.0.0.1:0:3001"] }); + assert.equal(network.mode, "slirp"); + assert.deepEqual(network.ports?.map((port) => port.guestPort), [3000, 3001]); + assert.ok((network.ports?.[0]?.hostPort ?? 0) > 0); + assert.ok((network.hostFwds?.[0]?.hostPort ?? 0) > 0); + assert.equal(network.ports?.[0]?.hostPort, network.hostFwds?.[0]?.hostPort); + assert.equal(network.hostFwds?.[1]?.hostAddr, "127.0.0.1"); + await network.cleanup?.(); +}); + test("parsePortForward follows Docker publish syntax for TCP ports", () => { assert.deepEqual(parsePortForward(3000), { host: "127.0.0.1", hostPort: 0, guestPort: 3000 }); assert.deepEqual(parsePortForward("3000"), { host: "127.0.0.1", hostPort: 0, guestPort: 3000 }); @@ -610,20 +730,97 @@ test("parsePortForward follows Docker publish syntax for TCP ports", () => { assert.throws(() => parsePortForward("host:not-a-port:3000"), /hostPort must be a TCP port number/); }); +test("host capabilities model backend defaults and WHP runtime limits", () => { + const kvm = capabilitiesForHost({ platform: "linux", arch: "x64" }); + assert.equal(hostBackendForHost({ platform: "linux", arch: "x64" }), "kvm"); + assert.equal(kvm.archLine, "linux/x86_64"); + assert.equal(defaultNetworkForCapabilities(kvm, undefined), "auto"); + assert.equal(defaultNetworkForCapabilities(kvm, undefined, "tap-test"), "tap"); + assert.deepEqual(kvm.networkModes, ["auto", "none", "tap", "slirp"]); + assert.equal(kvm.rootfsMaxCpus, 64); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 64, network: "auto", ports: ["3000"] }, kvm)); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 64, network: "slirp", ports: ["3000"] }, kvm)); + assert.doesNotThrow(() => validateRootfsRuntimeForCapabilities("run", { cpus: 64 }, kvm)); + assert.doesNotThrow(() => assertRootfsBuildSupportedForCapabilities("run", { image: "alpine:3.20" }, kvm)); + + const whp = capabilitiesForHost({ platform: "win32", arch: "x64" }); + assert.equal(hostBackendForHost({ platform: "win32", arch: "x64" }), "whp"); + assert.equal(whp.archLine, "windows/x86_64"); + assert.equal(whp.maxCpus, 64); + assert.equal(whp.rootfsMaxCpus, 64); + assert.equal(whp.rootfsBuild, true); + assert.equal(defaultNetworkForCapabilities(whp, undefined), "auto"); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 1, network: "none" }, whp)); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 2, network: "none" }, whp)); + assert.doesNotThrow(() => validateRootfsRuntimeForCapabilities("run", { cpus: 1 }, whp)); + assert.doesNotThrow(() => validateRootfsRuntimeForCapabilities("run", { cpus: 2 }, whp)); + assert.doesNotThrow(() => validateRootfsRuntimeForCapabilities("run", { cpus: 64 }, whp)); + assert.doesNotThrow(() => assertRootfsBuildSupportedForCapabilities("run", { image: "alpine:3.20" }, whp)); + assert.doesNotThrow(() => assertRootfsBuildSupportedForCapabilities("run", { rootfsPath: "base.ext4" }, whp)); + assert.doesNotThrow(() => validateRootfsOptionsForCapabilities("run", { rootfsPath: "base.ext4" }, whp)); + assert.doesNotThrow(() => validateRootfsOptionsForCapabilities("run", { image: "alpine:3.20" }, whp)); + assert.throws(() => validateRootfsOptionsForCapabilities("run", {}, whp), /run requires rootfsPath, diskPath, image, dockerfile, or repo/); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ network: "auto" }, whp)); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ network: "slirp" }, whp)); + assert.throws(() => validateVmOptionsForCapabilities({ network: "tap" }, whp), /network:tap.*network: 'none'/); + assert.throws(() => validateVmOptionsForCapabilities({ tapName: "tap-test" }, whp), /TAP networking.*network: 'none'/); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ ports: ["3000"], network: "slirp" }, whp)); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ ports: ["3000"], network: "auto" }, whp)); + assert.throws( + () => assertRootfsBuildSupportedForCapabilities("prepare", { dockerfile: "Dockerfile" }, whp), + /Windows\/WHP.*Dockerfile and repo builds still require Linux/, + ); + assert.throws( + () => assertRootfsBuildSupportedForCapabilities("snapshot create", { repo: "https://example.test/repo.git" }, whp), + /Dockerfile and repo builds still require Linux/, + ); + + const hvf = capabilitiesForHost({ platform: "darwin", arch: "arm64" }); + assert.equal(hvf.backend, "hvf"); + assert.equal(hvf.archLine, "darwin/arm64"); + assert.equal(defaultNetworkForCapabilities(hvf, undefined), "auto"); + assert.doesNotThrow(() => validateVmOptionsForCapabilities({ cpus: 64, network: "slirp", ports: ["3000"] }, hvf)); +}); + +test("native WHP backend type exposes the runVm contract", () => { + const config: WhpRunConfig = { + kernelPath: "guest.elf", + rootfsPath: "rootfs.ext4", + cmdline: "console=ttyS0", + memMiB: 64, + cpus: 1, + }; + const backend: Pick<NativeWhpBackend, "runVm"> = { + runVm(received): WhpRunResult { + assert.equal(received, config); + return { exitReason: "guest-exit", exitReasonCode: 2, runs: 3, console: "OK" }; + }, + }; + assert.deepEqual(backend.runVm(config), { exitReason: "guest-exit", exitReasonCode: 2, runs: 3, console: "OK" }); +}); + test("SDK exposes feature, doctor, and client helpers", async () => { - assert.ok(features().some((line) => line.includes("backend: kvm"))); + const hostCapabilities = capabilitiesForHost(); + const backendLine = hostCapabilities.backend === "unsupported" ? "backend: none" : `backend: ${hostCapabilities.backend}`; + assert.ok(features().some((line) => line.includes(backendLine))); assert.equal(nodeVmm, nodeVmmDefault); assert.equal(snapshot, createSnapshot); assert.equal(restore, restoreSnapshot); assert.deepEqual(nodeVmmDefault.features(), features()); const result = await doctor(); assert.equal(typeof result.ok, "boolean"); - assert.ok(result.checks.some((check) => check.name === "/dev/kvm")); + assert.ok(result.checks.some((check) => check.name === ( + hostCapabilities.backend === "whp" ? "whp-api" : + hostCapabilities.backend === "kvm" ? "/dev/kvm" : + hostCapabilities.backend === "hvf" ? "hvf-api" : + "platform" + ))); const client: NodeVmmClient = createNodeVmmClient({ logger: () => undefined }); const nodeClient: NodeVmmClient = createNodeVmmClient({ logger: () => undefined }); assert.deepEqual(client.features(), features()); assert.deepEqual(nodeClient.features(), features()); - assert.ok(features().some((line) => line.includes("vcpu: 1-64"))); + assert.ok(features().some((line) => line.includes("vcpu:"))); + assert.ok(features().some((line) => line.includes("network:"))); assert.equal(typeof client.run, "function"); assert.equal(typeof client.runCode, "function"); assert.equal(typeof client.boot, "function"); @@ -678,8 +875,10 @@ test("SDK validates missing options before doing expensive work", async () => { } await assert.rejects(() => prepareSandbox({ kernel: "vmlinux", net: "none" }), /prepare requires/); await assert.rejects(() => createSnapshot({ kernel: "vmlinux", output: "snapshot", net: "none" }), /snapshot create requires/); - assert.throws(() => ramSnapshotSmoke({ snapshotDir: "" }), /snapshotDir is required/); - assert.throws(() => dirtyRamSnapshotSmoke({ snapshotDir: "" }), /snapshotDir is required/); + if (process.platform === "linux") { + assert.throws(() => ramSnapshotSmoke({ snapshotDir: "" }), /snapshotDir is required/); + assert.throws(() => dirtyRamSnapshotSmoke({ snapshotDir: "" }), /snapshotDir is required/); + } assert.equal(build, buildRootfsImage); assert.equal(prepare, prepareSandbox); assert.equal(createSandbox, prepareSandbox); @@ -699,7 +898,7 @@ test("core snapshot create writes a reusable bundle manifest", async () => { kernel, output, memory: 512, - cpus: 4, + cpus: 1, net: "none", }); @@ -713,7 +912,7 @@ test("core snapshot create writes a reusable bundle manifest", async () => { const manifest = JSON.parse(await readFile(path.join(output, "snapshot.json"), "utf8")); assert.equal(manifest.kind, "node-vmm-rootfs-snapshot"); assert.equal(manifest.memory, 512); - assert.equal(manifest.cpus, 4); + assert.equal(manifest.cpus, 1); assert.equal(manifest.rootfs, "rootfs.ext4"); assert.equal(manifest.kernel, "kernel"); @@ -775,6 +974,40 @@ test("runKvmVm forwards native validation errors", () => { }), /kernelPath is required/, ); + assert.throws( + () => runKvmVm({ kernelPath: "vmlinux", rootfsPath: "", cmdline: "", memMiB: 1 }), + /rootfsPath is required/, + ); + assert.throws( + () => + runKvmVm({ + kernelPath: "vmlinux", + rootfsPath: "rootfs.ext4", + attachDisks: [{ path: "" }], + cmdline: "console=ttyS0", + memMiB: 1, + }), + /attachDisks\[0\]\.path is required/, + ); + assert.throws( + () => runKvmVm({ kernelPath: "vmlinux", rootfsPath: "rootfs.ext4", cmdline: "", memMiB: 1 }), + /cmdline is required/, + ); + assert.throws( + () => runKvmVm({ kernelPath: "vmlinux", rootfsPath: "rootfs.ext4", cmdline: "console=ttyS0", memMiB: 1, cpus: 0 }), + /cpus must be between 1 and 64/, + ); + assert.throws( + () => + runKvmVm({ + kernelPath: "vmlinux", + rootfsPath: "rootfs.ext4", + cmdline: "console=ttyS0", + memMiB: 1, + netTapName: "tap0", + }), + /netGuestMac is required/, + ); }); test("runKvmVmAsync forwards native validation errors from a worker", async () => { @@ -792,6 +1025,29 @@ test("runKvmVmAsync forwards native validation errors from a worker", async () = () => runKvmVmAsync({ kernelPath: "", rootfsPath: "", cmdline: "", memMiB: 1 }, { signal: controller.signal }), /aborted/, ); + await assert.rejects( + () => + runKvmVmAsync({ + kernelPath: "missing-kernel.elf", + rootfsPath: "missing-rootfs.ext4", + cmdline: "console=ttyS0", + memMiB: 1, + }), + (err: unknown) => err instanceof Error, + ); + await assert.rejects( + () => + runKvmVmAsync( + { + kernelPath: "missing-kernel.elf", + rootfsPath: "missing-rootfs.ext4", + cmdline: "console=ttyS0", + memMiB: 1, + }, + { signal: new AbortController().signal }, + ), + (err: unknown) => err instanceof Error, + ); }); test("runKvmVmControlled exposes lifecycle helpers and forwards native validation errors", async () => { @@ -804,6 +1060,20 @@ test("runKvmVmControlled exposes lifecycle helpers and forwards native validatio await assert.rejects(() => handle.stop(), /kernelPath is required/); }); +test("HVF run wrappers forward native validation errors", async () => { + if (process.platform !== "darwin" || process.arch !== "arm64") { + assert.throws(() => probeHvf(), /probeHvf|native backend/); + } + assert.match(hvfDefaultKernelCmdline(), /console=ttyAMA0/); + const config = { kernelPath: "", rootfsPath: "", cmdline: "", memMiB: 1, cpus: 2 }; + assert.throws(() => runHvfVm(config), /kernelPath is required/); + await assert.rejects(() => runHvfVmAsync(config), /kernelPath is required/); + const handle = runHvfVmControlled(config); + assert.equal(handle.state(), "starting"); + await assert.rejects(() => handle.wait(), /kernelPath is required/); + assert.equal(handle.state(), "exited"); +}); + test("buildRootfs reports permission paths", async () => { await assert.rejects( () => @@ -818,7 +1088,7 @@ test("buildRootfs reports permission paths", async () => { tempDir: os.tmpdir(), cacheDir: os.tmpdir(), }), - /requires root/, + /requires root|requires Linux host|Dockerfile and repo builds still require Linux|Dockerfile RUN instructions are not supported on macOS/, ); await assert.rejects( @@ -828,7 +1098,7 @@ test("buildRootfs reports permission paths", async () => { image: undefined, cacheDir: os.tmpdir(), }), - /requires root|build requires --image/, + /requires root|requires Linux host|build requires --image/, ); }); @@ -838,3 +1108,447 @@ test("temporary cleanup works for explicit directories", async () => { await rm(dir, { recursive: true, force: true }); assert.equal(await pathExists(dir), false); }); + +// Track D.2.a — prebuilt rootfs slug mapping must stay in sync with +// the GitHub Actions workflow that publishes the assets. If the slugs +// drift, the client-side fetch in buildOrReuseRootfs silently 404s and +// the WSL2 fallback runs instead — which is what we're trying to avoid. +test("prebuiltSlugForImage maps the published images and rejects others", async () => { + const mod = await import("../src/prebuilt-rootfs.js"); + assert.equal(mod.prebuiltSlugForImage("alpine:3.20"), "alpine-3.20"); + assert.equal(mod.prebuiltSlugForImage("node:20-alpine"), "node-20-alpine"); + assert.equal(mod.prebuiltSlugForImage("node:22-alpine"), "node-22-alpine"); + // Any other ref must return null so the caller falls back to the + // WSL2 build path. Case-insensitive match on input. + assert.equal(mod.prebuiltSlugForImage("Alpine:3.20"), "alpine-3.20"); + assert.equal(mod.prebuiltSlugForImage("ubuntu:24.04"), null); + assert.equal(mod.prebuiltSlugForImage("alpine:edge"), null); +}); + +// Track D.2.a — silent fallback when the prebuilt isn't published. We +// run tryFetchPrebuiltRootfs against a clearly-bogus repo so the fetch +// 404s, then assert it returns { fetched: false } without throwing +// (caller relies on this to fall through to the WSL2 build path). +test("tryFetchPrebuiltRootfs returns fetched:false on missing release", async () => { + const mod = await import("../src/prebuilt-rootfs.js"); + const tmp = await mkdtemp(path.join(os.tmpdir(), "node-vmm-prebuilt-")); + try { + const dest = path.join(tmp, "out.ext4"); + const result = await mod.tryFetchPrebuiltRootfs({ + image: "alpine:3.20", + destPath: dest, + packageVersion: "0.0.0-does-not-exist", + repo: "misaelzapata/node-vmm-this-repo-does-not-exist-test", + // Abort fast so the test doesn't depend on real network timeouts. + signal: AbortSignal.timeout(5000), + }); + assert.equal(result.fetched, false); + assert.ok(typeof result.reason === "string" && result.reason.length > 0); + assert.equal(await pathExists(dest), false, "must not leave a partial file behind"); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); + +test("prebuilt rootfs manifest validation accepts complete manifests and rejects bad metadata", async () => { + const mod = await import("../src/prebuilt-rootfs.js"); + const manifest = { + kind: mod.PREBUILT_ROOTFS_MANIFEST_KIND, + version: mod.PREBUILT_ROOTFS_MANIFEST_VERSION, + image: "alpine:3.20", + slug: "alpine-3.20", + diskMiB: 256, + platform: "linux", + arch: "x86_64", + createdAt: "2026-05-02T00:00:00.000Z", + rootfs: { name: "alpine-3.20.ext4", sizeBytes: 16, sha256: "a".repeat(64) }, + gzip: { name: "alpine-3.20.ext4.gz", sizeBytes: 32, sha256: "b".repeat(64) }, + }; + assert.deepEqual(mod.validatePrebuiltRootfsManifest(manifest), manifest); + assert.throws( + () => mod.validatePrebuiltRootfsManifest({ ...manifest, gzip: { ...manifest.gzip, sha256: "not-a-sha" } }), + /manifest gzip\.sha256 is invalid/, + ); + assert.throws( + () => mod.validatePrebuiltRootfsManifest({ ...manifest, rootfs: { ...manifest.rootfs, name: "../rootfs.ext4" } }), + /bad rootfs\.name asset name/, + ); +}); + +test("tryFetchPrebuiltRootfs downloads and verifies a mocked manifest asset", async () => { + const mod = await import("../src/prebuilt-rootfs.js"); + const tmp = await mkdtemp(path.join(os.tmpdir(), "node-vmm-prebuilt-ok-")); + const originalFetch = globalThis.fetch; + try { + const dest = path.join(tmp, "out.ext4"); + const rootfs = Buffer.from("mock-ext4-rootfs"); + const compressed = gzipSync(rootfs); + const rootfsSha = createHash("sha256").update(rootfs).digest("hex"); + const gzipSha = createHash("sha256").update(compressed).digest("hex"); + const manifest = { + kind: mod.PREBUILT_ROOTFS_MANIFEST_KIND, + version: mod.PREBUILT_ROOTFS_MANIFEST_VERSION, + image: "alpine:3.20", + slug: "alpine-3.20", + diskMiB: 256, + platform: "linux", + arch: "x86_64", + createdAt: "2026-05-02T00:00:00.000Z", + rootfs: { name: "alpine-3.20.ext4", sizeBytes: rootfs.byteLength, sha256: rootfsSha }, + gzip: { name: "alpine-3.20.ext4.gz", sizeBytes: compressed.byteLength, sha256: gzipSha }, + }; + const urls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + urls.push(url); + if (url.endsWith(".manifest.json")) { + return new Response(JSON.stringify(manifest), { headers: { "content-type": "application/json" } }); + } + if (url.endsWith(".gz")) { + return new Response(new Uint8Array(compressed)); + } + return new Response("missing", { status: 404, statusText: "Not Found" }); + }) as typeof fetch; + + const result = await mod.tryFetchPrebuiltRootfs({ + image: "alpine:3.20", + destPath: dest, + packageVersion: "1.2.3", + repo: "owner/repo", + }); + + assert.equal(result.fetched, true); + assert.equal(await readFile(dest, "utf8"), "mock-ext4-rootfs"); + assert.deepEqual( + urls.map((url) => new URL(url).pathname), + [ + "/owner/repo/releases/download/v1.2.3/alpine-3.20.ext4.manifest.json", + "/owner/repo/releases/download/v1.2.3/alpine-3.20.ext4.gz", + ], + ); + assert.equal(result.manifest?.slug, "alpine-3.20"); + } finally { + globalThis.fetch = originalFetch; + await rm(tmp, { recursive: true, force: true }); + } +}); + +test("tryFetchPrebuiltRootfs removes partial files on manifest checksum failure", async () => { + const mod = await import("../src/prebuilt-rootfs.js"); + const tmp = await mkdtemp(path.join(os.tmpdir(), "node-vmm-prebuilt-bad-sha-")); + const originalFetch = globalThis.fetch; + try { + const dest = path.join(tmp, "out.ext4"); + const rootfs = Buffer.from("tampered-rootfs"); + const compressed = gzipSync(rootfs); + const gzipSha = createHash("sha256").update(compressed).digest("hex"); + const manifest = { + kind: mod.PREBUILT_ROOTFS_MANIFEST_KIND, + version: mod.PREBUILT_ROOTFS_MANIFEST_VERSION, + image: "alpine:3.20", + slug: "alpine-3.20", + diskMiB: 256, + platform: "linux", + arch: "x86_64", + createdAt: "2026-05-02T00:00:00.000Z", + rootfs: { name: "alpine-3.20.ext4", sizeBytes: rootfs.byteLength, sha256: "0".repeat(64) }, + gzip: { name: "alpine-3.20.ext4.gz", sizeBytes: compressed.byteLength, sha256: gzipSha }, + }; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.endsWith(".manifest.json")) { + return new Response(JSON.stringify(manifest), { headers: { "content-type": "application/json" } }); + } + if (url.endsWith(".gz")) { + return new Response(new Uint8Array(compressed)); + } + return new Response("missing", { status: 404, statusText: "Not Found" }); + }) as typeof fetch; + + const result = await mod.tryFetchPrebuiltRootfs({ + image: "alpine:3.20", + destPath: dest, + packageVersion: "1.2.3", + repo: "owner/repo", + }); + + assert.equal(result.fetched, false); + assert.match(result.reason ?? "", /prebuilt rootfs checksum mismatch/); + assert.equal(await pathExists(dest), false, "bad prebuilt downloads must not leave partial disks"); + } finally { + globalThis.fetch = originalFetch; + await rm(tmp, { recursive: true, force: true }); + } +}); + +test("buildOrReuseRootfs rejects --prebuilt require before falling back to local build paths", async () => { + const cacheDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-prebuilt-require-")); + try { + await assert.rejects( + () => + buildOrReuseRootfs({ + options: { image: "ubuntu:24.04", prebuilt: "require" } as never, + source: { contextDir: cacheDir }, + output: path.join(cacheDir, "out.ext4"), + tempDir: cacheDir, + cacheDir, + }), + /no prebuilt rootfs is published for ubuntu:24\.04/, + ); + await assert.rejects( + () => + buildOrReuseRootfs({ + options: { dockerfile: "Dockerfile", prebuilt: "require" } as never, + source: { contextDir: cacheDir, dockerfile: path.join(cacheDir, "Dockerfile") }, + output: path.join(cacheDir, "out.ext4"), + tempDir: cacheDir, + cacheDir, + }), + /required prebuilt rootfs unavailable for this rootfs input/, + ); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +}); + +test("CLI accepts root disk persistence flag shapes and validates contradictory choices", async () => { + const parsed = parseOptions( + [ + "--prebuilt", + "require", + "--disk-path", + "data.ext4", + "--disk-size", + "64", + "--persist", + "app-cache", + "--reset", + ], + new Set(["reset"]), + new Set(["prebuilt", "disk-path", "disk-size", "persist"]), + ); + assert.equal(stringOption(parsed, "prebuilt"), "require"); + assert.equal(stringOption(parsed, "disk-path"), "data.ext4"); + assert.equal(intOption(parsed, "disk-size", 0), 64); + assert.equal(stringOption(parsed, "persist"), "app-cache"); + assert.equal(boolOption(parsed, "reset"), true); + + await assert.rejects( + () => + main([ + "node", + "node-vmm", + "run", + "--rootfs", + "root.ext4", + "--kernel", + "vmlinux", + "--net", + "none", + "--disk-path", + "data.ext4", + "--disk-size", + "64", + "--persist", + "app-cache", + ]), + /--persist.*diskPath|--persist.*--disk/i, + ); + await assert.rejects( + () => + main([ + "node", + "node-vmm", + "run", + "--image", + "alpine:3.20", + "--kernel", + "vmlinux", + "--net", + "none", + "--reset", + ]), + /--reset requires --persist or --disk PATH/, + ); + await assert.rejects( + () => + main([ + "node", + "node-vmm", + "run", + "--rootfs", + "root.ext4", + "--kernel", + "vmlinux", + "--net", + "none", + "--disk-size", + "0", + ]), + /--disk-size must be at least 1 MiB/, + ); + await assert.rejects( + () => + main([ + "node", + "node-vmm", + "run", + "--rootfs", + "root.ext4", + "--kernel", + "vmlinux", + "--net", + "none", + "--disk", + "0", + ]), + /--disk must be at least 1 MiB/, + ); +}); + +test("materializePersistentDisk creates a disk and metadata pair", async () => { + const cacheDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-persist-test-")); + try { + const baseRootfsPath = path.join(cacheDir, "base.ext4"); + await writeFile(baseRootfsPath, Buffer.alloc(1024 * 1024)); + + const created = await materializePersistentDisk({ + name: "work", + baseRootfsPath, + cacheDir, + sourceKey: "image-a", + diskMiB: 1, + }); + + const diskPath = path.join(cacheDir, "disks", "work.ext4"); + const metaPath = path.join(cacheDir, "disks", "work.json"); + assert.equal(created.rootfsPath, diskPath); + assert.equal(created.created, true); + assert.equal(await pathExists(diskPath), true); + const metadata = JSON.parse(await readFile(metaPath, "utf8")); + assert.equal(metadata.kind, "node-vmm-persistent-disk"); + assert.equal(metadata.name, "work"); + assert.equal(metadata.sourceKey, "image-a"); + assert.equal(metadata.sizeMiB, 1); + + const reused = await materializePersistentDisk({ + name: "work", + baseRootfsPath, + cacheDir, + sourceKey: "image-a", + diskMiB: 1, + }); + assert.equal(reused.created, false); + assert.equal(reused.resized, false); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +}); + +test("SDK validates attachDisks shape before native runtime setup", () => { + const whp = capabilitiesForHost({ platform: "win32", arch: "x64" }); + assert.doesNotThrow(() => + validateVmOptionsForCapabilities( + { network: "none", attachDisks: [{ path: "data.ext4" }, { path: "logs.ext4", readonly: true }] } as never, + whp, + ), + ); + assert.throws( + () => + validateVmOptionsForCapabilities( + { network: "none", attachDisks: [{ path: "" }] } as never, + whp, + ), + /attachDisks\[0\]\.path is required/, + ); + assert.throws( + () => + validateVmOptionsForCapabilities( + { network: "none", attachDisks: Array.from({ length: 17 }, (_value, index) => ({ path: `disk-${index}.ext4` })) } as never, + whp, + ), + /up to 16 attached data disks/, + ); +}); + +// Track D.1 regression: on rootfs cache hits, buildOrReuseRootfs must +// return early without calling buildRootfs (which on Windows is the +// WSL2-driven path). Hitting the cache is the contract that lets +// `node-vmm run --image alpine:3.20` re-run on Windows after the first +// build without spawning WSL2 a second time. +test("buildOrReuseRootfs cache hit returns the cached path without rebuilding (no WSL2 spawn)", async () => { + const cacheDir = await mkdtemp(path.join(os.tmpdir(), "node-vmm-cache-hit-")); + try { + const options = { + image: "alpine:3.20", + diskMiB: 256, + buildArgs: {}, + env: {}, + }; + const key = rootfsCacheKey(options as Parameters<typeof rootfsCacheKey>[0]); + const rootfsCacheRoot = path.join(cacheDir, "rootfs"); + await mkdir(rootfsCacheRoot, { recursive: true, mode: 0o700 }); + const cachedFile = path.join(rootfsCacheRoot, `${key}.ext4`); + await writeFile(cachedFile, "fake-rootfs", { mode: 0o600 }); + + let buildLogged = false; + const result = await buildOrReuseRootfs({ + options: options as Parameters<typeof buildOrReuseRootfs>[0]["options"], + source: { contextDir: cacheDir }, + output: path.join(cacheDir, "should-not-be-written.ext4"), + tempDir: cacheDir, + cacheDir, + logger: (message) => { + if (/cache miss|cache build/i.test(message)) { + buildLogged = true; + } + }, + }); + + assert.equal(result.fromCache, true); + assert.equal(result.built, false); + assert.equal(result.rootfsPath, cachedFile); + assert.equal(buildLogged, false, "build path must not log on cache hit"); + // Output path should NOT have been written; the cache file is reused + // verbatim. If it had, the WSL2 path would have run. + assert.equal(await pathExists(path.join(cacheDir, "should-not-be-written.ext4")), false); + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +}); + +// Track D.1 regression: `runImage --rootfs PATH` (no --image) must not +// even consider the build pipeline, regardless of platform. This test +// goes through the full validation/runImage path with a synthetic +// rootfs file but a clearly-invalid kernel, so we know it bails *before* +// any potential build call. Any WSL2 spawn would have shown up first. +test("runImage with --rootfs never enters the build pipeline", async () => { + const tmp = await mkdtemp(path.join(os.tmpdir(), "node-vmm-rootfs-direct-")); + try { + const fakeRootfs = path.join(tmp, "fake.ext4"); + await writeFile(fakeRootfs, "fake-rootfs", { mode: 0o600 }); + // No --image, no kernel: runImage will fail to resolve a kernel before + // it can possibly invoke the build pipeline. The shape of the error + // tells us which code path was taken: anything mentioning "wsl", + // "mkfs", or "buildRootfs" would indicate the contract is broken. + let err: unknown; + try { + await runImage( + { + rootfsPath: fakeRootfs, + kernelPath: path.join(tmp, "no-such-kernel"), + } as Parameters<typeof runImage>[0], + { cwd: tmp }, + ); + } catch (e) { + err = e; + } + const message = err instanceof Error ? err.message : String(err); + assert.ok(err, "runImage with --rootfs and missing kernel must error"); + assert.doesNotMatch(message, /wsl/i, `unexpected WSL2 reference: ${message}`); + assert.doesNotMatch(message, /mkfs|truncate/i, `unexpected build pipeline reference: ${message}`); + assert.doesNotMatch(message, /buildRootfs/i, `unexpected build pipeline reference: ${message}`); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}); diff --git a/third_party/libslirp/bin/x64-windows/libcharset-1.dll b/third_party/libslirp/bin/x64-windows/libcharset-1.dll new file mode 100644 index 0000000..c85f839 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libcharset-1.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libgcc_s_seh-1.dll b/third_party/libslirp/bin/x64-windows/libgcc_s_seh-1.dll new file mode 100644 index 0000000..811016d Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libgcc_s_seh-1.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libglib-2.0-0.dll b/third_party/libslirp/bin/x64-windows/libglib-2.0-0.dll new file mode 100644 index 0000000..dfd5cc4 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libglib-2.0-0.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libiconv-2.dll b/third_party/libslirp/bin/x64-windows/libiconv-2.dll new file mode 100644 index 0000000..3cace95 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libiconv-2.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libintl-8.dll b/third_party/libslirp/bin/x64-windows/libintl-8.dll new file mode 100644 index 0000000..aae1c80 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libintl-8.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libpcre2-8-0.dll b/third_party/libslirp/bin/x64-windows/libpcre2-8-0.dll new file mode 100644 index 0000000..85a0425 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libpcre2-8-0.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libslirp-0.dll b/third_party/libslirp/bin/x64-windows/libslirp-0.dll new file mode 100644 index 0000000..6bfaa33 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libslirp-0.dll differ diff --git a/third_party/libslirp/bin/x64-windows/libwinpthread-1.dll b/third_party/libslirp/bin/x64-windows/libwinpthread-1.dll new file mode 100644 index 0000000..785f580 Binary files /dev/null and b/third_party/libslirp/bin/x64-windows/libwinpthread-1.dll differ diff --git a/third_party/libslirp/include/libslirp-version.h b/third_party/libslirp/include/libslirp-version.h new file mode 100644 index 0000000..1536686 --- /dev/null +++ b/third_party/libslirp/include/libslirp-version.h @@ -0,0 +1,24 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +#ifndef LIBSLIRP_VERSION_H_ +#define LIBSLIRP_VERSION_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#define SLIRP_MAJOR_VERSION 4 +#define SLIRP_MINOR_VERSION 9 +#define SLIRP_MICRO_VERSION 1 +#define SLIRP_VERSION_STRING "4.9.1" + +#define SLIRP_CHECK_VERSION(major,minor,micro) \ + (SLIRP_MAJOR_VERSION > (major) || \ + (SLIRP_MAJOR_VERSION == (major) && SLIRP_MINOR_VERSION > (minor)) || \ + (SLIRP_MAJOR_VERSION == (major) && SLIRP_MINOR_VERSION == (minor) && \ + SLIRP_MICRO_VERSION >= (micro))) + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* LIBSLIRP_VERSION_H_ */ diff --git a/third_party/libslirp/include/libslirp.h b/third_party/libslirp/include/libslirp.h new file mode 100644 index 0000000..2f24ce1 --- /dev/null +++ b/third_party/libslirp/include/libslirp.h @@ -0,0 +1,426 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +#ifndef LIBSLIRP_H +#define LIBSLIRP_H + +#include <stdint.h> +#include <stdbool.h> +#include <sys/types.h> + +#ifdef _WIN32 +#include <winsock2.h> +#include <windows.h> +#include <ws2tcpip.h> +#include <in6addr.h> +#include <basetsd.h> +#include <errno.h> + +typedef SSIZE_T slirp_ssize_t; +#ifdef LIBSLIRP_STATIC +# define SLIRP_EXPORT +#elif defined(BUILDING_LIBSLIRP) +# define SLIRP_EXPORT __declspec(dllexport) +#else +# define SLIRP_EXPORT __declspec(dllimport) +#endif +#else +#include <sys/types.h> +typedef ssize_t slirp_ssize_t; +#include <netinet/in.h> +#include <arpa/inet.h> +#define SLIRP_EXPORT +#endif + +#include "libslirp-version.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __GNUC__ +#define SLIRP_DEPRECATED __attribute__((__deprecated__)) +#else +#define SLIRP_DEPRECATED +#endif + +/* Socket abstraction:*/ + +#if !defined(_WIN32) +/* Traditional Unix socket. */ +typedef int slirp_os_socket; +#define SLIRP_INVALID_SOCKET (-1) +#define SLIRP_PRIfd "d" +#else +/* Windows: Win64 is a LLP64 platform, sizeof(int) < sizeof(long long) == sizeof(void *). + * + * Windows likes to pass HANDLE types around, which are pointers (aka unsigned long longs), + * which cannot be represented as ints. And, MS, in its infinite wisdom, decided to use + * a SOCKET handle instead of an int for socket library calls. */ +typedef SOCKET slirp_os_socket; +#define SLIRP_INVALID_SOCKET INVALID_SOCKET +#if defined(_WIN64) +# define SLIRP_PRIfd "llx" +#else +# define SLIRP_PRIfd "x" +#endif +#endif + +/* Opaque structure containing the slirp state */ +typedef struct Slirp Slirp; + +/* Flags passed to SlirpAddPollCb and to be returned by SlirpGetREventsCb. */ +enum { + SLIRP_POLL_IN = 1 << 0, + SLIRP_POLL_OUT = 1 << 1, + SLIRP_POLL_PRI = 1 << 2, + SLIRP_POLL_ERR = 1 << 3, + SLIRP_POLL_HUP = 1 << 4, +}; + +/* Debugging flags. */ +enum { + SLIRP_DBG_CALL = 1 << 0, + SLIRP_DBG_MISC = 1 << 1, + SLIRP_DBG_ERROR = 1 << 2, + SLIRP_DBG_TFTP = 1 << 3, + SLIRP_DBG_VERBOSE_CALL = 1 << 4, +}; + +/* Callback for application to get data from the guest */ +typedef slirp_ssize_t (*SlirpReadCb)(void *buf, size_t len, void *opaque); +/* Callback for application to send data to the guest */ +typedef slirp_ssize_t (*SlirpWriteCb)(const void *buf, size_t len, void *opaque); +/* Timer callback */ +typedef void (*SlirpTimerCb)(void *opaque); +/* This is deprecated, use SlirpAddPollSocketCb instead */ +typedef int (*SlirpAddPollCb)(int fd, int events, void *opaque); +/* Callback for libslirp to register polling callbacks */ +typedef int (*SlirpAddPollSocketCb)(slirp_os_socket fd, int events, void *opaque); +/* Callback for libslirp to get polling result */ +typedef int (*SlirpGetREventsCb)(int idx, void *opaque); + +/* For now libslirp creates only a timer for the IPv6 RA */ +typedef enum SlirpTimerId { + SLIRP_TIMER_RA, + SLIRP_TIMER_NUM, +} SlirpTimerId; + +/* + * Callbacks from slirp, to be set by the application. + * + * The opaque parameter is set to the opaque pointer given in the slirp_new / + * slirp_init call. + */ +typedef struct SlirpCb { + /* + * Send an ethernet frame to the guest network. The opaque parameter is the + * one given to slirp_init(). If the guest is not ready to receive a frame, + * the function can just drop the data. TCP will then handle retransmissions + * at a lower pace. + * <0 reports an IO error. + */ + SlirpWriteCb send_packet; + /* Print a message for an error due to guest misbehavior. */ + void (*guest_error)(const char *msg, void *opaque); + /* Return the virtual clock value in nanoseconds */ + int64_t (*clock_get_ns)(void *opaque); + /* Create a new timer with the given callback and opaque data. Not + * needed if timer_new_opaque is provided. */ + void *(*timer_new)(SlirpTimerCb cb, void *cb_opaque, void *opaque); + /* Remove and free a timer */ + void (*timer_free)(void *timer, void *opaque); + /* Modify a timer to expire at @expire_time (ms) */ + void (*timer_mod)(void *timer, int64_t expire_time, void *opaque); + /* Deprecated, use register_poll_socket instead */ + void (*register_poll_fd)(int fd, void *opaque) SLIRP_DEPRECATED; + /* Deprecated, use unregister_poll_socket instead */ + void (*unregister_poll_fd)(int fd, void *opaque) SLIRP_DEPRECATED; + /* Kick the io-thread, to signal that new events may be processed because some TCP buffer + * can now receive more data, i.e. slirp_socket_can_recv will return 1. */ + void (*notify)(void *opaque); + + /* + * Fields introduced in SlirpConfig version 4 begin + */ + + /* Initialization has completed and a Slirp* has been created. */ + void (*init_completed)(Slirp *slirp, void *opaque); + /* Create a new timer. When the timer fires, the application passes + * the SlirpTimerId and cb_opaque to slirp_handle_timer. */ + void *(*timer_new_opaque)(SlirpTimerId id, void *cb_opaque, void *opaque); + + /* + * Fields introduced in SlirpConfig version 6 begin + */ + /* Register a socket for future polling */ + void (*register_poll_socket)(slirp_os_socket socket, void *opaque); + /* Unregister a socket */ + void (*unregister_poll_socket)(slirp_os_socket socket, void *opaque); +} SlirpCb; + +#define SLIRP_CONFIG_VERSION_MIN 1 +#define SLIRP_CONFIG_VERSION_MAX 6 + +typedef struct SlirpConfig { + /* Version must be provided */ + uint32_t version; + /* + * Fields introduced in SlirpConfig version 1 begin + */ + /* Whether to prevent the guest from accessing the Internet */ + int restricted; + /* Whether IPv4 is enabled */ + bool in_enabled; + /* Virtual network for the guest */ + struct in_addr vnetwork; + /* Mask for the virtual network for the guest */ + struct in_addr vnetmask; + /* Virtual address for the host exposed to the guest */ + struct in_addr vhost; + /* Whether IPv6 is enabled */ + bool in6_enabled; + /* Virtual IPv6 network for the guest */ + struct in6_addr vprefix_addr6; + /* Len of the virtual IPv6 network for the guest */ + uint8_t vprefix_len; + /* Virtual address for the host exposed to the guest */ + struct in6_addr vhost6; + /* Hostname exposed to the guest in DHCP hostname option */ + const char *vhostname; + /* Hostname exposed to the guest in the DHCP TFTP server name option */ + const char *tftp_server_name; + /* Path of the files served by TFTP */ + const char *tftp_path; + /* Boot file name exposed to the guest via DHCP */ + const char *bootfile; + /* Start of the DHCP range */ + struct in_addr vdhcp_start; + /* Virtual address for the DNS server exposed to the guest */ + struct in_addr vnameserver; + /* Virtual IPv6 address for the DNS server exposed to the guest */ + struct in6_addr vnameserver6; + /* DNS search names exposed to the guest via DHCP */ + const char **vdnssearch; + /* Domain name exposed to the guest via DHCP */ + const char *vdomainname; + /* MTU when sending packets to the guest */ + /* Default: IF_MTU_DEFAULT */ + size_t if_mtu; + /* MRU when receiving packets from the guest */ + /* Default: IF_MRU_DEFAULT */ + size_t if_mru; + /* Prohibit connecting to 127.0.0.1:* */ + bool disable_host_loopback; + /* + * Enable emulation code (*warning*: this code isn't safe, it is not + * recommended to enable it) + */ + bool enable_emu; + + /* + * Fields introduced in SlirpConfig version 2 begin + */ + /* Address to be used when sending data to the Internet */ + struct sockaddr_in *outbound_addr; + /* IPv6 Address to be used when sending data to the Internet */ + struct sockaddr_in6 *outbound_addr6; + + /* + * Fields introduced in SlirpConfig version 3 begin + */ + /* slirp will not redirect/serve any DNS packet */ + bool disable_dns; + + /* + * Fields introduced in SlirpConfig version 4 begin + */ + /* slirp will not reply to any DHCP requests */ + bool disable_dhcp; + + /* + * Fields introduced in SlirpConfig version 5 begin + */ + /* Manufacturer ID (IANA Private Enterprise number) */ + uint32_t mfr_id; + /* + * MAC address allocated for an out-of-band management controller, to be + * retrieved through NC-SI. + */ + uint8_t oob_eth_addr[6]; +} SlirpConfig; + +/* Create a new instance of a slirp stack */ +SLIRP_EXPORT +Slirp *slirp_new(const SlirpConfig *cfg, const SlirpCb *callbacks, + void *opaque); +/* slirp_init is deprecated in favor of slirp_new */ +SLIRP_EXPORT +Slirp *slirp_init(int restricted, bool in_enabled, struct in_addr vnetwork, + struct in_addr vnetmask, struct in_addr vhost, + bool in6_enabled, struct in6_addr vprefix_addr6, + uint8_t vprefix_len, struct in6_addr vhost6, + const char *vhostname, const char *tftp_server_name, + const char *tftp_path, const char *bootfile, + struct in_addr vdhcp_start, struct in_addr vnameserver, + struct in6_addr vnameserver6, const char **vdnssearch, + const char *vdomainname, const SlirpCb *callbacks, + void *opaque); +/* Shut down an instance of a slirp stack */ +SLIRP_EXPORT +void slirp_cleanup(Slirp *slirp); + +/* This is deprecated, use slirp_pollfds_fill_socket instead. */ +SLIRP_EXPORT +void slirp_pollfds_fill(Slirp *slirp, uint32_t *timeout, + SlirpAddPollCb add_poll, void *opaque) SLIRP_DEPRECATED; + +/* This is called by the application when it is about to sleep through poll(). + * *timeout is set to the amount of virtual time (in ms) that the application intends to + * wait (UINT32_MAX if infinite). slirp_pollfds_fill updates it according to + * e.g. TCP timers, so the application knows it should sleep a smaller amount of + * time. slirp_pollfds_fill calls add_poll for each file descriptor + * that should be monitored along the sleep. The opaque pointer is passed as + * such to add_poll, and add_poll returns an index. */ +SLIRP_EXPORT +void slirp_pollfds_fill_socket(Slirp *slirp, uint32_t *timeout, + SlirpAddPollSocketCb add_poll, void *opaque); + +/* This is called by the application after sleeping, to report which file + * descriptors are available. slirp_pollfds_poll calls get_revents on each file + * descriptor, giving it the index that add_poll returned during the + * slirp_pollfds_fill call, to know whether the descriptor is available for + * read/write/etc. (SLIRP_POLL_*) + * select_error should be passed 1 if poll() returned an error. */ +SLIRP_EXPORT +void slirp_pollfds_poll(Slirp *slirp, int select_error, + SlirpGetREventsCb get_revents, void *opaque); + +/* This is called by the application when the guest emits a packet on the + * guest network, to be interpreted by slirp. */ +SLIRP_EXPORT +void slirp_input(Slirp *slirp, const uint8_t *pkt, int pkt_len); + +/* This is called by the application when a timer expires, if it provides + * the timer_new_opaque callback. It is not needed if the application only + * uses timer_new. */ +SLIRP_EXPORT +void slirp_handle_timer(Slirp *slirp, SlirpTimerId id, void *cb_opaque); + +/* These set up / remove port forwarding between a host port in the real world + * and the guest network. + * Note: guest_addr must be in network order, while guest_port must be in host + * order. + */ +SLIRP_EXPORT +int slirp_add_hostfwd(Slirp *slirp, int is_udp, struct in_addr host_addr, + int host_port, struct in_addr guest_addr, int guest_port); +SLIRP_EXPORT +int slirp_remove_hostfwd(Slirp *slirp, int is_udp, struct in_addr host_addr, + int host_port); + +#define SLIRP_HOSTFWD_UDP 1 +#define SLIRP_HOSTFWD_V6ONLY 2 +SLIRP_EXPORT +int slirp_add_hostxfwd(Slirp *slirp, + const struct sockaddr *haddr, socklen_t haddrlen, + const struct sockaddr *gaddr, socklen_t gaddrlen, + int flags); +SLIRP_EXPORT +int slirp_remove_hostxfwd(Slirp *slirp, + const struct sockaddr *haddr, socklen_t haddrlen, + int flags); + +/* Set up port forwarding between a port in the guest network and a + * command running on the host */ +SLIRP_EXPORT +int slirp_add_exec(Slirp *slirp, const char *cmdline, + struct in_addr *guest_addr, int guest_port); +/* Set up port forwarding between a port in the guest network and a + * Unix port on the host */ +SLIRP_EXPORT +int slirp_add_unix(Slirp *slirp, const char *unixsock, + struct in_addr *guest_addr, int guest_port); +/* Set up port forwarding between a port in the guest network and a + * callback that will receive the data coming from the port */ +SLIRP_EXPORT +int slirp_add_guestfwd(Slirp *slirp, SlirpWriteCb write_cb, void *opaque, + struct in_addr *guest_addr, int guest_port); + +/* TODO: rather identify a guestfwd through an opaque pointer instead of through + * the guest_addr */ + +/* This is called by the application for a guestfwd, to determine how much data + * can be received by the forwarded port through a call to slirp_socket_recv. */ +SLIRP_EXPORT +size_t slirp_socket_can_recv(Slirp *slirp, struct in_addr guest_addr, + int guest_port); +/* This is called by the application for a guestfwd, to provide the data to be + * sent on the forwarded port */ +SLIRP_EXPORT +void slirp_socket_recv(Slirp *slirp, struct in_addr guest_addr, int guest_port, + const uint8_t *buf, int size); + +/* Remove entries added by slirp_add_exec, slirp_add_unix or slirp_add_guestfwd */ +SLIRP_EXPORT +int slirp_remove_guestfwd(Slirp *slirp, struct in_addr guest_addr, + int guest_port); + +/* Return a human-readable state of the slirp stack */ +SLIRP_EXPORT +char *slirp_connection_info(Slirp *slirp); + +/* Return a human-readable state of the NDP/ARP tables */ +SLIRP_EXPORT +char *slirp_neighbor_info(Slirp *slirp); + +/* Save the slirp state through the write_cb. The opaque pointer is passed as + * such to the write_cb. */ +SLIRP_EXPORT +int slirp_state_save(Slirp *s, SlirpWriteCb write_cb, void *opaque); + +/* Returns the version of the slirp state, to be saved along the state */ +SLIRP_EXPORT +int slirp_state_version(void); + +/* Load the slirp state through the read_cb. The opaque pointer is passed as + * such to the read_cb. The version should be given as it was obtained from + * slirp_state_version when slirp_state_save was called. */ +SLIRP_EXPORT +int slirp_state_load(Slirp *s, int version_id, SlirpReadCb read_cb, + void *opaque); + +/* Return the version of the slirp implementation */ +SLIRP_EXPORT +const char *slirp_version_string(void); + +/* Debugging support: There are two methods for enabling debugging + * in libslirp: the SLIRP_DEBUG environment variable and the + * slirp_(set|reset)_debug() functions. + * + * SLIRP_DEBUG is a list of debug options separated by colons, spaces + * or commas. Valid debug options are 'call', 'misc', 'error', 'tftp' + * and 'verbose_call'. + */ + +/* Set debugging flags independently of the SLIRP_DEBUG environment + * variable. */ +SLIRP_EXPORT +void slirp_set_debug(unsigned int flags); + +/* Reset debugging flags. */ +SLIRP_EXPORT +void slirp_reset_debug(unsigned int flags); + +#if defined(_WIN32) +/* Windows utility functions: */ + +/* inet_aton() replacement that uses inet_pton(). Eliminates the dreaded + * winsock2 deprecation messages. */ +SLIRP_EXPORT +int slirp_inet_aton(const char *cp, struct in_addr *ia); +#endif + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* LIBSLIRP_H */ diff --git a/third_party/libslirp/lib/x64-windows/libslirp.def b/third_party/libslirp/lib/x64-windows/libslirp.def new file mode 100644 index 0000000..31b1ac8 --- /dev/null +++ b/third_party/libslirp/lib/x64-windows/libslirp.def @@ -0,0 +1,29 @@ +LIBRARY libslirp-0.dll +EXPORTS + slirp_add_exec + slirp_add_guestfwd + slirp_add_hostfwd + slirp_add_hostxfwd + slirp_add_unix + slirp_cleanup + slirp_connection_info + slirp_handle_timer + slirp_inet_aton + slirp_init + slirp_input + slirp_neighbor_info + slirp_new + slirp_pollfds_fill + slirp_pollfds_fill_socket + slirp_pollfds_poll + slirp_remove_guestfwd + slirp_remove_hostfwd + slirp_remove_hostxfwd + slirp_reset_debug + slirp_set_debug + slirp_socket_can_recv + slirp_socket_recv + slirp_state_load + slirp_state_save + slirp_state_version + slirp_version_string diff --git a/third_party/libslirp/lib/x64-windows/libslirp.exp b/third_party/libslirp/lib/x64-windows/libslirp.exp new file mode 100644 index 0000000..961020b Binary files /dev/null and b/third_party/libslirp/lib/x64-windows/libslirp.exp differ diff --git a/third_party/libslirp/lib/x64-windows/libslirp.lib b/third_party/libslirp/lib/x64-windows/libslirp.lib new file mode 100644 index 0000000..4b241f0 Binary files /dev/null and b/third_party/libslirp/lib/x64-windows/libslirp.lib differ diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..911dec9 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,15 @@ +{ + "name": "node-vmm-native", + "version-string": "0.1.3", + "description": "Native dependencies for the node-vmm WHP backend on Windows.", + "dependencies": [ + { + "name": "libslirp", + "platform": "windows" + }, + { + "name": "glib", + "platform": "windows" + } + ] +}