diff --git a/.github/workflows/ValidatePullRequest.yml b/.github/workflows/ValidatePullRequest.yml
index 490647f09..1a64fef5a 100644
--- a/.github/workflows/ValidatePullRequest.yml
+++ b/.github/workflows/ValidatePullRequest.yml
@@ -179,6 +179,61 @@ jobs:
config: ${{ matrix.config }}
should_regen_goldens: ${{ needs.check-golden-label.outputs.should_regen_goldens }}
+ # Build and test the macOS HVF (Hypervisor.framework) backend on Apple silicon.
+ # Self-contained: guests are built and tested on the same GitHub-hosted arm64
+ # runner (no artifact sharing with the other build-test jobs). Requires
+ # macOS 26 for the 4 KiB IPA granule API.
+ build-test-macos-hvf:
+ needs: [docs-pr, update-guest-locks]
+ # Required because update-guest-locks is skipped on non-dependabot PRs,
+ # and a skipped dependency transitively skips all downstream jobs.
+ # See: https://github.com/actions/runner/issues/2205
+ if: ${{ !cancelled() && !failure() }}
+ timeout-minutes: 45
+ runs-on: macos-26
+ env:
+ # cargo-hyperlight builds a custom target/sysroot; run everything through
+ # the rustup proxy with the pinned toolchain (newer rustc rejects custom
+ # target specs, Homebrew rustc shadows the proxy otherwise).
+ RUSTUP_TOOLCHAIN: "1.94"
+ RUSTC: ~/.cargo/bin/rustc
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ submodules: true
+
+ - uses: hyperlight-dev/ci-setup-workflow@f6bd9cc86d0737976d2128c8b8ced8edc017cbb4 # v1.9.0
+ with:
+ rust-toolchain: "1.94"
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # crates.io cargo-hyperlight 0.1.13 does not compile on macOS (the ar
+ # detection fix is only on main). Pin the git rev; switch back to the
+ # crates.io pin (just ensure-cargo-hyperlight) once a release > 0.1.13
+ # ships. The Justfile's ensure-cargo-hyperlight accepts this binary
+ # because it reports version 0.1.13.
+ - name: Install cargo-hyperlight from git
+ run: cargo install --locked --git https://github.com/hyperlight-dev/cargo-hyperlight --rev 33384c0c4ed9dea4f0525943809fc444c41a27df cargo-hyperlight
+
+ # llvm-ar/llvm-ranlib are needed to archive the picolibc objects for C
+ # guests; Xcode's ar silently drops ELF members.
+ - name: Install LLVM
+ run: brew install llvm
+
+ - name: Build Rust and C guests
+ run: |
+ just build-and-move-rust-guests
+ just build-c-guests debug
+ just move-c-guests debug
+
+ # Builds, ad-hoc codesigns (com.apple.security.hypervisor) and runs the
+ # hyperlight-host lib + integration test binaries with the hvf feature.
+ # Snapshot-golden verification is a no-op here until the hvf goldens are
+ # published to the golden registry (see RegenSnapshotGoldens.yml).
+ - name: Test with the HVF backend
+ run: just test-hvf debug
+
# Run examples - needs guest artifacts, runs in parallel with build-test
run-examples:
needs:
@@ -255,6 +310,7 @@ jobs:
- code-checks
- check-golden-label
- build-test
+ - build-test-macos-hvf
- run-examples
- fuzzing
- spelling
diff --git a/.github/workflows/dep_code_checks.yml b/.github/workflows/dep_code_checks.yml
index 6398adb2b..d654fdc10 100644
--- a/.github/workflows/dep_code_checks.yml
+++ b/.github/workflows/dep_code_checks.yml
@@ -83,7 +83,7 @@ jobs:
run: just clippy-exhaustive release
- name: Verify MSRV
- run: ./dev/verify-msrv.sh hyperlight-host hyperlight-guest hyperlight-guest-bin hyperlight-common
+ run: ./dev/verify-msrv.sh hyperlight-host hyperlight-guest hyperlight-guest-bin hyperlight-common hyperlight-hvf
- name: Check cargo features compile
run: just check
diff --git a/Cargo.lock b/Cargo.lock
index 7db5b5dd0..08e6b60d7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -113,6 +113,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "applevisor-sys"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14ae37676989768c5b21414857b782a5e513d93129cda34854f5f78c423a63be"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "arbitrary"
version = "1.4.2"
@@ -1663,6 +1672,7 @@ name = "hyperlight-host"
version = "0.16.0"
dependencies = [
"anyhow",
+ "applevisor-sys",
"bitflags 2.13.1",
"blake3",
"built",
@@ -1685,6 +1695,7 @@ dependencies = [
"hyperlight-common",
"hyperlight-component-macro",
"hyperlight-guest-tracing",
+ "hyperlight-hvf",
"hyperlight-testing",
"iced-x86",
"kvm-bindings",
@@ -1732,6 +1743,18 @@ dependencies = [
"windows-version",
]
+[[package]]
+name = "hyperlight-hvf"
+version = "0.16.0"
+dependencies = [
+ "applevisor-sys",
+ "hyperlight-common",
+ "libc",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
[[package]]
name = "hyperlight-libc"
version = "0.16.0"
diff --git a/Cargo.toml b/Cargo.toml
index b82515ead..16fa78743 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ members = [
"src/hyperlight_host",
"src/hyperlight_guest_capi",
"src/hyperlight_guest_tracing",
+ "src/hyperlight_hvf",
"src/hyperlight_testing",
"fuzz",
"src/hyperlight_guest_bin",
@@ -43,6 +44,7 @@ hyperlight-guest-bin = { path = "src/hyperlight_guest_bin", version = "0.16.0",
hyperlight-guest-macro = { path = "src/hyperlight_guest_macro", version = "0.16.0", default-features = false }
hyperlight-testing = { path = "src/hyperlight_testing", default-features = false }
hyperlight-guest-tracing = { path = "src/hyperlight_guest_tracing", version = "0.16.0", default-features = false }
+hyperlight-hvf = { path = "src/hyperlight_hvf", version = "0.16.0", default-features = false }
hyperlight-libc = { path = "src/hyperlight_libc", version = "0.16.0", default-features = false }
hyperlight-component-util = { path = "src/hyperlight_component_util", version = "0.16.0", default-features = false }
hyperlight-component-macro = { path = "src/hyperlight_component_macro", version = "0.16.0", default-features = false }
diff --git a/Justfile b/Justfile
index 5112f2f88..e4b76bc9e 100644
--- a/Justfile
+++ b/Justfile
@@ -8,7 +8,7 @@ bin-suffix := if os() == "windows" { ".bat" } else { ".sh" }
nightly-toolchain := "nightly-2026-02-27"
# Pinned cargo-hyperlight version used to build the guest sysroot. Keep this in
# lockstep with the version pinned in flake.nix.
-cargo-hyperlight-version := "0.1.12"
+cargo-hyperlight-version := "0.1.13"
################
### cross-rs ###
@@ -247,6 +247,24 @@ test-isolated target=default-target features="" :
@# metrics tests
{{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- metrics::tests::test_metrics_are_emitted --exact
+# Ad-hoc codesigns hyperlight test binaries with the Hypervisor.framework
+# entitlement and runs the host test suite with the HVF backend (macOS/aarch64).
+# Guest binaries must already be built (see build-rust-guests).
+# NOTE: the binaries are executed directly, not via `cargo test` — codesigning
+# modifies them, which cargo would treat as a rebuild trigger, discarding the
+# signature (and unsigned binaries get HV_DENIED from Hypervisor.framework).
+test-hvf target=default-target:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ profile={{ if target == "debug" { "dev" } else { target } }}
+ out=$({{ cargo-cmd }} test -p hyperlight-host --no-default-features -F hvf --profile=$profile --no-run 2>&1)
+ bins=$(echo "$out" | sed -n 's/^ *Executable .*(\(.*\))$/\1/p')
+ for bin in $bins; do
+ codesign --sign - --entitlements {{ justfile_directory() }}/dev/hvf-entitlements.plist --force "$bin"
+ echo "=== $bin"
+ "$bin"
+ done
+
# runs integration tests
test-integration target=default-target features="":
@# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests)
diff --git a/README.md b/README.md
index ade86b6d2..af6655797 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
Hyperlight lets you safely run untrusted code inside hypervisor-isolated micro VMs that spin up in milliseconds, with guest function calls completing in microseconds. You embed it as a library in your Rust application, hand it a guest binary, and call functions across the VM boundary as naturally as calling a local function. To minimize startup time and memory footprint, there's no guest kernel or OS. Guests are purpose-built using the Hyperlight guest library.
-- Supports [KVM](https://linux-kvm.org/page/Main_Page), [MSHV](https://github.com/rust-vmm/mshv), and [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform)
+- Supports [KVM](https://linux-kvm.org/page/Main_Page), [MSHV](https://github.com/rust-vmm/mshv), [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform), and [Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (macOS on Apple silicon)
- No kernel or OS in the VM. Guests are regular ELF binaries written in `no_std` Rust or C
- Host and guest communicate through typed function calls
- Guests are sandboxed by default with no access to the host filesystem, network, etc.
diff --git a/dev/hvf-entitlements.plist b/dev/hvf-entitlements.plist
new file mode 100644
index 000000000..154f3308e
--- /dev/null
+++ b/dev/hvf-entitlements.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ com.apple.security.hypervisor
+
+
+
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 09c9e242a..e5ae00023 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -9,6 +9,7 @@ These are the minimum requirements to use Hyperlight as a library in your own pr
- **A supported hypervisor:**
- Linux: [KVM](https://help.ubuntu.com/community/KVM/Installation) or Microsoft Hypervisor (MSHV)
- Windows: [Windows Hypervisor Platform](https://docs.microsoft.com/en-us/virtualization/api/#windows-hypervisor-platform) (WHP)
+ - macOS (Apple silicon): [Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (HVF)
- **[Rust](https://www.rust-lang.org/tools/install)**, installed via `rustup`.
- **Platform build tools** (provides the C linker required by Rust):
- Ubuntu/Debian: `sudo apt install build-essential`
@@ -93,6 +94,65 @@ Requires Windows 11 Pro/Enterprise/Education or Windows Server 2025 or later.
sudo dnf install clang
```
+### macOS
+
+Requires macOS 26 or later on Apple silicon. Hyperlight uses
+[Hypervisor.framework](https://developer.apple.com/documentation/hypervisor) (HVF)
+via the `hvf` cargo feature of `hyperlight-host`, which is not enabled by default:
+
+```toml
+hyperlight-host = { version = "0.16.0", features = ["hvf"] }
+```
+
+Two macOS-specific things to know:
+
+1. **Entitlement.** Every executable that creates a VM — your application, and
+ Hyperlight's per-sandbox surrogate processes — must be signed with the
+ `com.apple.security.hypervisor` entitlement. Ad-hoc signing is sufficient for
+ development. Save this as `hvf-entitlements.plist`:
+
+ ```xml
+
+
+
+
+ com.apple.security.hypervisor
+
+
+
+ ```
+
+ and sign your binary after every build:
+
+ ```sh
+ codesign --sign - --entitlements hvf-entitlements.plist --force target/debug/your-app
+ ```
+
+ Hyperlight's surrogate binaries are extracted and ad-hoc signed automatically
+ at runtime; only your own executables need manual signing. Without the
+ entitlement, sandbox creation fails with `NoHypervisorFound`.
+
+2. **One VM per process.** Hypervisor.framework allows only a single VM per
+ process, so Hyperlight runs each sandbox's VM in a small surrogate helper
+ process (see [Hyperlight Surrogate](./hyperlight-surrogate-development-notes.md)).
+ This is transparent in normal use. Setting `HYPERLIGHT_MAX_SURROGATES=0`
+ disables surrogates and selects the in-process backend, which limits the
+ process to one live sandbox at a time.
+
+If building guest binaries on macOS:
+
+- Use `rustup` for your Rust installation — guest builds require the toolchain
+ pinned in `rust-toolchain.toml`, and distributions that shadow it (e.g.
+ Homebrew Rust) do not work.
+- Install LLVM (`brew install llvm`); Xcode's `ar` cannot archive the ELF
+ objects used when linking C guests.
+- Until a `cargo-hyperlight` release newer than 0.1.13 is published, install it
+ from git (0.1.13 does not compile on macOS):
+
+ ```sh
+ cargo install --locked --git https://github.com/hyperlight-dev/cargo-hyperlight cargo-hyperlight
+ ```
+
### WSL2
Follow the Ubuntu/Debian instructions above inside your WSL2 instance. WSL2 uses KVM.
diff --git a/docs/hyperlight-surrogate-development-notes.md b/docs/hyperlight-surrogate-development-notes.md
index 431cc85da..23c263337 100644
--- a/docs/hyperlight-surrogate-development-notes.md
+++ b/docs/hyperlight-surrogate-development-notes.md
@@ -9,3 +9,14 @@ These surrogate processes are managed by the host via the [surrogate_process_man
> **Note:** `HYPERLIGHT_MAX_SURROGATES` is authoritative — if `HYPERLIGHT_INITIAL_SURROGATES` exceeds it, the initial count is silently clamped down to the maximum. For example, setting only `HYPERLIGHT_MAX_SURROGATES=256` limits both the initial pool and the ceiling to 256.
`hyperlight_surrogate.exe` gets built during `hyperlight-host`'s build script, gets embedded into the `hyperlight-host` Rust library via [rust-embed](https://crates.io/crates/rust-embed), and is extracted at runtime next to the executable when the surrogate process manager is initialized. The extracted filename includes a short BLAKE3 hash of the binary content (e.g., `hyperlight_surrogate_a1b2c3d4.exe`) so that multiple hyperlight versions can coexist without file-deletion races.
+
+### HVF surrogate (macOS)
+
+On macOS with Hypervisor.framework (HVF) the constraint is similar — only one VM per process — but the implementation is fundamentally different. HVF binds a VM to the process that created it and offers no cross-process mapping API like `WHvMapGpaRange2`, so `hvf_surrogate` is a real server process rather than an empty suspended one:
+
+- Each sandbox's VM and vCPU are created, run, and destroyed inside a pooled `hvf_surrogate` process; the host is an IPC client (length-prefixed JSON frames over an inherited unix socket pair; the protocol is defined in the `hyperlight-hvf` crate's `proto` module).
+- Guest memory is never copied: the host allocates it from named POSIX shm objects (or file-backed regions), and the surrogate maps the same objects into its own address space and then into the guest.
+- The surrogate's main thread is the vCPU thread — HVF requires vCPU creation, register access, and `hv_vcpu_run` to happen on a single thread. A reader thread dispatches requests and answers `Cancel` immediately via `hv_vcpus_exit` (the only HVF call allowed from a non-owning thread); a writer thread serializes all responses.
+- The extracted surrogate binary is ad-hoc codesigned with the `com.apple.security.hypervisor` entitlement at extraction time, since Hypervisor.framework refuses to create VMs from unentitled binaries.
+
+The pool is managed by `hvf_surrogate_manager` and honors the same `HYPERLIGHT_INITIAL_SURROGATES` / `HYPERLIGHT_MAX_SURROGATES` environment variables (defaulting to 0 initial / 128 max — HVF currently allows 127 concurrent VMs system-wide, so exceeding the pool fails fast rather than surprising the guest). Setting `HYPERLIGHT_MAX_SURROGATES=0` disables surrogates and selects the direct in-process backend, which limits the process to a single live sandbox at a time.
diff --git a/hack/clippy-package-features.sh b/hack/clippy-package-features.sh
index 49b6716b8..35f055df4 100755
--- a/hack/clippy-package-features.sh
+++ b/hack/clippy-package-features.sh
@@ -36,7 +36,7 @@ fi
EXCLUDED_FEATURES=("${REQUIRED_FEATURES[@]}")
-if [[ -n "${TARGET_TRIPLE}" && "$TARGET_TRIPLE" =~ aarch64 ]] || [[ -z "${TARGET_TRIPLE}" && "$(uname -m)" = "aarch64" ]]; then
+if [[ -n "${TARGET_TRIPLE}" && "$TARGET_TRIPLE" =~ aarch64 ]] || [[ -z "${TARGET_TRIPLE}" && "$(uname -m)" =~ ^(aarch64|arm64)$ ]]; then
EXCLUDED_FEATURES+=("crashdump" "trace_guest" "mem_profile" "hw-interrupts" "gdb" "i686-guest" "nanvix-unstable")
fi
diff --git a/src/hyperlight_host/Cargo.toml b/src/hyperlight_host/Cargo.toml
index 90f862d69..2b748fe9c 100644
--- a/src/hyperlight_host/Cargo.toml
+++ b/src/hyperlight_host/Cargo.toml
@@ -78,12 +78,17 @@ rust-embed = { version = "8.11.0", features = ["debug-embed", "include-exclude",
windows-version = "0.1"
lazy_static = "1.4.0"
-[target.'cfg(unix)'.dependencies]
+[target.'cfg(target_os = "linux")'.dependencies]
kvm-bindings = { version = "0.14", features = ["fam-wrappers"], optional = true }
kvm-ioctls = { version = "0.25", optional = true }
mshv-bindings = { version = "0.6", optional = true }
mshv-ioctls = { version = "0.6", optional = true}
+[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]
+applevisor-sys = { version = "1.0", optional = true }
+hyperlight-hvf = { workspace = true, optional = true }
+rust-embed = { version = "8.11.0", optional = true, features = ["debug-embed", "include-exclude", "interpolate-folder-path"] }
+
[dev-dependencies]
uuid = { version = "1.23.3", features = ["v4"] }
signal-hook-registry = "1.4.8"
@@ -136,6 +141,7 @@ trace_guest = ["dep:opentelemetry", "dep:tracing-opentelemetry", "dep:hyperlight
mem_profile = [ "trace_guest", "dep:framehop", "dep:fallible-iterator", "hyperlight-common/mem_profile" ]
kvm = ["dep:kvm-bindings", "dep:kvm-ioctls"]
mshv3 = ["dep:mshv-bindings", "dep:mshv-ioctls"]
+hvf = ["dep:applevisor-sys", "dep:hyperlight-hvf", "dep:rust-embed"]
hw-interrupts = []
# This enables easy debug in the guest
gdb = ["dep:gdbstub", "dep:gdbstub_arch"]
diff --git a/src/hyperlight_host/build.rs b/src/hyperlight_host/build.rs
index 57a06e4bb..be3cbe840 100644
--- a/src/hyperlight_host/build.rs
+++ b/src/hyperlight_host/build.rs
@@ -101,6 +101,92 @@ fn main() -> Result<()> {
);
}
+ // aarch64 macOS with the `hvf` feature: build the HVF surrogate server
+ // binary and export its location (plus the hypervisor entitlement plist)
+ // so the host library can embed both and extract/codesign them at
+ // runtime. Mirrors the Windows surrogate pipeline above.
+ if std::env::var("CARGO_CFG_TARGET_OS")? == "macos"
+ && std::env::var("CARGO_CFG_TARGET_ARCH")? == "aarch64"
+ && std::env::var("CARGO_FEATURE_HVF").is_ok()
+ {
+ println!("cargo:rerun-if-changed=src/hvf_surrogate/src/main.rs");
+ println!("cargo:rerun-if-changed=src/hvf_surrogate/build.rs");
+ println!("cargo:rerun-if-changed=src/hvf_surrogate/Cargo.toml_temp_name");
+ println!("cargo:rerun-if-changed=../../dev/hvf-entitlements.plist");
+
+ // Copy the hvf_surrogate sources into a temp directory because we
+ // cannot include a file named `Cargo.toml` inside this package (see
+ // the Windows branch above). The copied tree lives outside the
+ // workspace, so the path dependency on `hyperlight-hvf` is rewritten
+ // to an absolute path.
+ let out_dir = std::env::var("OUT_DIR")?;
+ let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
+ std::fs::create_dir_all(format!("{out_dir}/hvf_surrogate/src"))?;
+ std::fs::copy(
+ format!("{manifest_dir}/src/hvf_surrogate/src/main.rs"),
+ format!("{out_dir}/hvf_surrogate/src/main.rs"),
+ )?;
+ std::fs::copy(
+ format!("{manifest_dir}/src/hvf_surrogate/build.rs"),
+ format!("{out_dir}/hvf_surrogate/build.rs"),
+ )?;
+ let hvf_path = std::fs::canonicalize(format!("{manifest_dir}/../hyperlight_hvf"))?;
+ let manifest = std::fs::read_to_string(format!(
+ "{manifest_dir}/src/hvf_surrogate/Cargo.toml_temp_name"
+ ))?
+ .replace("@HYPERLIGHT_HVF_PATH@", &hvf_path.to_string_lossy());
+ let target_manifest_path = format!("{out_dir}/hvf_surrogate/Cargo.toml");
+ std::fs::write(&target_manifest_path, manifest)?;
+
+ // The entitlements plist is embedded in the host library and
+ // re-applied with `codesign` when the surrogate binary is extracted.
+ let entitlements_path = format!("{out_dir}/hvf-entitlements.plist");
+ std::fs::copy(
+ format!("{manifest_dir}/../../dev/hvf-entitlements.plist"),
+ &entitlements_path,
+ )?;
+
+ // Note: CARGO_TARGET_DIR cannot be the same as the CARGO_TARGET_DIR
+ // for hyperlight-host, otherwise the build script will hang. Using a
+ // sub directory works tho! (see the Windows branch above)
+ let target_dir = std::path::PathBuf::from(&out_dir).join("../../hvf-surr");
+
+ let profile = std::env::var("PROFILE")?;
+ let build_profile = if profile.to_lowercase() == "debug" {
+ "dev".to_string()
+ } else {
+ profile.clone()
+ };
+
+ let target_triple = std::env::var("TARGET")?;
+
+ let status = std::process::Command::new("cargo")
+ .env("CARGO_TARGET_DIR", &target_dir)
+ .arg("build")
+ .arg("--manifest-path")
+ .arg(&target_manifest_path)
+ .arg("--target")
+ .arg(&target_triple)
+ .arg("--profile")
+ .arg(build_profile)
+ .status()
+ .expect("Failed to execute cargo build for hvf_surrogate");
+
+ if !status.success() {
+ panic!("Failed to build hvf_surrogate");
+ }
+
+ let surrogate_binary_dir = std::path::PathBuf::from(&target_dir)
+ .join(&target_triple)
+ .join(&profile);
+
+ println!(
+ "cargo:rustc-env=HYPERLIGHT_HVF_SURROGATE_DIR={}",
+ &surrogate_binary_dir.display()
+ );
+ println!("cargo:rustc-env=HYPERLIGHT_HVF_ENTITLEMENTS_PATH={entitlements_path}");
+ }
+
// Makes #[cfg(kvm)] == #[cfg(all(feature = "kvm", target_os = "linux"))]
// Essentially the kvm and mshv3 features are ignored on windows as long as you use #[cfg(kvm)] and not #[cfg(feature = "kvm")].
// You should never use #[cfg(feature = "kvm")] or #[cfg(feature = "mshv3")] in the codebase.
@@ -108,6 +194,7 @@ fn main() -> Result<()> {
gdb: { all(feature = "gdb", debug_assertions, target_arch = "x86_64") },
kvm: { all(feature = "kvm", target_os = "linux") },
mshv3: { all(feature = "mshv3", target_os = "linux") },
+ hvf: { all(feature = "hvf", target_os = "macos", target_arch = "aarch64") },
crashdump: { all(feature = "crashdump", target_arch = "x86_64") },
// print_debug feature is aliased with debug_assertions to make it only available in debug-builds.
print_debug: { all(feature = "print_debug", debug_assertions) },
diff --git a/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name b/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name
new file mode 100644
index 000000000..a2382e763
--- /dev/null
+++ b/src/hyperlight_host/src/hvf_surrogate/Cargo.toml_temp_name
@@ -0,0 +1,31 @@
+[package]
+name = "hvf_surrogate"
+version = "0.1.0"
+edition = "2021"
+
+[workspace]
+
+[dependencies]
+# This crate is copied into OUT_DIR and built by hyperlight-host's build.rs,
+# so a relative path would not resolve; the build script rewrites the
+# @HYPERLIGHT_HVF_PATH@ placeholder to the absolute path of the
+# hyperlight_hvf crate.
+hyperlight-hvf = { path = "@HYPERLIGHT_HVF_PATH@" }
+libc = "0.2.186"
+
+[profile.dev]
+panic = "abort"
+
+[profile.release]
+panic = "abort"
+
+[profile.release-with-debug]
+inherits = "release"
+debug = true
+panic = "abort"
+
+# Note - This crate gets built during the build.rs script for the
+# hyperlight-host crate (aarch64 macOS + `hvf` feature only). Cargo package
+# does not allow for files named 'Cargo.toml' to be included in a crate so
+# we'll rename during the build.rs script so this file can be included with
+# the source for the hvf_surrogate binary in the hyperlight-host crate.
diff --git a/src/hyperlight_host/src/hvf_surrogate/build.rs b/src/hyperlight_host/src/hvf_surrogate/build.rs
new file mode 100644
index 000000000..246587aaa
--- /dev/null
+++ b/src/hyperlight_host/src/hvf_surrogate/build.rs
@@ -0,0 +1,23 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+fn main() {
+ let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
+ let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
+ if target_os != "macos" || target_arch != "aarch64" {
+ panic!("hvf_surrogate can only be built for aarch64 macOS targets");
+ }
+}
diff --git a/src/hyperlight_host/src/hvf_surrogate/src/main.rs b/src/hyperlight_host/src/hvf_surrogate/src/main.rs
new file mode 100644
index 000000000..05d4a14a2
--- /dev/null
+++ b/src/hyperlight_host/src/hvf_surrogate/src/main.rs
@@ -0,0 +1,459 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! HVF surrogate server.
+//!
+//! HVF allows only one VM per process, so a host that runs multiple
+//! concurrent sandboxes delegates each VM to a surrogate process. This
+//! binary is that server: `hyperlight-host` spawns it with an inherited
+//! `SOCK_STREAM` unix socket fd in `HVF_SURROGATE_FD` and speaks the
+//! [`hyperlight_hvf::proto`] protocol to it.
+//!
+//! Threading (HVF requires vCPU create/run/register access on a single
+//! thread; `hv_vcpus_exit` may be called from any thread):
+//!
+//! - The **main thread** is the vCPU thread: it owns the VM and executes
+//! every stateful request.
+//! - A **reader thread** reads request frames from the socket. `Cancel` is
+//! handled directly there and gets no response; every other request is
+//! forwarded to the main thread.
+//! - A **writer thread** writes response frames. Responses are written by
+//! exactly one thread, and — unlike folding the writer role into the
+//! reader thread — the reader never blocks waiting for a response, so a
+//! `Cancel` that arrives while a `RunVcpu` is executing is always seen.
+//!
+//! Exit codes: `0` on clean EOF (the host went away), `2` on protocol
+//! errors (bad handshake, malformed frame, socket failure).
+
+use std::collections::HashMap;
+use std::ffi::{CString, c_void};
+use std::io;
+use std::os::unix::io::FromRawFd;
+use std::os::unix::net::UnixStream;
+use std::sync::Arc;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::mpsc::{Receiver, Sender, channel};
+
+use hyperlight_hvf::core::{self, Perms, Vm};
+use hyperlight_hvf::proto::{self, Backing, PROTO_VERSION, Request, Response};
+
+/// Environment variable carrying the inherited control-socket fd.
+const FD_ENV_VAR: &str = "HVF_SURROGATE_FD";
+
+/// Exit code for protocol errors (bad handshake, malformed frame).
+const EXIT_PROTOCOL_ERROR: i32 = 2;
+
+/// A live `mmap` of a memory object into this process, unmapped on drop.
+struct Mapping {
+ va: usize,
+ size: usize,
+}
+
+impl Drop for Mapping {
+ fn drop(&mut self) {
+ // SAFETY: `va`/`size` describe a live mapping created in
+ // `map_memory`, and this drop runs at most once.
+ unsafe {
+ libc::munmap(self.va as *mut c_void, self.size);
+ }
+ }
+}
+
+fn main() {
+ let Some(fd) = std::env::var(FD_ENV_VAR).ok().and_then(|v| v.parse().ok()) else {
+ eprintln!("hvf_surrogate: {FD_ENV_VAR} is not set or not a valid fd");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ };
+ // SAFETY: the parent spawns us with this socket fd open (created
+ // without CLOEXEC so it survives `exec`).
+ let socket = unsafe { UnixStream::from_raw_fd(fd) };
+ let writer_socket = match socket.try_clone() {
+ Ok(s) => s,
+ Err(e) => {
+ eprintln!("hvf_surrogate: failed to clone socket: {e}");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ };
+
+ // Requests (minus Cancel) flow reader -> main; responses flow
+ // main -> writer.
+ let (req_tx, req_rx) = channel::();
+ let (resp_tx, resp_rx) = channel::();
+
+ std::thread::spawn(move || writer_thread(writer_socket, resp_rx));
+
+ // The current vCPU id **plus one**, shared with the reader thread so it
+ // can cancel a running vCPU. 0 means "no vCPU" (HVF vCPU ids are
+ // zero-based, so the raw id cannot double as the sentinel).
+ let vcpu_id = Arc::new(AtomicU64::new(0));
+ let reader_vcpu_id = Arc::clone(&vcpu_id);
+ std::thread::spawn(move || reader_thread(socket, req_tx, reader_vcpu_id));
+
+ vcpu_thread(req_rx, resp_tx, &vcpu_id);
+}
+
+/// Main-thread loop: owns the VM and executes every stateful request.
+fn vcpu_thread(rx: Receiver, resp_tx: Sender, vcpu_id: &AtomicU64) {
+ let mut vm: Option = None;
+ let mut mappings: HashMap = HashMap::new();
+ while let Ok(req) = rx.recv() {
+ let resp = handle_request(req, &mut vm, &mut mappings, vcpu_id);
+ if resp_tx.send(resp).is_err() {
+ // The writer thread is gone; nothing useful left to do.
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ }
+ // The reader thread exits the whole process on EOF, so reaching this
+ // point means the channel closed unexpectedly; leave quietly.
+}
+
+/// Reads request frames from the socket. Completes the handshake, then
+/// relays requests to the vCPU thread; `Cancel` is handled locally with
+/// no response.
+fn reader_thread(mut socket: UnixStream, req_tx: Sender, vcpu_id: Arc) {
+ handshake(&mut socket);
+ loop {
+ match proto::read_frame::(&mut socket) {
+ Ok(Some(Request::Cancel)) => {
+ let id = vcpu_id.load(Ordering::Acquire);
+ if id != 0 {
+ // A stale cancel of an idle/destroyed vCPU fails
+ // harmlessly (ids are kernel-validated).
+ let _ = core::cancel(id - 1);
+ }
+ }
+ Ok(Some(req)) => {
+ if req_tx.send(req).is_err() {
+ // The vCPU thread is gone.
+ std::process::exit(0);
+ }
+ }
+ Ok(None) => {
+ // Clean EOF: the host is gone.
+ std::process::exit(0);
+ }
+ Err(e) => {
+ eprintln!("hvf_surrogate: malformed frame: {e}");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ }
+ }
+}
+
+/// Writes response frames; the only thread that writes to the socket after
+/// the handshake (which the reader thread completes before relaying any
+/// request).
+fn writer_thread(mut socket: UnixStream, rx: Receiver) {
+ while let Ok(resp) = rx.recv() {
+ if let Err(e) = proto::write_frame(&mut socket, &resp) {
+ eprintln!("hvf_surrogate: failed to write response: {e}");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ }
+}
+
+/// The protocol handshake: the first frame must be `Hello` with a matching
+/// protocol version.
+fn handshake(socket: &mut UnixStream) {
+ match proto::read_frame::(socket) {
+ Ok(Some(Request::Hello { version })) if version == PROTO_VERSION => {
+ if let Err(e) = proto::write_frame(
+ socket,
+ &Response::Hello {
+ version: PROTO_VERSION,
+ },
+ ) {
+ eprintln!("hvf_surrogate: failed to write handshake response: {e}");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ }
+ Ok(Some(Request::Hello { version })) => {
+ let _ = proto::write_frame(
+ socket,
+ &Response::Err(format!(
+ "protocol version mismatch: client {version}, server {PROTO_VERSION}"
+ )),
+ );
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ Ok(Some(_)) => {
+ eprintln!("hvf_surrogate: expected Hello as the first frame");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ Ok(None) => {
+ // The host went away before the handshake.
+ std::process::exit(0);
+ }
+ Err(e) => {
+ eprintln!("hvf_surrogate: malformed handshake frame: {e}");
+ std::process::exit(EXIT_PROTOCOL_ERROR);
+ }
+ }
+}
+
+fn err_no_vm() -> Response {
+ Response::Err("no VM (CreateVm first)".to_string())
+}
+
+/// Execute one request on the vCPU thread.
+fn handle_request(
+ req: Request,
+ vm: &mut Option,
+ mappings: &mut HashMap,
+ vcpu_id: &AtomicU64,
+) -> Response {
+ match req {
+ // A `Hello` after the initial handshake marks a new client session
+ // (the process was returned to the pool and re-acquired): reset any
+ // state left over from the previous checkout. A version mismatch
+ // here is answered with `Err` but keeps the process alive — the
+ // strict `exit(2)` applies to the initial handshake only.
+ Request::Hello { version } => {
+ if let Some(v) = vm.take() {
+ drop(v);
+ vcpu_id.store(0, Ordering::Release);
+ }
+ mappings.clear();
+ if version == PROTO_VERSION {
+ Response::Hello {
+ version: PROTO_VERSION,
+ }
+ } else {
+ Response::Err(format!(
+ "protocol version mismatch: client {version}, server {PROTO_VERSION}"
+ ))
+ }
+ }
+ Request::Cancel => Response::Err("Cancel is handled out-of-band".to_string()),
+ Request::CreateVm => {
+ if vm.is_some() {
+ return Response::Err("VM already exists".to_string());
+ }
+ match Vm::new() {
+ Ok(v) => {
+ // +1: vCPU ids are zero-based, 0 is the "no vCPU"
+ // sentinel.
+ vcpu_id.store(v.vcpu_id() + 1, Ordering::Release);
+ *vm = Some(v);
+ Response::Ok
+ }
+ Err(e) => Response::Err(e.to_string()),
+ }
+ }
+ Request::DestroyVm => {
+ if let Some(v) = vm.take() {
+ drop(v);
+ vcpu_id.store(0, Ordering::Release);
+ }
+ // Guest mappings died with the VM; release the process mappings.
+ mappings.clear();
+ Response::Ok
+ }
+ Request::MapMemory {
+ slot,
+ gpa,
+ size,
+ perms,
+ backing,
+ } => {
+ let Some(vm) = vm.as_mut() else {
+ return err_no_vm();
+ };
+ map_memory(vm, mappings, slot, gpa, size as usize, perms, backing)
+ }
+ Request::UnmapMemory { slot, gpa, size } => {
+ let Some(vm) = vm.as_mut() else {
+ return err_no_vm();
+ };
+ match vm.unmap_memory(slot, gpa, size as usize) {
+ Ok(()) => {
+ // Dropping the Mapping munmaps after the guest unmap.
+ mappings.remove(&slot);
+ Response::Ok
+ }
+ Err(e) => Response::Err(e.to_string()),
+ }
+ }
+ Request::RunVcpu => {
+ let Some(vm) = vm.as_mut() else {
+ return err_no_vm();
+ };
+ match vm.run_vcpu() {
+ Ok(exit) => Response::Exit(exit),
+ Err(e) => Response::Err(e.to_string()),
+ }
+ }
+ Request::GetRegs => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.regs()
+ .map(|r| Response::Regs(Box::new(r)))
+ .unwrap_or_else(err)
+ }
+ Request::SetRegs(regs) => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.set_regs(regs.as_ref())
+ .map(|()| Response::Ok)
+ .unwrap_or_else(err)
+ }
+ Request::GetFpu => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.fpu()
+ .map(|f| Response::Fpu(Box::new(f)))
+ .unwrap_or_else(err)
+ }
+ Request::SetFpu(fpu) => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.set_fpu(fpu.as_ref())
+ .map(|()| Response::Ok)
+ .unwrap_or_else(err)
+ }
+ Request::GetSregs => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.sregs().map(Response::Sregs).unwrap_or_else(err)
+ }
+ Request::SetSregs(sregs) => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.set_sregs(&sregs)
+ .map(|()| Response::Ok)
+ .unwrap_or_else(err)
+ }
+ Request::ResetVcpu => {
+ let Some(vm) = vm.as_ref() else {
+ return err_no_vm();
+ };
+ vm.reset_vcpu().map(|()| Response::Ok).unwrap_or_else(err)
+ }
+ }
+}
+
+fn err(e: core::HvfError) -> Response {
+ Response::Err(e.to_string())
+}
+
+/// `mmap` the backing object and map it into the guest, replacing any
+/// existing mapping for `slot`.
+fn map_memory(
+ vm: &mut Vm,
+ mappings: &mut HashMap,
+ slot: u32,
+ gpa: u64,
+ size: usize,
+ perms: Perms,
+ backing: Backing,
+) -> Response {
+ let va = match mmap_backing(&backing, size) {
+ Ok(va) => va,
+ Err(e) => return Response::Err(format!("failed to map backing object: {e}")),
+ };
+ // SAFETY: [va, va + size) is a live mapping owned by this process; on
+ // success it is stored in `mappings` and only munmapped after
+ // `vm.unmap_memory` (or VM destruction).
+ match unsafe { vm.map_memory(slot, gpa, va, size, perms) } {
+ Ok(()) => {
+ // On slot replacement the old Mapping is munmapped here, after
+ // `map_memory` already unmapped the old slot from the guest.
+ mappings.insert(slot, Mapping { va, size });
+ Response::Ok
+ }
+ Err(e) => {
+ // SAFETY: the mapping was created above and is not in use.
+ unsafe {
+ libc::munmap(va as *mut c_void, size);
+ }
+ Response::Err(e.to_string())
+ }
+ }
+}
+
+/// `mmap` `size` bytes of the backing object into this process and return
+/// the base address.
+fn mmap_backing(backing: &Backing, size: usize) -> io::Result {
+ match backing {
+ Backing::Shm { name, offset } => {
+ let c_name = CString::new(name.as_str())
+ .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
+ // SAFETY: `c_name` is a valid NUL-terminated string.
+ let fd = unsafe { libc::shm_open(c_name.as_ptr(), libc::O_RDWR, 0) };
+ if fd < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ // NOTE: the name is NOT unlinked here. The host owns the
+ // object's name lifecycle (it unlinks when its own backing
+ // drops) and may legitimately ask us to map the same object
+ // again (e.g. snapshot restore remaps); unlinking at map time
+ // would make those later `shm_open`s fail with ENOENT. Our
+ // mmap keeps the object alive even after the name is gone.
+ // SAFETY: `fd` is a live shm object descriptor and `offset`
+ // lies within it; the result is checked against MAP_FAILED.
+ let va = unsafe {
+ libc::mmap(
+ std::ptr::null_mut(),
+ size,
+ libc::PROT_READ | libc::PROT_WRITE,
+ libc::MAP_SHARED,
+ fd,
+ *offset as libc::off_t,
+ )
+ };
+ // SAFETY: `fd` is a live descriptor no longer needed once the
+ // mmap exists (or failed).
+ unsafe {
+ libc::close(fd);
+ }
+ if va == libc::MAP_FAILED {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(va as usize)
+ }
+ Backing::File { path, offset } => {
+ use std::os::unix::io::AsRawFd;
+ let file = std::fs::File::open(path)?;
+ // MAP_PRIVATE on purpose: `hv_vm_map` rejects read-only
+ // MAP_SHARED file mappings (measured: HV error 0xfae94001),
+ // while MAP_PRIVATE works and matches the host's own view of
+ // the file. These regions are read-only+exec, so the
+ // copy-on-read private view is coherent.
+ // SAFETY: `file` is a live read-only descriptor and `offset`
+ // lies within it; the result is checked against MAP_FAILED.
+ let va = unsafe {
+ libc::mmap(
+ std::ptr::null_mut(),
+ size,
+ libc::PROT_READ,
+ libc::MAP_PRIVATE,
+ file.as_raw_fd(),
+ *offset as libc::off_t,
+ )
+ };
+ if va == libc::MAP_FAILED {
+ return Err(io::Error::last_os_error());
+ }
+ Ok(va as usize)
+ }
+ }
+}
diff --git a/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs
new file mode 100644
index 000000000..cf46144b3
--- /dev/null
+++ b/src/hyperlight_host/src/hypervisor/hvf_surrogate_manager.rs
@@ -0,0 +1,764 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! Pool of HVF surrogate server processes for aarch64 macOS.
+//!
+//! HVF allows only one VM per process, so each concurrent sandbox delegates
+//! its VM to an `hvf_surrogate` server process (see
+//! `src/hyperlight_host/src/hvf_surrogate`). This module mirrors the Windows
+//! `surrogate_process_manager`: it embeds the surrogate binary (and the
+//! hypervisor entitlement plist) via rust-embed, extracts them next to the
+//! running executable, ad-hoc codesigns the binary, and hands out pooled
+//! processes. Each checked-out [`HvfSurrogateProcess`] owns one end of a
+//! socketpair speaking the [`hyperlight_hvf::proto`] protocol; dropping the
+//! handle returns a still-alive process to the pool.
+//!
+//! Sizing is controlled by `HYPERLIGHT_INITIAL_SURROGATES` (default 0 —
+//! unlike the suspended Windows shells these are real running server
+//! processes, so they are spawned on demand) and `HYPERLIGHT_MAX_SURROGATES`
+//! (default 128, matched to the platform's concurrent-VM cap; `0` disables
+//! surrogates entirely, see [`surrogates_disabled`]).
+
+use std::os::unix::io::FromRawFd;
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+use std::process::{Child, Command, Stdio};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, OnceLock};
+
+use crossbeam_channel::{Receiver, Sender, TryRecvError, unbounded};
+use rust_embed::RustEmbed;
+use tracing::{error, info, warn};
+
+use crate::{Result, new_error};
+
+// Use the rust-embed crate to embed the hvf_surrogate binary in the
+// hyperlight-host library to make dependency management easier.
+// $HYPERLIGHT_HVF_SURROGATE_DIR is set by hyperlight-host's build.rs script.
+// https://docs.rs/rust-embed/latest/rust_embed/
+#[derive(RustEmbed)]
+#[folder = "$HYPERLIGHT_HVF_SURROGATE_DIR"]
+#[include = "hvf_surrogate"]
+struct Asset;
+
+/// The name of the embedded surrogate asset (used as the key for `Asset::get`).
+const EMBEDDED_SURROGATE_NAME: &str = "hvf_surrogate";
+
+/// The entitlements plist embedded in this library; extracted next to the
+/// surrogate binary and applied with `codesign` on extraction. The path is
+/// set by hyperlight-host's build.rs script.
+const ENTITLEMENTS_PLIST: &[u8] = include_bytes!(env!("HYPERLIGHT_HVF_ENTITLEMENTS_PATH"));
+
+/// Environment variable passed to the surrogate carrying its inherited
+/// control-socket fd.
+const SURROGATE_FD_ENV_VAR: &str = "HVF_SURROGATE_FD";
+
+/// Environment variable controlling how many surrogate processes are
+/// pre-created when the manager starts. Must be <=
+/// `HYPERLIGHT_MAX_SURROGATES`. Defaults to 0 (created on demand).
+const INITIAL_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_INITIAL_SURROGATES";
+
+/// Environment variable controlling the maximum number of surrogate
+/// processes that can exist (including those created on demand). Must be >=
+/// `HYPERLIGHT_INITIAL_SURROGATES`. Defaults to 64. `0` disables
+/// surrogates entirely.
+const MAX_SURROGATES_ENV_VAR: &str = "HYPERLIGHT_MAX_SURROGATES";
+
+/// Default maximum number of surrogate processes. Sized to the platform's
+/// concurrent-VM limit: macOS caps HVF VMs system-wide (measured 127 on
+/// macOS 26, arm64), so a larger pool only stockpiles idle processes, and
+/// a smaller one can deadlock threads that hold one surrogate while
+/// acquiring another (pool backpressure is the correct behavior near the
+/// cap; exceeding it yields a clean `HV_NO_RESOURCES` error).
+const DEFAULT_MAX_SURROGATE_PROCESSES: usize = 128;
+
+/// A pooled surrogate process: the child handle plus our end of the
+/// control socketpair.
+struct PooledSurrogate {
+ child: Child,
+ socket: UnixStream,
+}
+
+/// A surrogate process checked out from the pool. Dropping it returns the
+/// process to the pool if it is still alive, and reaps it otherwise.
+pub(crate) struct HvfSurrogateProcess {
+ /// `Some` while checked out; taken by `Drop`.
+ inner: Option,
+ /// Channel back to the owning manager's pool.
+ return_sender: Sender,
+ /// Shared with the owning manager; decremented when this process dies
+ /// and is not returned, freeing capacity for a replacement.
+ created_count: Arc,
+}
+
+impl HvfSurrogateProcess {
+ /// The control socket connected to the surrogate server (clone it with
+ /// `try_clone` to share it across threads).
+ #[allow(clippy::expect_used)] // `inner` is only `None` after Drop has taken it
+ pub(crate) fn socket(&self) -> &UnixStream {
+ &self
+ .inner
+ .as_ref()
+ .expect("socket is only accessible while checked out")
+ .socket
+ }
+
+ /// Returns `true` if the surrogate process is still running.
+ #[allow(dead_code)] // used by tests; kept for diagnostics
+ pub(crate) fn is_alive(&mut self) -> bool {
+ self.inner
+ .as_mut()
+ .is_some_and(|p| matches!(p.child.try_wait(), Ok(None)))
+ }
+}
+
+impl Drop for HvfSurrogateProcess {
+ fn drop(&mut self) {
+ let Some(mut pooled) = self.inner.take() else {
+ return;
+ };
+ match pooled.child.try_wait() {
+ Ok(None) => {
+ // Still alive: return it to the pool. If the manager is
+ // already gone, kill and reap the process instead.
+ if let Err(crossbeam_channel::SendError(mut pooled)) =
+ self.return_sender.send(pooled)
+ {
+ let _ = pooled.child.kill();
+ let _ = pooled.child.wait();
+ }
+ }
+ _ => {
+ // Already exited (reaped by `try_wait`): free up capacity
+ // for a replacement.
+ self.created_count.fetch_sub(1, Ordering::AcqRel);
+ }
+ }
+ }
+}
+
+/// `HvfSurrogateProcessManager` manages `hvf_surrogate` processes. These
+/// processes are required to run multiple HVF VMs from a single host
+/// process: each surrogate owns one VM and is driven over a unix
+/// socketpair using the [`hyperlight_hvf::proto`] protocol.
+///
+/// This struct deals with the creation/destruction of these processes, the
+/// pooling of process handles, the distribution of handles from the pool to
+/// a Hyperlight Sandbox instance and the return of a handle to the pool
+/// once the Sandbox instance is destroyed. It is intended to be used as a
+/// singleton and is thread safe.
+///
+/// By default no processes are pre-created; additional processes are
+/// created on demand up to `HYPERLIGHT_MAX_SURROGATES` (default 64). If the
+/// pool is empty and the max has been reached, callers block until a
+/// process is returned.
+pub(crate) struct HvfSurrogateProcessManager {
+ /// `process_receiver` and `process_sender` synchronize reserving a
+ /// surrogate process from the pool and returning one to the pool. See
+ /// the Windows `SurrogateProcessManager` for why these are
+ /// crossbeam-channel types (`Send + Sync`).
+ process_receiver: Receiver,
+ process_sender: Sender,
+ /// Path to the on-disk surrogate binary (hash-stamped).
+ surrogate_process_path: PathBuf,
+ /// Maximum number of surrogate processes allowed to exist.
+ max_processes: usize,
+ /// Number of surrogate processes created so far (minus those observed
+ /// dead). Used to decide whether we can spawn more on demand.
+ created_count: Arc,
+}
+
+impl HvfSurrogateProcessManager {
+ fn new() -> Result {
+ let binary_name = surrogate_binary_name()?;
+ ensure_surrogate_exe(&binary_name)?;
+ let surrogate_process_path = get_surrogate_process_dir()?.join(&binary_name);
+
+ let (initial, max) = surrogate_process_counts();
+
+ let (sender, receiver) = unbounded();
+ let manager = HvfSurrogateProcessManager {
+ process_receiver: receiver,
+ process_sender: sender,
+ surrogate_process_path,
+ max_processes: max,
+ created_count: Arc::new(AtomicUsize::new(0)),
+ };
+
+ info!(
+ "pre-creating {} hvf surrogate processes ({}={:?}, {}={:?})",
+ initial,
+ INITIAL_SURROGATES_ENV_VAR,
+ std::env::var(INITIAL_SURROGATES_ENV_VAR).ok(),
+ MAX_SURROGATES_ENV_VAR,
+ std::env::var(MAX_SURROGATES_ENV_VAR).ok(),
+ );
+ for _ in 0..initial {
+ let pooled = spawn_surrogate_process(&manager.surrogate_process_path)?;
+ manager
+ .process_sender
+ .send(pooled)
+ .map_err(|e| new_error!("surrogate process channel disconnected: {}", e))?;
+ manager.created_count.fetch_add(1, Ordering::AcqRel);
+ }
+
+ Ok(manager)
+ }
+
+ /// Gets a surrogate process from the pool. If the pool is empty and
+ /// fewer than `max_processes` have been created, a new process is
+ /// spawned on demand. If the pool is empty and the maximum has been
+ /// reached, this call blocks until a process is returned. Dead pooled
+ /// processes are reaped and skipped.
+ pub(crate) fn get_surrogate_process(&self) -> Result {
+ // Fast path: try to grab an already-pooled process.
+ loop {
+ match self.process_receiver.try_recv() {
+ Ok(mut pooled) => {
+ if matches!(pooled.child.try_wait(), Ok(None)) {
+ return Ok(self.wrap(pooled));
+ }
+ // Dead (reaped by `try_wait`): free the slot and look
+ // for another pooled process.
+ self.created_count.fetch_sub(1, Ordering::AcqRel);
+ }
+ Err(TryRecvError::Empty) => break,
+ Err(TryRecvError::Disconnected) => {
+ return Err(new_error!("surrogate process channel disconnected"));
+ }
+ }
+ }
+
+ // On-demand growth: atomically claim a slot if one is available.
+ // We use a CAS loop so that concurrent callers don't overshoot
+ // the maximum.
+ loop {
+ let current = self.created_count.load(Ordering::Acquire);
+ if current >= self.max_processes {
+ // At the limit — fall through to the blocking recv below.
+ break;
+ }
+ if self
+ .created_count
+ .compare_exchange(current, current + 1, Ordering::AcqRel, Ordering::Acquire)
+ .is_ok()
+ {
+ info!(
+ "on-demand hvf surrogate process creation ({}/{})",
+ current + 1,
+ self.max_processes
+ );
+ match spawn_surrogate_process(&self.surrogate_process_path) {
+ Ok(pooled) => return Ok(self.wrap(pooled)),
+ Err(e) => {
+ // Rollback the slot claim so capacity isn't
+ // permanently lost on transient failures.
+ self.created_count.fetch_sub(1, Ordering::AcqRel);
+ return Err(e);
+ }
+ }
+ }
+ // CAS failed — another thread beat us; retry.
+ }
+
+ // Maximum reached — block until a process is returned to the pool.
+ loop {
+ let mut pooled = self
+ .process_receiver
+ .recv()
+ .map_err(|e| new_error!("surrogate process channel disconnected: {}", e))?;
+ if matches!(pooled.child.try_wait(), Ok(None)) {
+ return Ok(self.wrap(pooled));
+ }
+ self.created_count.fetch_sub(1, Ordering::AcqRel);
+ }
+ }
+
+ /// Wrap a pooled process in a checkout handle that returns itself to
+ /// this pool on drop.
+ fn wrap(&self, pooled: PooledSurrogate) -> HvfSurrogateProcess {
+ HvfSurrogateProcess {
+ inner: Some(pooled),
+ return_sender: self.process_sender.clone(),
+ created_count: Arc::clone(&self.created_count),
+ }
+ }
+}
+
+impl Drop for HvfSurrogateProcessManager {
+ fn drop(&mut self) {
+ // Kill and reap every pooled process. Checked-out processes are
+ // handled by their own drop.
+ while let Ok(mut pooled) = self.process_receiver.try_recv() {
+ if let Err(e) = pooled.child.kill() {
+ error!("failed to kill hvf surrogate process: {}", e);
+ }
+ let _ = pooled.child.wait();
+ }
+ }
+}
+
+/// Gets the singleton `HvfSurrogateProcessManager`.
+pub(crate) fn get_hvf_surrogate_process_manager() -> Result<&'static HvfSurrogateProcessManager> {
+ static MANAGER: OnceLock> =
+ OnceLock::new();
+ MANAGER
+ .get_or_init(|| {
+ HvfSurrogateProcessManager::new().map_err(|e| {
+ error!("Failed to create HvfSurrogateProcessManager: {:?}", e);
+ format!("{e}")
+ })
+ })
+ .as_ref()
+ .map_err(|e| new_error!("Failed to get HvfSurrogateProcessManager: {}", e))
+}
+
+/// Returns `true` when `HYPERLIGHT_MAX_SURROGATES=0`, meaning surrogate
+/// processes are disabled and the direct in-process HVF backend
+/// (single-VM-per-process mode) should be used instead.
+///
+/// The result is cached on first call — the env var is read only once.
+pub(crate) fn surrogates_disabled() -> bool {
+ static DISABLED: OnceLock = OnceLock::new();
+ *DISABLED.get_or_init(|| {
+ std::env::var(MAX_SURROGATES_ENV_VAR)
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ .is_some_and(|n| n == 0)
+ })
+}
+
+/// Returns the on-disk filename for the surrogate binary, incorporating the
+/// first 8 hex characters of the BLAKE3 hash of the embedded binary and the
+/// entitlements plist, so that different hyperlight versions produce
+/// different filenames and can coexist without file-deletion races.
+fn surrogate_binary_name() -> Result {
+ let exe = Asset::get(EMBEDDED_SURROGATE_NAME)
+ .ok_or_else(|| new_error!("could not find embedded hvf surrogate binary"))?;
+ let mut hasher = blake3::Hasher::new();
+ hasher.update(exe.data.as_ref());
+ hasher.update(ENTITLEMENTS_PLIST);
+ let short_hash = &hasher.finalize().to_hex()[..8];
+ Ok(format!("hvf_surrogate_{short_hash}"))
+}
+
+/// Pure validation/clamping logic for surrogate process counts.
+///
+/// `raw_initial` and `raw_max` are the parsed values from the environment
+/// (or `None` when the variable is unset or unparsable).
+///
+/// Resolution order:
+/// 1. `max` defaults to [`DEFAULT_MAX_SURROGATE_PROCESSES`] when `None`.
+/// 2. `initial` is clamped to `0..=max`, defaulting to 0 when `None`.
+///
+/// When `max == 0`, surrogates are disabled entirely and the system falls
+/// back to the direct in-process HVF backend.
+fn compute_surrogate_counts(raw_initial: Option, raw_max: Option) -> (usize, usize) {
+ let max = raw_max.unwrap_or(DEFAULT_MAX_SURROGATE_PROCESSES);
+
+ // Clamp initial to 0..=max so it can never exceed the authoritative limit.
+ let initial = raw_initial.map(|n| n.clamp(0, max)).unwrap_or(0);
+
+ (initial, max)
+}
+
+/// Returns the (initial, max) surrogate process counts from environment
+/// variables, applying validation and clamping.
+///
+/// - `HYPERLIGHT_INITIAL_SURROGATES`: clamped to `0..=max`, default 0.
+/// - `HYPERLIGHT_MAX_SURROGATES`: default 128; `0` disables surrogates.
+fn surrogate_process_counts() -> (usize, usize) {
+ let raw_initial = std::env::var(INITIAL_SURROGATES_ENV_VAR)
+ .ok()
+ .and_then(|v| v.parse::().ok());
+ let raw_max = std::env::var(MAX_SURROGATES_ENV_VAR)
+ .ok()
+ .and_then(|v| v.parse::().ok());
+
+ let (initial, max) = compute_surrogate_counts(raw_initial, raw_max);
+
+ if let Some(n) = raw_initial
+ && n != initial
+ {
+ warn!("{INITIAL_SURROGATES_ENV_VAR}={n} was clamped to {initial}");
+ }
+ if let Some(n) = raw_max
+ && n != max
+ {
+ warn!("{MAX_SURROGATES_ENV_VAR}={n} was clamped to {max}");
+ }
+
+ (initial, max)
+}
+
+fn get_surrogate_process_dir() -> Result {
+ let binding = std::env::current_exe()?;
+ let path = binding
+ .parent()
+ .ok_or_else(|| new_error!("could not get parent directory of current executable"))?;
+
+ Ok(path.to_path_buf())
+}
+
+/// Ensures the surrogate binary exists on disk at the hash-stamped path,
+/// executable and ad-hoc codesigned with the hypervisor entitlement
+/// (without it `hv_vm_create` fails in the surrogate).
+///
+/// The filename embeds the content hash of the binary and the plist, and
+/// extraction writes to a pid-stamped temporary file that is atomically
+/// renamed into place after signing, so any visible hash-stamped file is
+/// complete and signed.
+fn ensure_surrogate_exe(binary_name: &str) -> Result<()> {
+ let dir = get_surrogate_process_dir()?;
+ let final_path = dir.join(binary_name);
+ if final_path.exists() {
+ return Ok(());
+ }
+
+ let exe = Asset::get(EMBEDDED_SURROGATE_NAME)
+ .ok_or_else(|| new_error!("could not find embedded hvf surrogate binary"))?;
+
+ let tmp_path = dir.join(format!(".{binary_name}.tmp-{}", std::process::id()));
+ let plist_path = dir.join(format!("{binary_name}.entitlements.plist"));
+
+ let result = (|| -> Result<()> {
+ std::fs::write(&tmp_path, exe.data.as_ref())?;
+ std::fs::set_permissions(
+ &tmp_path,
+ ::from_mode(0o755),
+ )?;
+ std::fs::write(&plist_path, ENTITLEMENTS_PLIST)?;
+ let status = Command::new("codesign")
+ .arg("--sign")
+ .arg("-")
+ .arg("--entitlements")
+ .arg(&plist_path)
+ .arg("--force")
+ .arg(&tmp_path)
+ .status()?;
+ if !status.success() {
+ return Err(new_error!("codesign of hvf surrogate failed: {}", status));
+ }
+ info!(
+ "extracted and codesigned hvf surrogate to {}",
+ final_path.display()
+ );
+ match std::fs::rename(&tmp_path, &final_path) {
+ Ok(()) => Ok(()),
+ // Lost a race with a concurrent extractor; the winner's file
+ // is content-identical.
+ Err(_) if final_path.exists() => Ok(()),
+ Err(e) => Err(e.into()),
+ }
+ })();
+
+ if result.is_err() {
+ let _ = std::fs::remove_file(&tmp_path);
+ }
+ result
+}
+
+/// Spawns a surrogate process with one end of a fresh socketpair inherited
+/// as its control socket.
+fn spawn_surrogate_process(exe_path: &Path) -> Result {
+ let mut fds = [0i32; 2];
+ // SAFETY: `fds` is a valid two-element array; on success both
+ // descriptors are owned by us. No SOCK_CLOEXEC: the child end must
+ // survive the exec of the surrogate binary.
+ if unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) } != 0 {
+ return Err(std::io::Error::last_os_error().into());
+ }
+ let (parent_fd, child_fd) = (fds[0], fds[1]);
+
+ // Mark our end close-on-exec: without this the surrogate would inherit
+ // a duplicate of the parent's end too, and would then never see EOF
+ // when the parent closes its end (its own duplicate keeps the socket
+ // open).
+ // SAFETY: `parent_fd` is a live descriptor owned by us.
+ if unsafe { libc::fcntl(parent_fd, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 {
+ let err = std::io::Error::last_os_error();
+ // SAFETY: both descriptors are live and owned by us.
+ unsafe {
+ libc::close(parent_fd);
+ libc::close(child_fd);
+ }
+ return Err(err.into());
+ }
+
+ let spawn_result = Command::new(exe_path)
+ .env(SURROGATE_FD_ENV_VAR, child_fd.to_string())
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .spawn();
+
+ // The child (if any) has inherited its end by now; the parent never
+ // uses it again.
+ // SAFETY: `child_fd` is a live descriptor owned by us.
+ unsafe {
+ libc::close(child_fd);
+ }
+
+ let child = match spawn_result {
+ Ok(child) => child,
+ Err(e) => {
+ // SAFETY: `parent_fd` is a live descriptor owned by us.
+ unsafe {
+ libc::close(parent_fd);
+ }
+ return Err(e.into());
+ }
+ };
+
+ // SAFETY: `parent_fd` is a live socket descriptor owned by us.
+ let socket = unsafe { UnixStream::from_raw_fd(parent_fd) };
+ Ok(PooledSurrogate { child, socket })
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use hyperlight_hvf::proto::{PROTO_VERSION, Request, Response, read_frame, write_frame};
+
+ use super::*;
+
+ fn handshake(sock: &UnixStream) {
+ let mut s = sock;
+ write_frame(
+ &mut s,
+ &Request::Hello {
+ version: PROTO_VERSION,
+ },
+ )
+ .unwrap();
+ match read_frame::(&mut s).unwrap() {
+ Some(Response::Hello { version }) => assert_eq!(version, PROTO_VERSION),
+ other => panic!("unexpected handshake response: {other:?}"),
+ }
+ }
+
+ fn request(sock: &UnixStream, req: &Request) -> Response {
+ let mut s = sock;
+ write_frame(&mut s, req).unwrap();
+ read_frame::(&mut s)
+ .unwrap()
+ .expect("surrogate closed the connection")
+ }
+
+ fn connect(sock: &UnixStream) -> UnixStream {
+ let s = sock.try_clone().unwrap();
+ s.set_read_timeout(Some(Duration::from_secs(30))).unwrap();
+ s
+ }
+
+ /// Protocol-level cancellation probe: map a shm region containing an
+ /// infinite loop (`b .`), run the vCPU, and cancel it from another
+ /// thread. The run must exit with `VmExit::Cancelled`.
+ #[test]
+ fn surrogate_cancel_interrupts_run() {
+ use std::ffi::CString;
+
+ use hyperlight_hvf::core::{Perms, Regs, VmExit};
+ use hyperlight_hvf::proto::Backing;
+
+ let mgr = get_hvf_surrogate_process_manager().unwrap();
+ let proc = mgr.get_surrogate_process().unwrap();
+ let sock = connect(proc.socket());
+ handshake(&sock);
+ assert!(matches!(request(&sock, &Request::CreateVm), Response::Ok));
+
+ // Guest memory: one page with `b .` (0x14000000) at the start.
+ const SIZE: usize = 0x4000;
+ const GPA: u64 = 0x4000_0000;
+ let name = format!("/hl-{}-cancel", std::process::id());
+ let c_name = CString::new(name.as_str()).unwrap();
+ // SAFETY: `c_name` is a valid NUL-terminated string; flags/mode are
+ // valid. On success the fd is ours.
+ let fd = unsafe {
+ libc::shm_open(
+ c_name.as_ptr(),
+ libc::O_RDWR | libc::O_CREAT | libc::O_EXCL,
+ 0o600,
+ )
+ };
+ assert!(
+ fd >= 0,
+ "shm_open failed: {:?}",
+ std::io::Error::last_os_error()
+ );
+ // SAFETY: `fd` is a live shm descriptor we just created.
+ assert_eq!(unsafe { libc::ftruncate(fd, SIZE as libc::off_t) }, 0);
+ // SAFETY: `fd` is a live shm descriptor of SIZE bytes.
+ let va = unsafe {
+ libc::mmap(
+ std::ptr::null_mut(),
+ SIZE,
+ libc::PROT_READ | libc::PROT_WRITE,
+ libc::MAP_SHARED,
+ fd,
+ 0,
+ )
+ };
+ assert_ne!(va, libc::MAP_FAILED);
+ // `b .` — branch to self, an infinite loop.
+ // SAFETY: `va` points to a live writable mapping of SIZE bytes.
+ unsafe { (va as *mut u32).write(0x1400_0000) };
+
+ assert!(matches!(
+ request(
+ &sock,
+ &Request::MapMemory {
+ slot: 0,
+ gpa: GPA,
+ size: SIZE as u64,
+ perms: Perms {
+ read: true,
+ write: false,
+ exec: true,
+ },
+ backing: Backing::Shm {
+ name: name.clone(),
+ offset: 0,
+ },
+ }
+ ),
+ Response::Ok
+ ));
+
+ // Run with MMU off (fresh vCPU: SCTLR_EL1 reset value), PC at GPA,
+ // EL1t with interrupts masked — same pstate the host uses.
+ let regs = Regs {
+ pc: GPA,
+ pstate: 0b11 << 6 | 0b100,
+ ..Default::default()
+ };
+ assert!(matches!(
+ request(&sock, &Request::SetRegs(Box::new(regs))),
+ Response::Ok
+ ));
+
+ // Cancel from another thread after 200ms (a cloned fd, mirroring
+ // the interrupt handle's shared writer).
+ let cancel_sock = sock.try_clone().unwrap();
+ let cancel_thread = std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(200));
+ let mut s = &cancel_sock;
+ write_frame(&mut s, &Request::Cancel).unwrap();
+ });
+
+ let resp = request(&sock, &Request::RunVcpu);
+ cancel_thread.join().unwrap();
+ assert!(
+ matches!(resp, Response::Exit(VmExit::Cancelled)),
+ "expected Exit(Cancelled), got {resp:?}"
+ );
+
+ assert!(matches!(request(&sock, &Request::DestroyVm), Response::Ok));
+
+ // SAFETY: `va`/`fd`/`name` are the live mapping, descriptor and
+ // object created above.
+ unsafe {
+ libc::munmap(va, SIZE);
+ libc::close(fd);
+ libc::shm_unlink(c_name.as_ptr());
+ }
+ }
+
+ /// Smoke test: acquire a surrogate, complete the handshake, create a
+ /// VM, round-trip the system registers with a known value, destroy the
+ /// VM, and return the process to the pool — then verify the pooled
+ /// process is reused for another full VM lifecycle.
+ ///
+ /// The test binary must be codesigned with
+ /// `dev/hvf-entitlements.plist` (see the `test-hvf` just recipe).
+ #[test]
+ fn surrogate_handshake_and_sregs_round_trip() {
+ let mgr = get_hvf_surrogate_process_manager().unwrap();
+ assert!(!surrogates_disabled());
+
+ let mut proc = mgr.get_surrogate_process().unwrap();
+ assert!(proc.is_alive());
+ let sock = connect(proc.socket());
+
+ handshake(&sock);
+ assert!(matches!(request(&sock, &Request::CreateVm), Response::Ok));
+
+ // Round-trip sregs with a known value.
+ let Response::Sregs(mut sregs) = request(&sock, &Request::GetSregs) else {
+ panic!("expected Sregs");
+ };
+ sregs.sp_el1 = 0x1234_5000;
+ assert!(matches!(
+ request(&sock, &Request::SetSregs(sregs)),
+ Response::Ok
+ ));
+ let Response::Sregs(got) = request(&sock, &Request::GetSregs) else {
+ panic!("expected Sregs");
+ };
+ assert_eq!(got, sregs);
+
+ assert!(matches!(request(&sock, &Request::DestroyVm), Response::Ok));
+
+ // Return the process to the pool; re-acquiring must yield a live,
+ // reusable surrogate.
+ drop(proc);
+ let mut proc2 = mgr.get_surrogate_process().unwrap();
+ assert!(proc2.is_alive());
+ let sock2 = connect(proc2.socket());
+ handshake(&sock2);
+ assert!(matches!(request(&sock2, &Request::CreateVm), Response::Ok));
+ assert!(matches!(request(&sock2, &Request::DestroyVm), Response::Ok));
+ }
+
+ /// Verifies `compute_surrogate_counts()` returns sensible defaults
+ /// when inputs are `None`, and correct clamped values otherwise.
+ #[test]
+ fn test_compute_surrogate_counts() {
+ // Both unset → defaults (initial 0, max 64).
+ let (initial, max) = compute_surrogate_counts(None, None);
+ assert_eq!(initial, 0);
+ assert_eq!(max, DEFAULT_MAX_SURROGATE_PROCESSES);
+
+ // Only initial set → honoured, clamped to max.
+ let (initial, max) = compute_surrogate_counts(Some(8), None);
+ assert_eq!(initial, 8);
+ assert_eq!(max, DEFAULT_MAX_SURROGATE_PROCESSES);
+
+ // Initial above max → clamped down to max.
+ let (initial, max) = compute_surrogate_counts(Some(100), Some(10));
+ assert_eq!(initial, 10);
+ assert_eq!(max, 10);
+
+ // Max at zero → surrogates disabled, initial follows.
+ let (initial, max) = compute_surrogate_counts(None, Some(0));
+ assert_eq!(initial, 0);
+ assert_eq!(max, 0);
+
+ // Both set, max > initial.
+ let (initial, max) = compute_surrogate_counts(Some(3), Some(7));
+ assert_eq!(initial, 3);
+ assert_eq!(max, 7);
+ }
+
+ /// Verifies extraction naming is deterministic.
+ #[test]
+ fn test_surrogate_binary_name_deterministic() {
+ let a = surrogate_binary_name().unwrap();
+ let b = surrogate_binary_name().unwrap();
+ assert_eq!(a, b);
+ assert!(a.starts_with("hvf_surrogate_"));
+ }
+}
diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs
index 770959c14..562a55d55 100644
--- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs
+++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs
@@ -17,24 +17,36 @@ limitations under the License.
// TODO(aarch64): implement arch-specific HyperlightVm methods
use std::sync::Arc;
-use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64};
+#[cfg(any(kvm, mshv3))]
+use std::sync::atomic::AtomicU64;
+#[cfg(any(kvm, mshv3, hvf))]
+use std::sync::atomic::{AtomicBool, AtomicU8};
use super::{
AccessPageTableError, CreateHyperlightVmError, DispatchGuestCallError, HyperlightVm,
InitializeError,
};
+#[cfg(any(kvm, mshv3, hvf))]
+use crate::hypervisor::InterruptHandleImpl;
+#[cfg(any(kvm, mshv3))]
+use crate::hypervisor::LinuxInterruptHandle;
#[cfg(gdb)]
use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse};
+#[cfg(hvf)]
+use crate::hypervisor::hvf_surrogate_manager;
use crate::hypervisor::hyperlight_vm::get_guest_log_filter;
use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters};
+#[cfg(hvf)]
+use crate::hypervisor::virtual_machine::hvf::{HvfSurrogateVm, HvfVm};
#[cfg(kvm)]
use crate::hypervisor::virtual_machine::kvm::KvmVm;
-#[cfg(kvm)]
+#[cfg(any(kvm, mshv3, hvf))]
use crate::hypervisor::virtual_machine::{HypervisorType, VmError};
-use crate::hypervisor::virtual_machine::{
- RegisterError, ResetVcpuError, VirtualMachine, get_available_hypervisor,
-};
-use crate::hypervisor::{InterruptHandleImpl, LinuxInterruptHandle};
+use crate::hypervisor::virtual_machine::{RegisterError, ResetVcpuError};
+#[cfg(any(kvm, mshv3, hvf))]
+use crate::hypervisor::virtual_machine::{VirtualMachine, get_available_hypervisor};
+#[cfg(hvf)]
+use crate::hypervisor::{HvfInterruptTarget, MacOSInterruptHandle};
use crate::mem::mgr::{SandboxMemoryManager, SnapshotSharedMemory};
use crate::mem::shared_mem::{GuestSharedMemory, HostSharedMemory};
use crate::sandbox::SandboxConfiguration;
@@ -59,52 +71,110 @@ impl HyperlightVm {
#[cfg(crashdump)] _rt_cfg: SandboxRuntimeConfig,
#[cfg(feature = "mem_profile")] _trace_info: MemTraceInfo,
) -> std::result::Result {
+ // No hypervisor backend is compiled for this platform (e.g. aarch64
+ // without the kvm, mshv3 or hvf features).
+ #[cfg(not(any(kvm, mshv3, hvf)))]
+ {
+ let _ = (
+ snapshot_mem,
+ scratch_mem,
+ root_pt_addr,
+ next_action,
+ rsp_gva,
+ page_size,
+ config,
+ );
+ Err(CreateHyperlightVmError::NoHypervisorFound)
+ }
// TODO: support gdb on aarch64
- type VmType = Box;
- let vm: VmType = match get_available_hypervisor() {
- #[cfg(kvm)]
- Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?),
- // TODO: mshv support
- #[cfg(mshv3)]
- Some(HypervisorType::Mshv) => return Err(CreateHyperlightVmError::NoHypervisorFound),
- None => return Err(CreateHyperlightVmError::NoHypervisorFound),
- };
- vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr))
- .map_err(VmError::Register)?;
- let interrupt_handle: Arc = Arc::new(LinuxInterruptHandle {
- state: AtomicU8::new(0),
- tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }),
- retry_delay: config.get_interrupt_retry_delay(),
- sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(),
- dropped: AtomicBool::new(false),
- });
-
- let snapshot_slot = 0u32;
- let scratch_slot = 1u32;
- let vm_can_reset_vcpu = vm.can_reset_vcpu();
- let mut ret = Self {
- vm,
- next_action,
- rsp_gva,
- interrupt_handle,
- page_size,
-
- next_slot: scratch_slot + 1,
- freed_slots: Vec::new(),
-
- snapshot_slot,
- snapshot_memory: None,
- scratch_slot,
- scratch_memory: None,
-
- mmap_regions: Vec::new(),
-
- vm_can_reset_vcpu,
- pending_tlb_flush: false,
- };
- ret.update_snapshot_mapping(snapshot_mem)?;
- ret.update_scratch_mapping(scratch_mem)?;
- Ok(ret)
+ #[cfg(any(kvm, mshv3, hvf))]
+ {
+ type VmType = Box;
+ #[cfg(any(kvm, mshv3))]
+ let (vm, interrupt_handle): (VmType, Arc) = {
+ let vm: VmType = match get_available_hypervisor() {
+ #[cfg(kvm)]
+ Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?),
+ // TODO: mshv support
+ #[cfg(mshv3)]
+ Some(HypervisorType::Mshv) => {
+ return Err(CreateHyperlightVmError::NoHypervisorFound);
+ }
+ None => return Err(CreateHyperlightVmError::NoHypervisorFound),
+ };
+ let interrupt_handle: Arc =
+ Arc::new(LinuxInterruptHandle {
+ state: AtomicU8::new(0),
+ tid: AtomicU64::new(unsafe { libc::pthread_self() as u64 }),
+ retry_delay: config.get_interrupt_retry_delay(),
+ sig_rt_min_offset: config.get_interrupt_vcpu_sigrtmin_offset(),
+ dropped: AtomicBool::new(false),
+ });
+ (vm, interrupt_handle)
+ };
+ #[cfg(hvf)]
+ let (vm, interrupt_handle): (VmType, Arc) = {
+ // `config` only carries Linux signal settings today.
+ let _ = config;
+ match get_available_hypervisor() {
+ Some(HypervisorType::Hvf) => {
+ if hvf_surrogate_manager::surrogates_disabled() {
+ // Direct in-process backend: single VM per process.
+ let vm = HvfVm::new().map_err(VmError::CreateVm)?;
+ let interrupt_handle: Arc =
+ Arc::new(MacOSInterruptHandle {
+ state: AtomicU8::new(0),
+ target: HvfInterruptTarget::Local(vm.vcpu_id()),
+ dropped: AtomicBool::new(false),
+ });
+ (Box::new(vm) as VmType, interrupt_handle)
+ } else {
+ // Surrogate backend: the VM lives in a pooled
+ // surrogate process; cancellation goes over the
+ // shared socket writer.
+ let vm = HvfSurrogateVm::new().map_err(VmError::CreateVm)?;
+ let interrupt_handle: Arc =
+ Arc::new(MacOSInterruptHandle {
+ state: AtomicU8::new(0),
+ target: HvfInterruptTarget::Remote(vm.cancel_writer()),
+ dropped: AtomicBool::new(false),
+ });
+ (Box::new(vm) as VmType, interrupt_handle)
+ }
+ }
+ None => return Err(CreateHyperlightVmError::NoHypervisorFound),
+ }
+ };
+ vm.set_sregs(&CommonSpecialRegisters::defaults(root_pt_addr))
+ .map_err(VmError::Register)?;
+
+ let snapshot_slot = 0u32;
+ let scratch_slot = 1u32;
+ let vm_can_reset_vcpu = vm.can_reset_vcpu();
+ let mut ret = Self {
+ vm,
+ next_action,
+ rsp_gva,
+ interrupt_handle,
+ page_size,
+
+ next_slot: scratch_slot + 1,
+ freed_slots: Vec::new(),
+
+ snapshot_slot,
+ snapshot_memory: None,
+ scratch_slot,
+ scratch_memory: None,
+
+ mmap_regions: Vec::new(),
+
+ vm_can_reset_vcpu,
+ pending_tlb_flush: false,
+ };
+ ret.update_snapshot_mapping(snapshot_mem)?;
+ ret.update_scratch_mapping(scratch_mem)?;
+ Ok(ret)
+ }
}
#[allow(clippy::too_many_arguments)]
diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs
index 2e545aba7..d5c7a72a5 100644
--- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs
+++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs
@@ -422,13 +422,15 @@ impl HyperlightVm {
&mut self,
region: &MemoryRegion,
) -> std::result::Result<(), MapRegionError> {
+ #[allow(clippy::useless_conversion, clippy::clone_on_copy)]
+ let host_region_start: usize = region.host_region.start.clone().into();
+ #[allow(clippy::useless_conversion, clippy::clone_on_copy)]
+ let host_region_end: usize = region.host_region.end.clone().into();
if [
region.guest_region.start,
region.guest_region.end,
- #[allow(clippy::useless_conversion)]
- region.host_region.start.into(),
- #[allow(clippy::useless_conversion)]
- region.host_region.end.into(),
+ host_region_start,
+ host_region_end,
]
.iter()
.any(|x| x % self.page_size != 0)
diff --git a/src/hyperlight_host/src/hypervisor/mod.rs b/src/hyperlight_host/src/hypervisor/mod.rs
index 732f08563..31cd97071 100644
--- a/src/hyperlight_host/src/hypervisor/mod.rs
+++ b/src/hyperlight_host/src/hypervisor/mod.rs
@@ -33,16 +33,26 @@ pub(crate) mod surrogate_process_manager;
#[cfg(target_os = "windows")]
pub mod wrappers;
+#[cfg(hvf)]
+/// Pool of HVF surrogate server processes (one HVF VM per process)
+pub(crate) mod hvf_surrogate_manager;
+
#[cfg(crashdump)]
pub(crate) mod crashdump;
pub(crate) mod hyperlight_vm;
use std::fmt::Debug;
+#[cfg(hvf)]
+use std::os::unix::net::UnixStream;
#[cfg(any(kvm, mshv3))]
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
+#[cfg(any(target_os = "windows", hvf))]
+use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicU8, Ordering};
+#[cfg(hvf)]
+use std::sync::{Arc, Mutex};
#[cfg(any(kvm, mshv3))]
use std::time::Duration;
@@ -456,7 +466,162 @@ impl InterruptHandle for WindowsInterruptHandle {
}
}
-#[cfg(all(test, any(target_os = "windows", kvm)))]
+#[cfg(hvf)]
+#[derive(Debug)]
+pub(super) struct MacOSInterruptHandle {
+ /// Atomic value packing vcpu execution state.
+ ///
+ /// Bit layout:
+ /// - Bit 1: RUNNING_BIT - set when vcpu is actively running
+ /// - Bit 0: CANCEL_BIT - set when cancellation has been requested
+ ///
+ /// Cancelling forces an immediate exit of a running vCPU and is a
+ /// no-op-safe operation otherwise, but we still track the RUNNING_BIT to
+ /// report whether the vCPU was actually interrupted.
+ ///
+ /// CANCEL_BIT persists across vcpu exits/re-entries within a single `VirtualCPU::run()` call
+ /// (e.g., during host function calls), but is cleared at the start of each new `VirtualCPU::run()` call.
+ pub(super) state: AtomicU8,
+
+ /// How to reach the vCPU to cancel a run.
+ pub(super) target: HvfInterruptTarget,
+
+ /// Whether the corresponding VM has been dropped.
+ pub(super) dropped: AtomicBool,
+}
+
+/// How a [`MacOSInterruptHandle`] reaches the vCPU to cancel a run.
+#[cfg(hvf)]
+#[derive(Debug)]
+pub(super) enum HvfInterruptTarget {
+ /// Direct mode: the vCPU lives in this process and is cancelled with
+ /// `hv_vcpus_exit`. The call is documented as callable from any thread,
+ /// and vCPU IDs are kernel-validated (a stale cancel of a destroyed
+ /// vCPU simply fails), so no lock is needed to guard against concurrent
+ /// vCPU destruction.
+ Local(u64),
+ /// Surrogate mode: the vCPU lives in a surrogate process and is
+ /// cancelled by sending a `Cancel` frame over the shared writer (the
+ /// same mutex serializes request frames, so frames cannot interleave).
+ Remote(Arc>),
+}
+
+#[cfg(hvf)]
+impl MacOSInterruptHandle {
+ const RUNNING_BIT: u8 = 1 << 1;
+ const CANCEL_BIT: u8 = 1 << 0;
+ #[cfg(gdb)]
+ const DEBUG_INTERRUPT_BIT: u8 = 1 << 2;
+
+ /// Force the vCPU out of `hv_vcpu_run` if it is currently running.
+ fn interrupt(&self) -> bool {
+ // Acquire ordering to synchronize with the Release in set_running()
+ let state = self.state.load(Ordering::Acquire);
+ if state & Self::RUNNING_BIT == 0 {
+ return false;
+ }
+
+ if self.dropped.load(Ordering::Acquire) {
+ return false;
+ }
+
+ match &self.target {
+ HvfInterruptTarget::Local(vcpu) => {
+ // SAFETY: `hv_vcpus_exit` may be called from any thread, and
+ // the vCPU ID is kernel-validated, so a stale cancel after
+ // the vCPU was destroyed fails harmlessly.
+ unsafe {
+ applevisor_sys::hv_vcpus_exit(vcpu, 1)
+ == applevisor_sys::hv_error_t::HV_SUCCESS as i32
+ }
+ }
+ HvfInterruptTarget::Remote(writer) => {
+ let Ok(mut writer) = writer.lock() else {
+ return false;
+ };
+ hyperlight_hvf::proto::write_frame(
+ &mut *writer,
+ &hyperlight_hvf::proto::Request::Cancel,
+ )
+ .is_ok()
+ }
+ }
+ }
+}
+
+#[cfg(hvf)]
+impl InterruptHandleImpl for MacOSInterruptHandle {
+ fn set_running(&self) {
+ // Release ordering to ensure prior memory operations are visible when another thread observes running=true
+ self.state.fetch_or(Self::RUNNING_BIT, Ordering::Release);
+ }
+
+ fn is_cancelled(&self) -> bool {
+ // Acquire ordering to synchronize with the Release in kill()
+ // This ensures we see the CANCEL_BIT set by the interrupt thread
+ self.state.load(Ordering::Acquire) & Self::CANCEL_BIT != 0
+ }
+
+ fn clear_cancel(&self) {
+ // Release ordering to ensure that any operations from the previous run()
+ // are visible to other threads. While this is typically called by the vcpu thread
+ // at the start of run(), the VM itself can move between threads across guest calls.
+ self.state.fetch_and(!Self::CANCEL_BIT, Ordering::Release);
+ }
+
+ fn clear_running(&self) {
+ // Release ordering to ensure all vcpu operations are visible before clearing running
+ self.state.fetch_and(!Self::RUNNING_BIT, Ordering::Release);
+ }
+
+ fn is_debug_interrupted(&self) -> bool {
+ #[cfg(gdb)]
+ {
+ self.state.load(Ordering::Acquire) & Self::DEBUG_INTERRUPT_BIT != 0
+ }
+ #[cfg(not(gdb))]
+ {
+ false
+ }
+ }
+
+ #[cfg(gdb)]
+ fn clear_debug_interrupt(&self) {
+ self.state
+ .fetch_and(!Self::DEBUG_INTERRUPT_BIT, Ordering::Release);
+ }
+
+ fn set_dropped(&self) {
+ // Release ordering to ensure all VM cleanup operations are visible
+ // to any thread that checks dropped() via Acquire
+ self.dropped.store(true, Ordering::Release);
+ }
+}
+
+#[cfg(hvf)]
+impl InterruptHandle for MacOSInterruptHandle {
+ fn kill(&self) -> bool {
+ // Release ordering ensures that any writes before kill() are visible to the vcpu thread
+ // when it checks is_cancelled() with Acquire ordering
+ self.state.fetch_or(Self::CANCEL_BIT, Ordering::Release);
+ self.interrupt()
+ }
+
+ #[cfg(gdb)]
+ fn kill_from_debugger(&self) -> bool {
+ self.state
+ .fetch_or(Self::DEBUG_INTERRUPT_BIT, Ordering::Release);
+ self.interrupt()
+ }
+
+ fn dropped(&self) -> bool {
+ // Acquire ordering to synchronize with the Release in set_dropped()
+ // This ensures we see all VM cleanup operations that happened before drop
+ self.dropped.load(Ordering::Acquire)
+ }
+}
+
+#[cfg(all(test, any(target_os = "windows", kvm, hvf)))]
pub(crate) mod tests {
use std::sync::{Arc, Mutex};
diff --git a/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs b/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs
index 0d80977eb..7329aec24 100644
--- a/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs
+++ b/src/hyperlight_host/src/hypervisor/regs/aarch64/special_regs.rs
@@ -32,26 +32,42 @@ pub(crate) struct CommonSpecialRegisters {
pub(crate) sp_el1: u64,
}
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_PS_48: u64 = 0b101u64 << 32;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_TG0_4K: u64 = 0b00u64 << 14;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_TG1_4K: u64 = 0b10u64 << 30;
+#[cfg(any(kvm, mshv3, hvf))]
#[allow(clippy::identity_op)]
pub(crate) const TCR_EL1_T0SZ_48: u64 = 16u64 << 0;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_T1SZ_48: u64 = 16u64 << 16;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_IRGN0_WB_AA: u64 = 0b01 << 8;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_ORGN0_WB_AA: u64 = 0b01 << 10;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const TCR_EL1_SH0_ISH: u64 = 0b11 << 12;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const MAIR_NORMAL_OWB_NT_AA: u64 = 0b1111_1111;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const MAIR_ITEM_WIDTH: u8 = 8;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const SCTLR_EL1_RES1: u64 = 0b11u64 << 28 | 0b11u64 << 22 | 0b1u64 << 20 | 0b1u64 << 11;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const SCTLR_EL1_M: u64 = 0b1u64 << 0;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const SCTLR_EL1_C: u64 = 0b1u64 << 2;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const SCTLR_EL1_I: u64 = 0b1u64 << 12;
+#[cfg(any(kvm, mshv3, hvf))]
pub(crate) const CPACR_EL1_FPEN_NO_TRAP: u64 = 0b11 << 20;
+#[cfg(any(kvm, mshv3, hvf))]
impl CommonSpecialRegisters {
pub(crate) fn defaults(root_pt_addr: u64) -> Self {
CommonSpecialRegisters {
diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs
new file mode 100644
index 000000000..59e7feda3
--- /dev/null
+++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/direct.rs
@@ -0,0 +1,209 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! Direct (in-process) HVF backend.
+//!
+//! Used when surrogates are disabled (`HYPERLIGHT_MAX_SURROGATES=0`). Only
+//! one [`HvfVm`] can exist per process — HVF's one-VM-per-process limit —
+//! which is enforced by [`NoSurrogateGuard`] so a second VM fails with a
+//! clean error instead of a raw `HV_BUSY`.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+
+use hyperlight_hvf::core::{self, HvfError, Perms};
+
+use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters};
+use crate::hypervisor::virtual_machine::{
+ CreateVmError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError, UnmapMemoryError,
+ VirtualMachine, VmExit,
+};
+#[cfg(feature = "trace_guest")]
+use crate::sandbox::trace::TraceContext as SandboxTraceContext;
+
+/// Set while a direct-mode VM exists in this process; cleared when the
+/// [`NoSurrogateGuard`] stored on the [`HvfVm`] is dropped.
+static DIRECT_VM_ACTIVE: AtomicBool = AtomicBool::new(false);
+
+/// RAII guard that sets [`DIRECT_VM_ACTIVE`] on creation and clears it on
+/// drop. Stored as a field on [`HvfVm`] so the flag stays set for exactly
+/// the lifetime of the VM (mirrors WHP's `NoSurrogateGuard`).
+#[derive(Debug)]
+struct NoSurrogateGuard;
+
+impl NoSurrogateGuard {
+ fn acquire() -> std::result::Result {
+ if DIRECT_VM_ACTIVE.swap(true, Ordering::SeqCst) {
+ return Err(CreateVmError::SurrogateProcess(
+ "HYPERLIGHT_MAX_SURROGATES=0 limits the process to a single VM; \
+ a VM is already active"
+ .into(),
+ ));
+ }
+ Ok(Self)
+ }
+}
+
+impl Drop for NoSurrogateGuard {
+ fn drop(&mut self) {
+ DIRECT_VM_ACTIVE.store(false, Ordering::SeqCst);
+ }
+}
+
+/// The direct (in-process) HVF implementation of a single-vcpu VM.
+#[derive(Debug)]
+pub(crate) struct HvfVm {
+ inner: core::Vm,
+ /// Clears [`DIRECT_VM_ACTIVE`] when this VM is dropped.
+ _no_surrogate_guard: NoSurrogateGuard,
+}
+
+impl HvfVm {
+ pub(crate) fn new() -> std::result::Result {
+ let guard = NoSurrogateGuard::acquire()?;
+ Ok(Self {
+ inner: core::Vm::new().map_err(|e| CreateVmError::CreateVmFd(e.into()))?,
+ _no_surrogate_guard: guard,
+ })
+ }
+
+ /// The raw HVF vCPU ID, used to construct the local interrupt handle
+ /// (`hv_vcpus_exit` may be called from any thread).
+ pub(crate) fn vcpu_id(&self) -> u64 {
+ self.inner.vcpu_id()
+ }
+}
+
+impl VirtualMachine for HvfVm {
+ unsafe fn map_memory(
+ &mut self,
+ (slot, region): (u32, &crate::mem::memory_region::MemoryRegion),
+ ) -> std::result::Result<(), MapMemoryError> {
+ let perms = Perms {
+ read: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::READ),
+ write: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::WRITE),
+ exec: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::EXECUTE),
+ };
+ // SAFETY: the caller guarantees the host region is valid and outlives
+ // the mapping (see the trait's `map_memory` contract).
+ unsafe {
+ self.inner.map_memory(
+ slot,
+ region.guest_region.start as u64,
+ usize::from(region.host_region.start.clone()),
+ region.guest_region.end - region.guest_region.start,
+ perms,
+ )
+ }
+ .map_err(|e| MapMemoryError::Hypervisor(e.into()))
+ }
+
+ fn unmap_memory(
+ &mut self,
+ (slot, region): (u32, &crate::mem::memory_region::MemoryRegion),
+ ) -> std::result::Result<(), UnmapMemoryError> {
+ self.inner
+ .unmap_memory(
+ slot,
+ region.guest_region.start as u64,
+ region.guest_region.end - region.guest_region.start,
+ )
+ .map_err(|e| UnmapMemoryError::Hypervisor(e.into()))
+ }
+
+ fn run_vcpu(
+ &mut self,
+ #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext,
+ ) -> std::result::Result {
+ #[cfg(feature = "trace_guest")]
+ let _ = tc;
+ match self.inner.run_vcpu() {
+ Ok(exit) => Ok(exit.into()),
+ Err(HvfError::NoInstructionSyndrome) => Err(RunVcpuError::ParseGpaAccessInfo),
+ Err(e) => Err(RunVcpuError::Unknown(e.into())),
+ }
+ }
+
+ fn regs(&self) -> std::result::Result {
+ self.inner
+ .regs()
+ .map(Into::into)
+ .map_err(|e| RegisterError::GetRegs(e.into()))
+ }
+
+ fn set_regs(&self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> {
+ self.inner
+ .set_regs(®s.into())
+ .map_err(|e| RegisterError::SetRegs(e.into()))
+ }
+
+ fn fpu(&self) -> std::result::Result {
+ self.inner
+ .fpu()
+ .map(Into::into)
+ .map_err(|e| RegisterError::GetFpu(e.into()))
+ }
+
+ fn set_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> {
+ self.inner
+ .set_fpu(&fpu.into())
+ .map_err(|e| RegisterError::SetFpu(e.into()))
+ }
+
+ fn sregs(&self) -> std::result::Result {
+ self.inner
+ .sregs()
+ .map(Into::into)
+ .map_err(|e| RegisterError::GetSregs(e.into()))
+ }
+
+ fn set_sregs(&self, sregs: &CommonSpecialRegisters) -> std::result::Result<(), RegisterError> {
+ self.inner
+ .set_sregs(&sregs.into())
+ .map_err(|e| RegisterError::SetSregs(e.into()))
+ }
+
+ fn debug_regs(
+ &self,
+ ) -> std::result::Result {
+ todo!("debug registers are not supported on aarch64")
+ }
+
+ fn set_debug_regs(
+ &self,
+ _drs: &crate::hypervisor::regs::CommonDebugRegs,
+ ) -> std::result::Result<(), RegisterError> {
+ todo!("debug registers are not supported on aarch64")
+ }
+
+ fn can_reset_vcpu(&self) -> bool {
+ true
+ }
+
+ fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> {
+ // HVF has no "vcpu init" operation like KVM's KVM_ARM_VCPU_INIT;
+ // `core::Vm::reset_vcpu` emulates it. Special registers are
+ // applied separately by the caller (`apply_sregs`).
+ self.inner
+ .reset_vcpu()
+ .map_err(|e| ResetVcpuError::Hypervisor(e.into()))
+ }
+}
diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs
new file mode 100644
index 000000000..965c7a6d0
--- /dev/null
+++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/mod.rs
@@ -0,0 +1,156 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! HVF (Hypervisor.framework) backends for macOS on aarch64.
+//!
+//! Two [`VirtualMachine`] implementations exist:
+//!
+//! - [`direct::HvfVm`]: the single-VM-per-process backend — HVF binds a VM
+//! to its creating process, so only one sandbox backed by it can exist in
+//! a process at a time. Used when surrogates are disabled
+//! (`HYPERLIGHT_MAX_SURROGATES=0`).
+//! - [`surrogate::HvfSurrogateVm`]: delegates each sandbox's VM to a
+//! surrogate helper process over the [`hyperlight_hvf::proto`] IPC
+//! protocol, allowing multiple concurrent sandboxes.
+//!
+//! The actual HVF logic lives in the shared [`hyperlight_hvf::core`] crate;
+//! this module adapts it to the [`VirtualMachine`] trait and hosts the
+//! register/exit conversions shared by both backends.
+
+use hyperlight_hvf::core::{self, FpuState, Regs, Sregs};
+use tracing::{Span, instrument};
+
+use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters};
+use crate::hypervisor::virtual_machine::VmExit;
+
+pub(crate) mod direct;
+pub(crate) mod surrogate;
+
+pub(crate) use direct::HvfVm;
+pub(crate) use surrogate::HvfSurrogateVm;
+
+/// Return `true` if Hypervisor.framework is available.
+///
+/// Requires the calling binary to hold the `com.apple.security.hypervisor`
+/// entitlement; unsigned test binaries must be ad-hoc codesigned with it.
+#[instrument(skip_all, parent = Span::current(), level = "Trace")]
+pub(crate) fn is_hypervisor_present() -> bool {
+ core::is_hypervisor_present()
+}
+
+impl From for CommonRegisters {
+ fn from(r: Regs) -> Self {
+ CommonRegisters {
+ x: r.x,
+ sp: r.sp,
+ pc: r.pc,
+ pstate: r.pstate,
+ }
+ }
+}
+
+impl From<&Regs> for CommonRegisters {
+ fn from(r: &Regs) -> Self {
+ CommonRegisters {
+ x: r.x,
+ sp: r.sp,
+ pc: r.pc,
+ pstate: r.pstate,
+ }
+ }
+}
+
+impl From<&CommonRegisters> for Regs {
+ fn from(r: &CommonRegisters) -> Self {
+ Regs {
+ x: r.x,
+ sp: r.sp,
+ pc: r.pc,
+ pstate: r.pstate,
+ }
+ }
+}
+
+impl From for CommonFpu {
+ fn from(f: FpuState) -> Self {
+ CommonFpu {
+ v: f.v,
+ fpsr: f.fpsr,
+ fpcr: f.fpcr,
+ }
+ }
+}
+
+impl From<&FpuState> for CommonFpu {
+ fn from(f: &FpuState) -> Self {
+ CommonFpu {
+ v: f.v,
+ fpsr: f.fpsr,
+ fpcr: f.fpcr,
+ }
+ }
+}
+
+impl From<&CommonFpu> for FpuState {
+ fn from(f: &CommonFpu) -> Self {
+ FpuState {
+ v: f.v,
+ fpsr: f.fpsr,
+ fpcr: f.fpcr,
+ }
+ }
+}
+
+impl From for CommonSpecialRegisters {
+ fn from(s: Sregs) -> Self {
+ CommonSpecialRegisters {
+ ttbr0_el1: s.ttbr0_el1,
+ tcr_el1: s.tcr_el1,
+ mair_el1: s.mair_el1,
+ sctlr_el1: s.sctlr_el1,
+ cpacr_el1: s.cpacr_el1,
+ vbar_el1: s.vbar_el1,
+ sp_el1: s.sp_el1,
+ }
+ }
+}
+
+impl From<&CommonSpecialRegisters> for Sregs {
+ fn from(s: &CommonSpecialRegisters) -> Self {
+ Sregs {
+ ttbr0_el1: s.ttbr0_el1,
+ tcr_el1: s.tcr_el1,
+ mair_el1: s.mair_el1,
+ sctlr_el1: s.sctlr_el1,
+ cpacr_el1: s.cpacr_el1,
+ vbar_el1: s.vbar_el1,
+ sp_el1: s.sp_el1,
+ }
+ }
+}
+
+impl From for VmExit {
+ fn from(e: core::VmExit) -> Self {
+ match e {
+ core::VmExit::Halt => VmExit::Halt(),
+ core::VmExit::IoOut(port, data) => VmExit::IoOut(port, data),
+ core::VmExit::MmioRead(addr) => VmExit::MmioRead(addr),
+ core::VmExit::MmioWrite(addr) => VmExit::MmioWrite(addr),
+ core::VmExit::Cancelled => VmExit::Cancelled(),
+ core::VmExit::Unknown(msg) => VmExit::Unknown(msg),
+ }
+ }
+}
diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs
new file mode 100644
index 000000000..59cc24b2a
--- /dev/null
+++ b/src/hyperlight_host/src/hypervisor/virtual_machine/hvf/surrogate.rs
@@ -0,0 +1,304 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! Surrogate-process HVF backend.
+//!
+//! HVF allows only one VM per process, so this backend delegates the VM to
+//! an `hvf_surrogate` server process (acquired from the
+//! [`HvfSurrogateProcessManager`] pool) and forwards every
+//! [`VirtualMachine`] operation over the [`hyperlight_hvf::proto`] IPC
+//! protocol.
+//!
+//! Socket discipline: the sandbox thread performs request→response cycles;
+//! the interrupt thread may send `Request::Cancel` concurrently. All frames
+//! (requests AND cancels) are written through one shared
+//! `Arc>` writer — `proto::write_frame` serializes each
+//! frame into a single buffer, so frames cannot interleave on the wire.
+//! Responses are read on a separate stream clone used only by the sandbox
+//! thread (`Cancel` has no response).
+
+use std::fmt;
+use std::os::unix::net::UnixStream;
+use std::sync::{Arc, Mutex};
+
+use hyperlight_hvf::core::Perms;
+use hyperlight_hvf::proto::{self, PROTO_VERSION, Request, Response};
+
+use crate::hypervisor::hvf_surrogate_manager::{
+ HvfSurrogateProcess, get_hvf_surrogate_process_manager,
+};
+use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters};
+use crate::hypervisor::virtual_machine::{
+ CreateVmError, HypervisorError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError,
+ UnmapMemoryError, VirtualMachine, VmExit,
+};
+#[cfg(feature = "trace_guest")]
+use crate::sandbox::trace::TraceContext as SandboxTraceContext;
+
+/// A [`VirtualMachine`] whose VM lives in a surrogate process.
+pub(crate) struct HvfSurrogateVm {
+ /// The pooled surrogate process; held for its `Drop`, which returns a
+ /// still-running process to the pool.
+ _process: HvfSurrogateProcess,
+ /// Shared writer for ALL frames (requests from the sandbox thread and
+ /// cancels from the interrupt thread). Also handed to the interrupt
+ /// handle via [`HvfSurrogateVm::cancel_writer`].
+ writer: Arc>,
+ /// Reader for responses; only the sandbox thread performs
+ /// request→response cycles, but the trait takes `&self` for register
+ /// reads, hence the mutex.
+ reader: Mutex,
+}
+
+// The fields are all Send + Sync; `fmt::Debug` is implemented manually
+// because `HvfSurrogateProcess` has no Debug impl.
+impl fmt::Debug for HvfSurrogateVm {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("HvfSurrogateVm").finish_non_exhaustive()
+ }
+}
+
+impl HvfSurrogateVm {
+ /// Acquire a surrogate process from the pool, complete the protocol
+ /// handshake, and create its VM.
+ pub(crate) fn new() -> std::result::Result {
+ let process = get_hvf_surrogate_process_manager()
+ .and_then(|m| m.get_surrogate_process())
+ .map_err(|e| CreateVmError::SurrogateProcess(format!("{e}")))?;
+ let reader = process
+ .socket()
+ .try_clone()
+ .map_err(|e| CreateVmError::SurrogateProcess(format!("failed to clone socket: {e}")))?;
+ let writer = process
+ .socket()
+ .try_clone()
+ .map_err(|e| CreateVmError::SurrogateProcess(format!("failed to clone socket: {e}")))?;
+
+ let vm = Self {
+ _process: process,
+ writer: Arc::new(Mutex::new(writer)),
+ reader: Mutex::new(reader),
+ };
+
+ match vm.request(&Request::Hello {
+ version: PROTO_VERSION,
+ }) {
+ Ok(Response::Hello { version }) if version == PROTO_VERSION => {}
+ other => {
+ return Err(CreateVmError::SurrogateProcess(format!(
+ "surrogate handshake failed: {other:?}"
+ )));
+ }
+ }
+ vm.request(&Request::CreateVm)
+ .and_then(expect_ok)
+ .map_err(CreateVmError::CreateVmFd)?;
+ Ok(vm)
+ }
+
+ /// The shared frame writer, handed to the interrupt handle so it can
+ /// send `Request::Cancel` concurrently with in-flight requests.
+ pub(crate) fn cancel_writer(&self) -> Arc> {
+ Arc::clone(&self.writer)
+ }
+
+ /// Send a request and wait for its response.
+ fn request(&self, req: &Request) -> std::result::Result {
+ {
+ let mut writer = self.writer.lock().map_err(|_| {
+ HypervisorError::HvfSurrogateError("surrogate socket lock poisoned".into())
+ })?;
+ proto::write_frame(&mut *writer, req).map_err(|e| {
+ HypervisorError::HvfSurrogateError(format!("failed to send request: {e}"))
+ })?;
+ }
+ let mut reader = self.reader.lock().map_err(|_| {
+ HypervisorError::HvfSurrogateError("surrogate socket lock poisoned".into())
+ })?;
+ proto::read_frame::(&mut *reader)
+ .map_err(|e| {
+ HypervisorError::HvfSurrogateError(format!("failed to read response: {e}"))
+ })?
+ .ok_or_else(|| {
+ HypervisorError::HvfSurrogateError("surrogate closed the connection".into())
+ })
+ }
+}
+
+impl Drop for HvfSurrogateVm {
+ fn drop(&mut self) {
+ // Best-effort: destroy the VM so the surrogate returns to its idle
+ // state and can be reused by another sandbox. The response is read
+ // to keep the stream in sync for the next client of this pooled
+ // process.
+ let _ = self.request(&Request::DestroyVm);
+ }
+}
+
+/// Extract `Response::Ok`, mapping `Err`/unexpected responses to a
+/// [`HypervisorError`].
+fn expect_ok(resp: Response) -> std::result::Result<(), HypervisorError> {
+ match resp {
+ Response::Ok => Ok(()),
+ Response::Err(e) => Err(HypervisorError::HvfSurrogateError(e)),
+ other => Err(HypervisorError::HvfSurrogateError(format!(
+ "unexpected response: {other:?}"
+ ))),
+ }
+}
+
+impl VirtualMachine for HvfSurrogateVm {
+ unsafe fn map_memory(
+ &mut self,
+ (slot, region): (u32, &crate::mem::memory_region::MemoryRegion),
+ ) -> std::result::Result<(), MapMemoryError> {
+ let perms = Perms {
+ read: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::READ),
+ write: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::WRITE),
+ exec: region
+ .flags
+ .contains(crate::mem::memory_region::MemoryRegionFlags::EXECUTE),
+ };
+ let (backing, size, gpa) = region.surrogate_backing();
+ self.request(&Request::MapMemory {
+ slot,
+ gpa,
+ size,
+ perms,
+ backing,
+ })
+ .and_then(expect_ok)
+ .map_err(MapMemoryError::Hypervisor)
+ }
+
+ fn unmap_memory(
+ &mut self,
+ (slot, region): (u32, &crate::mem::memory_region::MemoryRegion),
+ ) -> std::result::Result<(), UnmapMemoryError> {
+ self.request(&Request::UnmapMemory {
+ slot,
+ gpa: region.guest_region.start as u64,
+ size: (region.guest_region.end - region.guest_region.start) as u64,
+ })
+ .and_then(expect_ok)
+ .map_err(UnmapMemoryError::Hypervisor)
+ }
+
+ fn run_vcpu(
+ &mut self,
+ #[cfg(feature = "trace_guest")] tc: &mut SandboxTraceContext,
+ ) -> std::result::Result {
+ #[cfg(feature = "trace_guest")]
+ let _ = tc;
+ match self.request(&Request::RunVcpu) {
+ Ok(Response::Exit(exit)) => Ok(exit.into()),
+ Ok(Response::Err(e)) => {
+ Err(RunVcpuError::Unknown(HypervisorError::HvfSurrogateError(e)))
+ }
+ Ok(other) => Err(RunVcpuError::Unknown(HypervisorError::HvfSurrogateError(
+ format!("unexpected response: {other:?}"),
+ ))),
+ Err(e) => Err(RunVcpuError::Unknown(e)),
+ }
+ }
+
+ fn regs(&self) -> std::result::Result {
+ match self.request(&Request::GetRegs) {
+ Ok(Response::Regs(regs)) => Ok(regs.as_ref().into()),
+ Ok(Response::Err(e)) => Err(RegisterError::GetRegs(
+ HypervisorError::HvfSurrogateError(e),
+ )),
+ Ok(other) => Err(RegisterError::GetRegs(HypervisorError::HvfSurrogateError(
+ format!("unexpected response: {other:?}"),
+ ))),
+ Err(e) => Err(RegisterError::GetRegs(e)),
+ }
+ }
+
+ fn set_regs(&self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> {
+ self.request(&Request::SetRegs(Box::new(regs.into())))
+ .and_then(expect_ok)
+ .map_err(RegisterError::SetRegs)
+ }
+
+ fn fpu(&self) -> std::result::Result {
+ match self.request(&Request::GetFpu) {
+ Ok(Response::Fpu(fpu)) => Ok(fpu.as_ref().into()),
+ Ok(Response::Err(e)) => {
+ Err(RegisterError::GetFpu(HypervisorError::HvfSurrogateError(e)))
+ }
+ Ok(other) => Err(RegisterError::GetFpu(HypervisorError::HvfSurrogateError(
+ format!("unexpected response: {other:?}"),
+ ))),
+ Err(e) => Err(RegisterError::GetFpu(e)),
+ }
+ }
+
+ fn set_fpu(&self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> {
+ self.request(&Request::SetFpu(Box::new(fpu.into())))
+ .and_then(expect_ok)
+ .map_err(RegisterError::SetFpu)
+ }
+
+ fn sregs(&self) -> std::result::Result {
+ match self.request(&Request::GetSregs) {
+ Ok(Response::Sregs(sregs)) => Ok(sregs.into()),
+ Ok(Response::Err(e)) => Err(RegisterError::GetSregs(
+ HypervisorError::HvfSurrogateError(e),
+ )),
+ Ok(other) => Err(RegisterError::GetSregs(HypervisorError::HvfSurrogateError(
+ format!("unexpected response: {other:?}"),
+ ))),
+ Err(e) => Err(RegisterError::GetSregs(e)),
+ }
+ }
+
+ fn set_sregs(&self, sregs: &CommonSpecialRegisters) -> std::result::Result<(), RegisterError> {
+ self.request(&Request::SetSregs(sregs.into()))
+ .and_then(expect_ok)
+ .map_err(RegisterError::SetSregs)
+ }
+
+ fn debug_regs(
+ &self,
+ ) -> std::result::Result {
+ todo!("debug registers are not supported on aarch64")
+ }
+
+ fn set_debug_regs(
+ &self,
+ _drs: &crate::hypervisor::regs::CommonDebugRegs,
+ ) -> std::result::Result<(), RegisterError> {
+ todo!("debug registers are not supported on aarch64")
+ }
+
+ fn can_reset_vcpu(&self) -> bool {
+ true
+ }
+
+ fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> {
+ // HVF has no "vcpu init" operation like KVM's KVM_ARM_VCPU_INIT;
+ // the surrogate emulates it (`core::Vm::reset_vcpu`). Special
+ // registers are applied separately by the caller (`apply_sregs`).
+ self.request(&Request::ResetVcpu)
+ .and_then(expect_ok)
+ .map_err(ResetVcpuError::Hypervisor)
+ }
+}
diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs
index dac344711..dc4fa3c97 100644
--- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs
+++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs
@@ -28,6 +28,9 @@ use crate::mem::memory_region::MemoryRegion;
#[cfg(feature = "trace_guest")]
use crate::sandbox::trace::TraceContext as SandboxTraceContext;
+/// HVF (Hypervisor.framework) functionality (macOS, aarch64)
+#[cfg(hvf)]
+pub(crate) mod hvf;
/// KVM (Kernel-based Virtual Machine) functionality (linux)
#[cfg(kvm)]
pub(crate) mod kvm;
@@ -77,6 +80,12 @@ pub fn get_available_hypervisor() -> &'static Option {
} else {
None
}
+ } else if #[cfg(hvf)] {
+ if hvf::is_hypervisor_present() {
+ Some(HypervisorType::Hvf)
+ } else {
+ None
+ }
} else {
None
}
@@ -102,6 +111,9 @@ pub(crate) enum HypervisorType {
#[cfg(target_os = "windows")]
Whp,
+
+ #[cfg(hvf)]
+ Hvf,
}
/// Minimum XSAVE buffer size: 512 bytes legacy region + 64 bytes header.
@@ -122,6 +134,7 @@ compile_error!(
);
/// The various reasons a VM's vCPU can exit
+#[cfg_attr(not(any(kvm, mshv3, target_os = "windows", hvf)), allow(dead_code))]
pub(crate) enum VmExit {
/// The vCPU has exited due to a debug event (usually breakpoint)
#[cfg(gdb)]
@@ -145,10 +158,10 @@ pub(crate) enum VmExit {
Unknown(String),
/// The operation should be retried, for example this can happen on Linux where a call to run the CPU can return EAGAIN
#[cfg_attr(
- any(target_os = "windows", feature = "hw-interrupts"),
+ any(target_os = "windows", feature = "hw-interrupts", hvf),
expect(
dead_code,
- reason = "Retry() is never constructed on Windows or with hw-interrupts (EAGAIN causes continue instead)"
+ reason = "Retry() is never constructed on Windows, with hw-interrupts (EAGAIN causes continue instead), or on HVF"
)
)]
Retry(),
@@ -185,7 +198,7 @@ pub enum CreateVmError {
InitializeVm(HypervisorError),
#[error("Set Partition Property failed: {0}")]
SetPartitionProperty(HypervisorError),
- #[cfg(target_os = "windows")]
+ #[cfg(any(target_os = "windows", hvf))]
#[error("Surrogate process creation failed: {0}")]
SurrogateProcess(String),
}
@@ -305,6 +318,12 @@ pub enum HypervisorError {
#[cfg(target_os = "windows")]
#[error("Windows error: {0}")]
WindowsError(#[from] windows_result::Error),
+ #[cfg(hvf)]
+ #[error("{0}")]
+ HvfError(#[from] hyperlight_hvf::core::HvfError),
+ #[cfg(hvf)]
+ #[error("HVF surrogate error: {0}")]
+ HvfSurrogateError(String),
}
/// Trait for single-vCPU VMs. Provides a common interface for basic VM operations.
@@ -373,10 +392,12 @@ pub(crate) trait VirtualMachine: Debug + Send {
/// Single-operation vCPU reset
#[cfg(target_arch = "aarch64")]
+ #[cfg_attr(not(any(kvm, mshv3, hvf)), allow(dead_code))]
fn can_reset_vcpu(&self) -> bool {
false
}
#[cfg(target_arch = "aarch64")]
+ #[cfg_attr(not(any(kvm, mshv3, hvf)), allow(dead_code))]
fn reset_vcpu(&mut self) -> std::result::Result<(), ResetVcpuError> {
Err(ResetVcpuError::NotSupported)
}
diff --git a/src/hyperlight_host/src/mem/layout.rs b/src/hyperlight_host/src/mem/layout.rs
index 6422b9b11..561f53212 100644
--- a/src/hyperlight_host/src/mem/layout.rs
+++ b/src/hyperlight_host/src/mem/layout.rs
@@ -120,10 +120,10 @@ impl<'a> BaseGpaRegion<&'a [u8], &'a [u8]> {
BaseGpaRegion::Snapshot(sn) => sn,
BaseGpaRegion::Scratch(sc) => sc,
BaseGpaRegion::Mmap(r) => unsafe {
- #[allow(clippy::useless_conversion)]
- let host_region_base: usize = r.host_region.start.into();
- #[allow(clippy::useless_conversion)]
- let host_region_end: usize = r.host_region.end.into();
+ #[allow(clippy::useless_conversion, clippy::clone_on_copy)]
+ let host_region_base: usize = r.host_region.start.clone().into();
+ #[allow(clippy::useless_conversion, clippy::clone_on_copy)]
+ let host_region_end: usize = r.host_region.end.clone().into();
let len = host_region_end - host_region_base;
std::slice::from_raw_parts(host_region_base as *const u8, len)
},
diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs
index 5d839647a..b7b48f9d6 100644
--- a/src/hyperlight_host/src/mem/memory_region.rs
+++ b/src/hyperlight_host/src/mem/memory_region.rs
@@ -171,7 +171,10 @@ impl MemoryRegionType {
/// This trait is used to parameterize [`MemoryRegion_`]
pub trait MemoryRegionKind {
/// The type used to represent host memory addresses.
- type HostBaseType: Copy;
+ ///
+ /// This is only required to be [`Clone`] (not [`Copy`]) because the
+ /// macOS variant carries the name of the backing POSIX shm object.
+ type HostBaseType: Clone;
/// Computes an address by adding a size to a base address.
///
@@ -196,7 +199,7 @@ pub trait MemoryRegionKind {
#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)]
pub struct HostGuestMemoryRegion {}
-#[cfg(not(target_os = "windows"))]
+#[cfg(not(any(target_os = "windows", target_os = "macos")))]
impl MemoryRegionKind for HostGuestMemoryRegion {
type HostBaseType = usize;
@@ -276,6 +279,50 @@ impl MemoryRegionKind for HostGuestMemoryRegion {
}
}
+/// A [`HostRegionBase`] keeps track of not just a host virtual address,
+/// but also the POSIX shm object backing it. This is used on macOS,
+/// where HVF allows only one VM per process: each sandbox's VM may live
+/// in a surrogate helper process that maps the same memory by
+/// `shm_open`-ing `name` and mapping it at `offset`.
+///
+/// The shm object spans the guard pages surrounding the usable region;
+/// `offset` is relative to the start of the object (i.e. it includes
+/// the leading guard page).
+#[cfg(target_os = "macos")]
+#[derive(Debug, PartialEq, Eq, Clone, Hash)]
+pub struct HostRegionBase {
+ /// Name of the backing POSIX shm object, as passed to `shm_open`.
+ /// Empty for file-backed regions (see `path`).
+ pub name: String,
+ /// Offset of the region start within the shm object or file
+ pub offset: usize,
+ /// Host virtual address of the region start
+ pub base: usize,
+ /// For file-backed regions ([`MemoryRegionType::MappedFile`]): the
+ /// filesystem path the surrogate process maps read-only instead of a
+ /// shm object.
+ pub path: Option,
+}
+#[cfg(target_os = "macos")]
+impl From for usize {
+ fn from(x: HostRegionBase) -> usize {
+ x.base
+ }
+}
+#[cfg(target_os = "macos")]
+impl MemoryRegionKind for HostGuestMemoryRegion {
+ type HostBaseType = HostRegionBase;
+
+ fn add(base: Self::HostBaseType, size: usize) -> Self::HostBaseType {
+ HostRegionBase {
+ name: base.name,
+ offset: base.offset + size,
+ base: base.base + size,
+ path: base.path,
+ }
+ }
+}
+
/// Type for memory regions that only track guest addresses.
///
#[derive(Debug, PartialEq, Eq, Copy, Clone, Hash)]
@@ -306,6 +353,35 @@ pub struct MemoryRegion_ {
/// A memory region that tracks both host and guest addresses.
pub type MemoryRegion = MemoryRegion_;
+#[cfg(hvf)]
+impl MemoryRegion {
+ /// Extract the surrogate-IPC view of this region: the memory object
+ /// backing it (a POSIX shm object name, or a filesystem path for
+ /// file-backed regions), the region size, and its guest physical
+ /// address.
+ ///
+ /// Used when delegating the mapping to the HVF surrogate process;
+ /// see [`hyperlight_hvf::proto::Request::MapMemory`].
+ pub(crate) fn surrogate_backing(&self) -> (hyperlight_hvf::proto::Backing, u64, u64) {
+ let start = &self.host_region.start;
+ let backing = match &start.path {
+ Some(path) => hyperlight_hvf::proto::Backing::File {
+ path: path.clone(),
+ offset: start.offset as u64,
+ },
+ None => hyperlight_hvf::proto::Backing::Shm {
+ name: start.name.clone(),
+ offset: start.offset as u64,
+ },
+ };
+ (
+ backing,
+ (self.guest_region.end - self.guest_region.start) as u64,
+ self.guest_region.start as u64,
+ )
+ }
+}
+
/// A [`MemoryRegionKind`] for crash dump regions that always uses raw
/// `usize` host addresses. The crash dump path only reads host memory
/// through raw pointers, so it never needs the file-mapping metadata
@@ -353,10 +429,10 @@ impl MemoryRegionVecBuilder {
) -> usize {
if self.regions.is_empty() {
let guest_end = self.guest_base_phys_addr + size;
- let host_end = ::add(self.host_base_virt_addr, size);
+ let host_end = ::add(self.host_base_virt_addr.clone(), size);
self.regions.push(MemoryRegion_ {
guest_region: self.guest_base_phys_addr..guest_end,
- host_region: self.host_base_virt_addr..host_end,
+ host_region: self.host_base_virt_addr.clone()..host_end,
flags,
region_type,
});
@@ -366,10 +442,10 @@ impl MemoryRegionVecBuilder {
#[allow(clippy::unwrap_used)]
// we know this is safe because we check if the regions are empty above
let last_region = self.regions.last().unwrap();
- let host_end = ::add(last_region.host_region.end, size);
+ let host_end = ::add(last_region.host_region.end.clone(), size);
let new_region = MemoryRegion_ {
guest_region: last_region.guest_region.end..last_region.guest_region.end + size,
- host_region: last_region.host_region.end..host_end,
+ host_region: last_region.host_region.end.clone()..host_end,
flags,
region_type,
};
diff --git a/src/hyperlight_host/src/mem/shared_mem.rs b/src/hyperlight_host/src/mem/shared_mem.rs
index 4b706cc41..76e502839 100644
--- a/src/hyperlight_host/src/mem/shared_mem.rs
+++ b/src/hyperlight_host/src/mem/shared_mem.rs
@@ -18,7 +18,7 @@ use std::any::type_name;
use std::ffi::c_void;
use std::io::Error;
use std::mem::{align_of, size_of};
-#[cfg(target_os = "linux")]
+#[cfg(unix)]
use std::ptr::null_mut;
use std::sync::{Arc, RwLock};
@@ -99,6 +99,20 @@ macro_rules! generate_writer {
pub struct HostMapping {
#[cfg(not(target_os = "windows"))]
mmap: Mmap,
+ /// Number of usable bytes between the guard regions, i.e. the
+ /// size reported by [`SharedMemory::mem_size`]. This is the
+ /// originally requested size; the mapped span between the guards
+ /// may be larger, since it is rounded up to the host page size so
+ /// that the trailing guard starts on a host-page boundary (see
+ /// [`guard_page_size`]).
+ #[cfg(not(target_os = "windows"))]
+ usable_size: usize,
+ /// The POSIX shm object backing `mmap`. Kept open (and named) for
+ /// as long as the mapping exists so a surrogate process can map the
+ /// same memory by name. Declared after `mmap` so that drop order is
+ /// `munmap` first, then `close` + `shm_unlink`.
+ #[cfg(target_os = "macos")]
+ shm: ShmBacking,
#[cfg(target_os = "windows")]
mapping: WindowsMapping,
}
@@ -177,15 +191,96 @@ impl HostMapping {
}
}
+/// RAII guard for a POSIX shm object created with `shm_open`. Closes
+/// the fd and unlinks the name on drop.
+///
+/// macOS only: all guest memory is backed by named shm objects so that
+/// the HVF surrogate process (which owns the VM when multiple sandboxes
+/// coexist; HVF allows only one VM per process) can map the same memory
+/// by name.
+#[cfg(target_os = "macos")]
+#[derive(Debug)]
+struct ShmBacking {
+ /// Object name as passed to `shm_open` (e.g. `/hl--`)
+ name: String,
+ /// Open descriptor for the object
+ fd: std::os::fd::RawFd,
+}
+
+#[cfg(target_os = "macos")]
+impl ShmBacking {
+ /// Create a new, uniquely-named shm object of `size` bytes.
+ ///
+ /// The name has the form `/hl--`; macOS limits shm
+ /// names to `PSHMNAMELEN` (31) characters, so keep it short.
+ fn create(size: usize) -> Result {
+ use std::ffi::CString;
+
+ use rand::RngExt;
+
+ let pid = std::process::id();
+ let mut rng = rand::rng();
+ // Retry on the (unlikely) name collision with an existing object.
+ loop {
+ let name = format!("/hl-{pid:x}-{:016x}", rng.random::());
+ debug_assert!(name.len() <= 31, "shm name too long: {name}");
+ let c_name = CString::new(name.as_str())
+ .map_err(|e| new_error!("shm name contains NUL byte: {}", e))?;
+ // SAFETY: `c_name` is a valid NUL-terminated string and the
+ // flags/mode are valid; on success the returned fd is ours.
+ let fd = unsafe {
+ libc::shm_open(
+ c_name.as_ptr(),
+ libc::O_RDWR | libc::O_CREAT | libc::O_EXCL,
+ 0o600,
+ )
+ };
+ if fd < 0 {
+ let err = Error::last_os_error();
+ if err.raw_os_error() == Some(libc::EEXIST) {
+ continue;
+ }
+ log_then_return!(HyperlightError::MemoryAllocationFailed(err.raw_os_error()));
+ }
+ // SAFETY: `fd` is a valid descriptor for the object we just
+ // created. On failure, clean up the object before returning.
+ if unsafe { libc::ftruncate(fd, size as libc::off_t) } != 0 {
+ let err = Error::last_os_error();
+ unsafe {
+ libc::close(fd);
+ libc::shm_unlink(c_name.as_ptr());
+ }
+ log_then_return!(HyperlightError::MemoryAllocationFailed(err.raw_os_error()));
+ }
+ return Ok(Self { name, fd });
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+impl Drop for ShmBacking {
+ fn drop(&mut self) {
+ // Errors are ignored: the surrogate process may have unlinked
+ // the name first, and there is nothing useful to do about a
+ // failed close here anyway.
+ unsafe {
+ libc::close(self.fd);
+ if let Ok(c_name) = std::ffi::CString::new(self.name.as_str()) {
+ libc::shm_unlink(c_name.as_ptr());
+ }
+ }
+ }
+}
+
/// RAII guard for an `mmap` reservation. Calls `munmap` on drop.
-#[cfg(target_os = "linux")]
+#[cfg(unix)]
#[derive(Debug)]
struct Mmap {
base: *mut c_void,
len: usize,
}
-#[cfg(target_os = "linux")]
+#[cfg(unix)]
impl Drop for Mmap {
fn drop(&mut self) {
// SAFETY: `self.base` and `self.len` are exactly what was
@@ -202,6 +297,102 @@ impl Drop for Mmap {
}
}
+/// Size of each guard region surrounding a shared memory mapping.
+///
+/// `mprotect` requires host-page-aligned addresses and lengths, so a
+/// guard region must span a whole host page. The host page size can
+/// exceed the guest page size ([`PAGE_SIZE_USIZE`]) — e.g. 16 KiB on
+/// Apple silicon, or 64 KiB on some aarch64 Linux kernels — so use
+/// the larger of the two.
+#[cfg(unix)]
+fn guard_page_size() -> usize {
+ page_size::get().max(PAGE_SIZE_USIZE)
+}
+
+/// macOS: create a `[guard][usable][guard]` mapping backed by a named
+/// POSIX shm object, so that the HVF surrogate process can map the same
+/// memory by name.
+///
+/// The layout matches the anonymous-mapping unix path: the shm object
+/// (and the mapping) spans both guard pages, the usable region starts
+/// one host page ([`guard_page_size`]) into the object, and the span
+/// between the guards is rounded up to the host page size. Only
+/// `usable_size` bytes are exposed as usable memory (via
+/// [`HostMapping::usable_size`]); the padding is mapped read-write but
+/// is otherwise invisible.
+#[cfg(target_os = "macos")]
+fn shm_backed_host_mapping(usable_size: usize) -> Result {
+ use libc::{MAP_FAILED, MAP_SHARED, PROT_NONE, PROT_READ, PROT_WRITE, mmap, mprotect};
+
+ let guard_size = guard_page_size();
+ let mapped_size = usable_size
+ .checked_next_multiple_of(guard_size)
+ .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?;
+ let total_size = mapped_size
+ .checked_add(2 * guard_size) // guard region around the memory
+ .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?;
+
+ // usize and isize are guaranteed to be the same size, and
+ // isize::MAX should be positive, so this cast should be safe.
+ if total_size > isize::MAX as usize {
+ return Err(HyperlightError::MemoryRequestTooBig(
+ total_size,
+ isize::MAX as usize,
+ ));
+ }
+
+ let shm = ShmBacking::create(total_size)?;
+
+ // SAFETY: `shm.fd` is a valid descriptor for a shm object of
+ // `total_size` bytes, and mapping it in full with `MAP_SHARED` has
+ // no other preconditions; the kernel picks the address.
+ let addr = unsafe {
+ mmap(
+ null_mut(),
+ total_size as libc::size_t,
+ PROT_READ | PROT_WRITE,
+ MAP_SHARED,
+ shm.fd,
+ 0 as libc::off_t,
+ )
+ };
+ if addr == MAP_FAILED {
+ log_then_return!(HyperlightError::MmapFailed(
+ Error::last_os_error().raw_os_error()
+ ));
+ }
+ let mmap = Mmap {
+ base: addr,
+ len: total_size,
+ };
+
+ // protect the guard regions (one host page each)
+ let res = unsafe { mprotect(mmap.base, guard_size, PROT_NONE) };
+ if res != 0 {
+ return Err(HyperlightError::MprotectFailed(
+ Error::last_os_error().raw_os_error(),
+ ));
+ }
+ let res = unsafe {
+ mprotect(
+ (mmap.base as *const u8).add(total_size - guard_size) as *mut c_void,
+ guard_size,
+ PROT_NONE,
+ )
+ };
+ if res != 0 {
+ return Err(HyperlightError::MprotectFailed(
+ Error::last_os_error().raw_os_error(),
+ ));
+ }
+
+ Ok(HostMapping {
+ mmap,
+ usable_size,
+ shm,
+ })
+}
+
/// RAII guard for a Win32 mapped view. Calls `UnmapViewOfFile` on drop.
#[cfg(target_os = "windows")]
#[derive(Debug)]
@@ -401,7 +592,14 @@ pub trait SharedMemory {
/// need to be marked as `unsafe` because doing anything with this
/// pointer itself requires `unsafe`.
fn base_addr(&self) -> usize {
- self.region().ptr() as usize + PAGE_SIZE_USIZE
+ #[cfg(not(windows))]
+ {
+ self.region().ptr() as usize + guard_page_size()
+ }
+ #[cfg(windows)]
+ {
+ self.region().ptr() as usize + PAGE_SIZE_USIZE
+ }
}
/// Return the base address of the host mapping of this region as
@@ -409,14 +607,30 @@ pub trait SharedMemory {
/// not need to be marked as `unsafe` because doing anything with
/// this pointer itself requires `unsafe`.
fn base_ptr(&self) -> *mut u8 {
- self.region().ptr().wrapping_add(PAGE_SIZE_USIZE)
+ #[cfg(not(windows))]
+ {
+ self.region().ptr().wrapping_add(guard_page_size())
+ }
+ #[cfg(windows)]
+ {
+ self.region().ptr().wrapping_add(PAGE_SIZE_USIZE)
+ }
}
/// Return the length of usable memory contained in `self`.
/// The returned size does not include the size of the surrounding
- /// guard pages.
+ /// guard regions, nor (on unix) any padding inserted to align the
+ /// trailing guard to the host page size: it is exactly the size
+ /// requested when the region was created.
fn mem_size(&self) -> usize {
- self.region().size() - 2 * PAGE_SIZE_USIZE
+ #[cfg(not(windows))]
+ {
+ self.region().usable_size
+ }
+ #[cfg(windows)]
+ {
+ self.region().size() - 2 * PAGE_SIZE_USIZE
+ }
}
/// Return the raw base address of the host mapping, including the
@@ -437,11 +651,23 @@ pub trait SharedMemory {
/// On Linux this returns a raw `usize` pointer. On Windows it
/// returns a [`HostRegionBase`](super::memory_region::HostRegionBase)
/// that carries the file-mapping handle metadata needed by WHP.
+ /// On macOS it returns a [`HostRegionBase`](super::memory_region::HostRegionBase)
+ /// that carries the shm object name + offset needed by the HVF
+ /// surrogate process.
fn host_region_base(&self) -> ::HostBaseType {
- #[cfg(not(windows))]
+ #[cfg(all(not(windows), not(target_os = "macos")))]
{
self.base_addr()
}
+ #[cfg(target_os = "macos")]
+ {
+ super::memory_region::HostRegionBase {
+ name: self.region().shm.name.clone(),
+ offset: guard_page_size(),
+ base: self.base_addr(),
+ path: None,
+ }
+ }
#[cfg(windows)]
{
super::memory_region::HostRegionBase {
@@ -537,7 +763,7 @@ impl ExclusiveSharedMemory {
/// size in bytes. The region will be surrounded by guard pages.
///
/// Return `Err` if shared memory could not be allocated.
- #[cfg(target_os = "linux")]
+ #[cfg(all(unix, not(target_os = "macos")))]
#[instrument(skip_all, parent = Span::current(), level= "Trace")]
pub fn new(min_size_bytes: usize) -> Result {
use libc::{
@@ -551,17 +777,32 @@ impl ExclusiveSharedMemory {
return Err(new_error!("Cannot create shared memory with size 0"));
}
- let total_size = min_size_bytes
- .checked_add(2 * PAGE_SIZE_USIZE) // guard page around the memory
- .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?;
-
- if total_size % PAGE_SIZE_USIZE != 0 {
+ if min_size_bytes % PAGE_SIZE_USIZE != 0 {
return Err(new_error!(
"shared memory must be a multiple of {}",
PAGE_SIZE_USIZE
));
}
+ // The guard regions must each span a whole host page:
+ // `mprotect` requires host-page-aligned addresses and lengths,
+ // and the host page size may exceed the guest page size (see
+ // `guard_page_size`).
+ let guard_size = guard_page_size();
+
+ // Round the mapped span between the guards up to a host-page
+ // multiple so that the trailing guard starts on a host-page
+ // boundary. Only the requested `min_size_bytes` is exposed as
+ // usable memory (via `HostMapping::usable_size`); the padding
+ // is mapped read-write but is otherwise invisible.
+ let mapped_size = min_size_bytes
+ .checked_next_multiple_of(guard_size)
+ .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?;
+
+ let total_size = mapped_size
+ .checked_add(2 * guard_size) // guard region around the memory
+ .ok_or_else(|| new_error!("Memory required for sandbox exceeded usize::MAX"))?;
+
// usize and isize are guaranteed to be the same size, and
// isize::MAX should be positive, so this cast should be safe.
if total_size > isize::MAX as usize {
@@ -597,10 +838,10 @@ impl ExclusiveSharedMemory {
len: total_size,
};
- // protect the guard pages
+ // protect the guard regions (one host page each)
#[cfg(not(miri))]
{
- let res = unsafe { mprotect(mmap.base, PAGE_SIZE_USIZE, PROT_NONE) };
+ let res = unsafe { mprotect(mmap.base, guard_size, PROT_NONE) };
if res != 0 {
return Err(HyperlightError::MprotectFailed(
Error::last_os_error().raw_os_error(),
@@ -608,8 +849,8 @@ impl ExclusiveSharedMemory {
}
let res = unsafe {
mprotect(
- (mmap.base as *const u8).add(total_size - PAGE_SIZE_USIZE) as *mut c_void,
- PAGE_SIZE_USIZE,
+ (mmap.base as *const u8).add(total_size - guard_size) as *mut c_void,
+ guard_size,
PROT_NONE,
)
};
@@ -630,7 +871,46 @@ impl ExclusiveSharedMemory {
// type does have Send and Sync manually impl'd, the Arc
// is not pointless as the lint suggests.
#[allow(clippy::arc_with_non_send_sync)]
- region: Arc::new(HostMapping { mmap }),
+ region: Arc::new(HostMapping {
+ mmap,
+ usable_size: min_size_bytes,
+ }),
+ })
+ }
+
+ /// Create a new region of shared memory with the given minimum
+ /// size in bytes. The region will be surrounded by guard pages.
+ ///
+ /// On macOS the region is backed by a named POSIX shm object (see
+ /// [`shm_backed_host_mapping`]) so that the HVF surrogate process
+ /// can map the same memory by name.
+ ///
+ /// Return `Err` if shared memory could not be allocated.
+ #[cfg(target_os = "macos")]
+ #[instrument(skip_all, parent = Span::current(), level= "Trace")]
+ pub fn new(min_size_bytes: usize) -> Result {
+ if min_size_bytes == 0 {
+ return Err(new_error!("Cannot create shared memory with size 0"));
+ }
+
+ if !min_size_bytes.is_multiple_of(PAGE_SIZE_USIZE) {
+ return Err(new_error!(
+ "shared memory must be a multiple of {}",
+ PAGE_SIZE_USIZE
+ ));
+ }
+
+ Ok(Self {
+ // HostMapping is only non-Send/Sync because raw pointers
+ // are not ("as a lint", as the Rust docs say). We don't
+ // want to mark HostMapping Send/Sync immediately, because
+ // that could socially imply that it's "safe" to use
+ // unsafe accesses from multiple threads at once. Instead, we
+ // directly impl Send and Sync on this type. Since this
+ // type does have Send and Sync manually impl'd, the Arc
+ // is not pointless as the lint suggests.
+ #[allow(clippy::arc_with_non_send_sync)]
+ region: Arc::new(shm_backed_host_mapping(min_size_bytes)?),
})
}
@@ -1560,10 +1840,11 @@ impl ReadonlySharedMemory {
})
}
- /// Linux: reserve `[guard][blob][guard]` as one anonymous
+ /// Unix: reserve `[guard][blob][guard]` as one anonymous
/// `PROT_NONE` mapping, then `MAP_FIXED` the file over the
- /// middle slot.
- #[cfg(target_os = "linux")]
+ /// middle slot. Each guard spans one host page (see
+ /// [`guard_page_size`]).
+ #[cfg(all(unix, not(target_os = "macos")))]
fn map_file(file: &std::fs::File, len: usize) -> Result> {
use std::os::unix::io::AsRawFd;
@@ -1574,7 +1855,17 @@ impl ReadonlySharedMemory {
mmap, off_t, size_t,
};
- let total_size = len.checked_add(2 * PAGE_SIZE_USIZE).ok_or_else(|| {
+ // The guard regions must each span a whole host page (see
+ // `guard_page_size`), so round the middle slot up to a
+ // host-page multiple to keep the trailing guard host-page
+ // aligned. Only `len` bytes are exposed as usable memory; any
+ // padding between the end of the file and the trailing guard
+ // keeps the reservation's `PROT_NONE`.
+ let guard_size = guard_page_size();
+ let mapped_size = len.checked_next_multiple_of(guard_size).ok_or_else(|| {
+ new_error!("Memory required for file-backed mapping exceeded usize::MAX")
+ })?;
+ let total_size = mapped_size.checked_add(2 * guard_size).ok_or_else(|| {
new_error!("Memory required for file-backed mapping exceeded usize::MAX")
})?;
@@ -1617,9 +1908,9 @@ impl ReadonlySharedMemory {
let file_prot = PROT_READ | PROT_WRITE;
#[cfg(not(mshv3))]
let file_prot = PROT_READ;
- // SAFETY: `total_size = len + 2 * PAGE_SIZE_USIZE`, so
- // `base + PAGE_SIZE_USIZE` is in-bounds of the reservation.
- let usable_ptr = unsafe { (base as *mut u8).add(PAGE_SIZE_USIZE) };
+ // SAFETY: `total_size = mapped_size + 2 * guard_size`, so
+ // `base + guard_size` is in-bounds of the reservation.
+ let usable_ptr = unsafe { (base as *mut u8).add(guard_size) };
// SAFETY: `usable_ptr..usable_ptr + len` lies entirely within
// the reservation owned by `reservation`. `MAP_FIXED`
// replaces that sub-range in place; on failure the
@@ -1641,11 +1932,34 @@ impl ReadonlySharedMemory {
));
}
- // 3. The first and last pages keep their `PROT_NONE` from the
- // anonymous reservation, so no extra `mprotect` is needed.
+ // 3. The first and last host pages keep their `PROT_NONE` from
+ // the anonymous reservation, so no extra `mprotect` is
+ // needed.
#[allow(clippy::arc_with_non_send_sync)]
- Ok(Arc::new(HostMapping { mmap: reservation }))
+ Ok(Arc::new(HostMapping {
+ mmap: reservation,
+ usable_size: len,
+ }))
+ }
+
+ /// macOS: read the file contents into a fresh named POSIX shm
+ /// object with the usual `[guard][blob][guard]` layout (the same
+ /// layout as the mmap'd file has on other unix platforms).
+ ///
+ /// The surrogate process maps guest memory by shm object name — it
+ /// cannot be handed an fd — so mapping the file directly is not an
+ /// option; snapshot files are small, so copying is fine. This is
+ /// exactly the `from_bytes` pattern: allocate shm-backed memory,
+ /// then copy the contents in.
+ #[cfg(target_os = "macos")]
+ fn map_file(file: &std::fs::File, len: usize) -> Result> {
+ use std::os::unix::fs::FileExt;
+
+ let mut anon = ExclusiveSharedMemory::new(len)?;
+ file.read_exact_at(anon.as_mut_slice(), 0)
+ .map_err(|e| new_error!("Failed to read file into shared memory: {}", e))?;
+ Ok(anon.region)
}
/// Windows: reserve `[guard][blob][guard]` as one
@@ -2001,7 +2315,7 @@ mod tests {
/// Test that verifies memory is properly unmapped when all SharedMemory
/// references are dropped.
#[test]
- #[cfg(all(target_os = "linux", not(miri)))]
+ #[cfg(all(unix, not(miri)))]
fn test_drop() {
use proc_maps::get_process_maps;
@@ -2022,9 +2336,12 @@ mod tests {
let (hshm1, gshm) = eshm.build();
let hshm2 = hshm1.clone();
- // Use the usable memory region (not raw), since mprotect splits the mapping
+ // Use the usable memory region (not raw), since mprotect splits the mapping.
+ // The span between the guards is rounded up to the host page
+ // size, which may exceed PAGE_SIZE_USIZE (e.g. 16 KiB on Apple
+ // silicon); `mem_size()` only reports the requested size.
let base_ptr = hshm1.base_ptr() as usize;
- let mem_size = hshm1.mem_size();
+ let mem_size = hshm1.mem_size().next_multiple_of(super::guard_page_size());
// Helper to check if exact mapping exists (matching both address and size)
let has_exact_mapping = |ptr: usize, size: usize| -> bool {
@@ -2458,7 +2775,7 @@ mod tests {
}
}
- #[cfg(target_os = "linux")]
+ #[cfg(unix)]
mod guard_page_crash_test {
use crate::mem::shared_mem::{ExclusiveSharedMemory, SharedMemory};
@@ -2473,6 +2790,14 @@ mod tests {
std::process::exit(TEST_EXIT_CODE.into());
})
.unwrap();
+ // macOS reports accesses to PROT_NONE pages as
+ // EXC_BAD_ACCESS/KERN_PROTECTION_FAILURE, which is
+ // delivered as SIGBUS rather than SIGSEGV.
+ #[cfg(target_os = "macos")]
+ signal_hook_registry::register_signal_unchecked(libc::SIGBUS, || {
+ std::process::exit(TEST_EXIT_CODE.into());
+ })
+ .unwrap();
}
}
@@ -2514,18 +2839,17 @@ mod tests {
#[test]
#[cfg_attr(miri, ignore)] // miri can't spawn subprocesses
fn guard_page_testing_shim() {
- let tests = vec!["read", "write", "exec"];
+ let tests = [test_path(read), test_path(write), test_path(exec)];
+ // Re-execute the current test binary so the subprocess
+ // runs with the same feature set as this test run. A
+ // nested `cargo test` would rebuild with default
+ // features, which do not compile on every supported host
+ // (e.g. the default `kvm`/`mshv3` features fail to build
+ // on macOS).
+ let exe = std::env::current_exe().expect("current_exe");
for test in tests {
- let triple = std::env::var("TARGET_TRIPLE").ok();
- let target_args = if let Some(triple) = triple.filter(|t| !t.is_empty()) {
- vec!["--target".to_string(), triple.to_string()]
- } else {
- vec![]
- };
- let output = std::process::Command::new("cargo")
- .args(["test", "-p", "hyperlight-host", "--lib"])
- .args(target_args)
- .args(["--", "--ignored", test])
+ let output = std::process::Command::new(&exe)
+ .args(["--ignored", "--exact", "--test-threads=1", test])
.stdin(std::process::Stdio::null())
.output()
.expect("Unable to launch tests");
@@ -2544,6 +2868,17 @@ mod tests {
}
}
}
+
+ /// Derive a libtest filter path for a test function from its
+ /// type name. Strips the leading crate-name segment that
+ /// `type_name` includes but libtest does not.
+ fn test_path(_: F) -> &'static str {
+ let full = std::any::type_name::();
+ let (_, rest) = full
+ .split_once("::")
+ .expect("type_name of a function item is always qualified by the crate name");
+ rest
+ }
}
#[cfg(not(miri))]
@@ -2654,14 +2989,25 @@ mod tests {
println!("survived_guard");
}
- /// Loads from the byte immediately after the mapping.
+ /// Loads from the byte immediately after the mapped span.
#[test]
#[ignore]
pub(super) fn trailing_guard_page_traps() {
let tmp = make_temp_file(PAGE_SIZE_USIZE);
let rsm = ReadonlySharedMemory::from_file(tmp.as_file(), PAGE_SIZE_USIZE)
.expect("from_file should succeed");
- let guard_ptr = unsafe { rsm.base_ptr().add(rsm.mem_size()) };
+ // The mapped span between the guards is `mem_size()`
+ // rounded up to the guard size, so the trailing guard
+ // starts past the padding. The guard spans one host
+ // page on unix, which may exceed PAGE_SIZE_USIZE
+ // (e.g. 16 KiB on Apple silicon).
+ #[cfg(unix)]
+ let mapped_span = rsm
+ .mem_size()
+ .next_multiple_of(crate::mem::shared_mem::guard_page_size());
+ #[cfg(windows)]
+ let mapped_span = rsm.mem_size();
+ let guard_ptr = unsafe { rsm.base_ptr().add(mapped_span) };
println!("reached_guard");
let _ = unsafe { std::ptr::read_volatile(guard_ptr) };
println!("survived_guard");
@@ -2761,13 +3107,22 @@ mod tests {
}
/// Returns true if `status` indicates the process died from a
- /// memory access fault (SIGSEGV on unix, STATUS_ACCESS_VIOLATION
- /// (or 0xDEAD) on Windows).
+ /// memory access fault (SIGSEGV on unix, SIGBUS on macOS —
+ /// which reports faults on PROT_NONE pages as
+ /// EXC_BAD_ACCESS/KERN_PROTECTION_FAILURE delivered as SIGBUS —
+ /// or STATUS_ACCESS_VIOLATION (or 0xDEAD) on Windows).
fn killed_by_access_violation(status: &std::process::ExitStatus) -> bool {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
- status.signal() == Some(libc::SIGSEGV)
+ let access_violation_signals = if cfg!(target_os = "macos") {
+ [libc::SIGSEGV, libc::SIGBUS].as_slice()
+ } else {
+ [libc::SIGSEGV].as_slice()
+ };
+ status
+ .signal()
+ .is_some_and(|s| access_violation_signals.contains(&s))
}
#[cfg(windows)]
{
diff --git a/src/hyperlight_host/src/sandbox/config.rs b/src/hyperlight_host/src/sandbox/config.rs
index f12387a0b..d83dbe4b4 100644
--- a/src/hyperlight_host/src/sandbox/config.rs
+++ b/src/hyperlight_host/src/sandbox/config.rs
@@ -142,19 +142,19 @@ impl SandboxConfiguration {
}
/// Sets the interrupt retry delay
- #[cfg(target_os = "linux")]
+ #[cfg(unix)]
pub fn set_interrupt_retry_delay(&mut self, delay: Duration) {
self.interrupt_retry_delay = delay;
}
/// Get the delay between retries for interrupts
- #[cfg(target_os = "linux")]
+ #[cfg(unix)]
pub fn get_interrupt_retry_delay(&self) -> Duration {
self.interrupt_retry_delay
}
/// Get the signal offset from `SIGRTMIN` used to determine the signal number for interrupting the VCPU thread
- #[cfg(target_os = "linux")]
+ #[cfg(unix)]
pub fn get_interrupt_vcpu_sigrtmin_offset(&self) -> u8 {
self.interrupt_vcpu_sigrtmin_offset
}
diff --git a/src/hyperlight_host/src/sandbox/file_mapping.rs b/src/hyperlight_host/src/sandbox/file_mapping.rs
index 4f3ed2a4d..bbe50e120 100644
--- a/src/hyperlight_host/src/sandbox/file_mapping.rs
+++ b/src/hyperlight_host/src/sandbox/file_mapping.rs
@@ -36,6 +36,8 @@ use tracing::{Span, instrument};
use crate::HyperlightError;
#[cfg(target_os = "windows")]
use crate::hypervisor::wrappers::HandleWrapper;
+#[cfg(target_os = "macos")]
+use crate::mem::memory_region::{HostGuestMemoryRegion, HostRegionBase, MemoryRegionKind};
#[cfg(target_os = "windows")]
use crate::mem::memory_region::{HostRegionBase, MemoryRegionKind};
use crate::mem::memory_region::{MemoryRegion, MemoryRegionFlags, MemoryRegionType};
@@ -44,7 +46,7 @@ use crate::{Result, log_then_return};
/// A prepared (host-side) file mapping ready to be applied to a VM.
///
/// Created by [`prepare_file_cow`]. The host-side OS resources (file
-/// mapping handle + view on Windows, mmap on Linux) are held here
+/// mapping handle + view on Windows, mmap on Unix) are held here
/// until consumed by the VM-side apply step.
///
/// If dropped without being consumed, the `Drop` impl releases all
@@ -60,6 +62,10 @@ pub(crate) struct PreparedFileMapping {
/// Host-side OS resources. `None` after successful consumption
/// by the apply step (ownership transferred to the VM layer).
pub(crate) host_resources: Option,
+ /// The path of the mapped file. Retained on macOS so the HVF
+ /// surrogate process can map the same file read-only by path.
+ #[cfg(target_os = "macos")]
+ pub(crate) path: std::path::PathBuf,
}
/// Platform-specific host-side file mapping resources.
@@ -70,9 +76,9 @@ pub(crate) enum HostFileResources {
mapping_handle: HandleWrapper,
view_base: *mut c_void,
},
- /// Linux: `mmap` base pointer.
- #[cfg(target_os = "linux")]
- Linux {
+ /// Unix: `mmap` base pointer.
+ #[cfg(unix)]
+ Unix {
mmap_base: *mut c_void,
mmap_size: usize,
},
@@ -103,8 +109,8 @@ impl Drop for PreparedFileMapping {
tracing::error!("PreparedFileMapping::drop: CloseHandle failed: {:?}", e);
}
},
- #[cfg(target_os = "linux")]
- HostFileResources::Linux {
+ #[cfg(unix)]
+ HostFileResources::Unix {
mmap_base,
mmap_size,
} => unsafe {
@@ -121,7 +127,7 @@ impl Drop for PreparedFileMapping {
}
// SAFETY: The raw pointers in HostFileResources point to kernel-managed
-// mappings (Windows file mapping views / Linux mmap regions), not aliased
+// mappings (Windows file mapping views / Unix mmap regions), not aliased
// user-allocated heap memory. Ownership is fully contained within the
// struct, and cleanup APIs (UnmapViewOfFile, CloseHandle, munmap) are
// thread-safe.
@@ -168,8 +174,8 @@ impl PreparedFileMapping {
region_type: MemoryRegionType::MappedFile,
})
}
- #[cfg(target_os = "linux")]
- HostFileResources::Linux {
+ #[cfg(all(unix, not(target_os = "macos")))]
+ HostFileResources::Unix {
mmap_base,
mmap_size,
} => {
@@ -188,6 +194,37 @@ impl PreparedFileMapping {
region_type: MemoryRegionType::MappedFile,
})
}
+ #[cfg(target_os = "macos")]
+ HostFileResources::Unix {
+ mmap_base,
+ mmap_size,
+ } => {
+ let guest_start = self.guest_base as usize;
+ let guest_end = guest_start.checked_add(self.size).ok_or_else(|| {
+ crate::HyperlightError::Error(format!(
+ "guest_region overflow: {:#x} + {:#x}",
+ guest_start, self.size
+ ))
+ })?;
+ // MappedFile regions stay file-backed (the surrogate
+ // maps them by path, not by shm object), so there is no
+ // shm name to carry here; `base` is the host VA of the
+ // mapping, used by the in-process HVF backend.
+ let host_base = HostRegionBase {
+ name: String::new(),
+ offset: 0,
+ base: *mmap_base as usize,
+ path: Some(self.path.clone()),
+ };
+ let host_end =
+ ::add(host_base.clone(), *mmap_size);
+ Ok(MemoryRegion {
+ host_region: host_base..host_end,
+ guest_region: guest_start..guest_end,
+ flags: MemoryRegionFlags::READ | MemoryRegionFlags::EXECUTE,
+ region_type: MemoryRegionType::MappedFile,
+ })
+ }
}
}
@@ -348,10 +385,12 @@ pub(crate) fn prepare_file_cow(file_path: &Path, guest_base: u64) -> Result GuestSharedMemory {
- use hyperlight_common::mem::PAGE_SIZE_USIZE;
-
- let len = src.len().div_ceil(PAGE_SIZE_USIZE) * PAGE_SIZE_USIZE;
+ // Round up to the *host* page size: `map_region` requires
+ // host-page-aligned regions (4K on x86 hosts, 16K on arm64
+ // macOS), so tests must not assume 4K pages.
+ let len = src.len().div_ceil(page_size::get()) * page_size::get();
let mut mem = ExclusiveSharedMemory::new(len).unwrap();
mem.copy_from_slice(src, 0).unwrap();
@@ -2642,16 +2643,18 @@ mod tests {
u_sbox.evolve().unwrap()
};
- // Use multi-page regions so partial overlap is geometrically possible
- let mem1 = page_aligned_memory(&[0xAA; 8192]); // 2 pages
- let mem2 = page_aligned_memory(&[0xBB; 8192]); // 2 pages
+ // Use multi-page regions so partial overlap is geometrically
+ // possible (2 pages each, in host page units)
+ let ps = page_size::get();
+ let mem1 = page_aligned_memory(&vec![0xAA; 2 * ps]);
+ let mem2 = page_aligned_memory(&vec![0xBB; 2 * ps]);
let guest_base: usize = 0x200000000;
let region1 = region_for_memory(&mem1, guest_base, MemoryRegionFlags::READ);
unsafe { sbox.map_region(®ion1).unwrap() };
- // region2 starts one page before region1, overlapping by one page
- let overlap_base = guest_base - 0x1000;
+ // region2 starts one host page before region1, overlapping by one page
+ let overlap_base = guest_base - ps;
let region2 = region_for_memory(&mem2, overlap_base, MemoryRegionFlags::READ);
let err = unsafe { sbox.map_region(®ion2) }.unwrap_err();
assert!(
@@ -2690,13 +2693,14 @@ mod tests {
u_sbox.evolve().unwrap()
};
- // Try to map at BASE_ADDRESS (0x1000) which overlaps the snapshot region
+ // Try to map inside the snapshot region (which starts at
+ // BASE_ADDRESS = 0x1000). BASE_ADDRESS itself is not necessarily
+ // host-page-aligned (16K hosts), so map at the next host-page
+ // boundary — still inside the snapshot region.
+ let guest_base = crate::mem::layout::SandboxMemoryLayout::BASE_ADDRESS
+ .next_multiple_of(page_size::get());
let mem = allocate_guest_memory();
- let region = region_for_memory(
- &mem,
- crate::mem::layout::SandboxMemoryLayout::BASE_ADDRESS,
- MemoryRegionFlags::READ,
- );
+ let region = region_for_memory(&mem, guest_base, MemoryRegionFlags::READ);
let err = unsafe { sbox.map_region(®ion) }.unwrap_err();
assert!(
format!("{err:?}").contains("Overlapping"),
@@ -2712,12 +2716,16 @@ mod tests {
u_sbox.evolve().unwrap()
};
- // The scratch region occupies the top of the GPA space
+ // The scratch region occupies the top of the GPA space. Its base
+ // is not necessarily host-page-aligned (16K hosts), so align down
+ // to the host page boundary; the region still overlaps the scratch
+ // region's first page.
let scratch_addr = hyperlight_common::layout::scratch_base_gpa(
crate::sandbox::SandboxConfiguration::DEFAULT_SCRATCH_SIZE,
) as usize;
+ let guest_base = scratch_addr / page_size::get() * page_size::get();
let mem = allocate_guest_memory();
- let region = region_for_memory(&mem, scratch_addr, MemoryRegionFlags::READ);
+ let region = region_for_memory(&mem, guest_base, MemoryRegionFlags::READ);
let err = unsafe { sbox.map_region(®ion) }.unwrap_err();
assert!(
format!("{err:?}").contains("verlap"),
diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
index 5e9fe6c02..c4f7f81ff 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs
@@ -64,6 +64,7 @@ pub(super) enum Hypervisor {
Kvm,
Mshv,
Whp,
+ Hvf,
}
impl Hypervisor {
@@ -79,6 +80,12 @@ impl Hypervisor {
Some(HypervisorType::Mshv) => Some(Self::Mshv),
#[cfg(target_os = "windows")]
Some(HypervisorType::Whp) => Some(Self::Whp),
+ #[cfg(hvf)]
+ Some(HypervisorType::Hvf) => Some(Self::Hvf),
+ // `HypervisorType` has no variants when no backend is compiled
+ // (e.g. aarch64 without any hypervisor feature).
+ #[cfg(not(any(kvm, mshv3, target_os = "windows", hvf)))]
+ Some(_) => None,
None => None,
}
}
@@ -88,6 +95,7 @@ impl Hypervisor {
Self::Kvm => "KVM",
Self::Mshv => "MSHV",
Self::Whp => "WHP",
+ Self::Hvf => "HVF",
}
}
@@ -99,6 +107,7 @@ impl Hypervisor {
Self::Kvm => "kvm",
Self::Mshv => "mshv",
Self::Whp => "whp",
+ Self::Hvf => "hvf",
}
}
}
@@ -132,6 +141,12 @@ impl CpuVendor {
// `0x` prefix padded to width 4, e.g. Apple `0x61`, Arm `0x41`.
Self(format!("{implementer:#04x}"))
}
+ #[cfg(all(target_arch = "aarch64", target_os = "macos"))]
+ {
+ // macOS does not emulate MIDR_EL1 reads from EL0, but every
+ // aarch64 macOS host is Apple silicon (implementer `0x61`).
+ Self("0x61".to_string())
+ }
}
pub(super) fn as_str(&self) -> &str {
@@ -671,7 +686,7 @@ mod tests {
matches!(v, "GenuineIntel" | "AuthenticAMD"),
"unrecognized x86_64 CPU vendor: {v:?}"
);
- #[cfg(all(target_arch = "aarch64", target_os = "linux"))]
+ #[cfg(all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")))]
// MIDR_EL1 implementer byte for Apple silicon.
assert_eq!(v, "0x61", "unexpected aarch64 CPU implementer");
}
@@ -986,7 +1001,8 @@ mod schema_pin {
const PINNED_HYPERVISOR: &str = r#"[
"kvm",
"mshv",
- "whp"
+ "whp",
+ "hvf"
]"#;
fn assert_round_trip(pinned: &str) {
diff --git a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
index 4dae80a9e..b650f338e 100644
--- a/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
+++ b/src/hyperlight_host/src/sandbox/snapshot/file_tests.rs
@@ -498,6 +498,7 @@ fn cfg_current_hypervisor() -> &'static str {
"kvm" => "kvm",
"mshv" => "mshv",
"whp" => "whp",
+ "hvf" => "hvf",
other => panic!("unknown hypervisor tag {other}"),
}
}
@@ -1045,7 +1046,7 @@ fn save_appends_into_existing_layout_with_new_tag() {
);
let hv = anns["dev.hyperlight.snapshot.hypervisor"].as_str().unwrap();
assert!(
- ["kvm", "mshv", "whp"].contains(&hv),
+ ["kvm", "mshv", "whp", "hvf"].contains(&hv),
"unexpected hypervisor annotation: {}",
hv
);
diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs
index b449ea68d..3f30b9a0a 100644
--- a/src/hyperlight_host/tests/integration_test.rs
+++ b/src/hyperlight_host/tests/integration_test.rs
@@ -1472,7 +1472,13 @@ fn interrupt_infinite_moving_loop_stress_test() {
use std::thread;
// We have a high thread count to stress test and to have interesting interleavings
+ #[cfg(not(target_os = "macos"))]
const NUM_THREADS: usize = 200;
+ // macOS (HVF) caps concurrent VMs system-wide (measured 127 on macOS
+ // 26, arm64) and each thread holds two sandboxes; 48 threads (96 VMs)
+ // keeps the same interleavings comfortably below the platform cap.
+ #[cfg(target_os = "macos")]
+ const NUM_THREADS: usize = 48;
let mut handles = vec![];
@@ -1833,7 +1839,7 @@ fn memory_region_types_are_publicly_accessible() {
// MemoryRegion_ struct and all its fields are pub (struct literal
// construction requires every field to be pub).
- #[cfg(not(target_os = "windows"))]
+ #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
let base: ::HostBaseType = 0x1000;
let _region = MemoryRegion_:: {
@@ -1844,6 +1850,25 @@ fn memory_region_types_are_publicly_accessible() {
};
}
+ #[cfg(target_os = "macos")]
+ {
+ use hyperlight_host::mem::memory_region::HostRegionBase;
+
+ let host_base = HostRegionBase {
+ name: String::new(),
+ offset: 0,
+ base: 0x1000,
+ path: None,
+ };
+ let _region = MemoryRegion_:: {
+ guest_region: 0x1000..0x2000,
+ host_region: host_base.clone()
+ ..::add(host_base, 0x1000),
+ flags: MemoryRegionFlags::READ,
+ region_type: MemoryRegionType::Code,
+ };
+ }
+
#[cfg(target_os = "windows")]
{
use hyperlight_host::hypervisor::wrappers::HandleWrapper;
diff --git a/src/hyperlight_host/tests/snapshot_goldens/platform.rs b/src/hyperlight_host/tests/snapshot_goldens/platform.rs
index 54be335c9..9b9d864ea 100644
--- a/src/hyperlight_host/tests/snapshot_goldens/platform.rs
+++ b/src/hyperlight_host/tests/snapshot_goldens/platform.rs
@@ -26,12 +26,17 @@ use crate::goldens_version::GOLDENS_VERSION;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Hypervisor {
- #[cfg_attr(target_os = "windows", allow(dead_code))]
+ #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
Kvm,
- #[cfg_attr(target_os = "windows", allow(dead_code))]
+ #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
Mshv,
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
Whp,
+ #[cfg_attr(
+ not(all(target_os = "macos", target_arch = "aarch64", feature = "hvf")),
+ allow(dead_code)
+ )]
+ Hvf,
}
impl Hypervisor {
@@ -40,12 +45,14 @@ impl Hypervisor {
Self::Kvm => "kvm",
Self::Mshv => "mshv",
Self::Whp => "whp",
+ Self::Hvf => "hvf",
}
}
/// Detect the locally available hypervisor. Order matches the
/// host crate's preference: `/dev/mshv` over `/dev/kvm` on
- /// Linux, WHP on Windows.
+ /// Linux, WHP on Windows, HVF on aarch64 macOS (when the `hvf`
+ /// feature is compiled in).
fn detect() -> Option {
#[cfg(target_os = "linux")]
{
@@ -61,7 +68,15 @@ impl Hypervisor {
{
Some(Self::Whp)
}
- #[cfg(not(any(target_os = "linux", target_os = "windows")))]
+ #[cfg(all(target_os = "macos", target_arch = "aarch64", feature = "hvf"))]
+ {
+ Some(Self::Hvf)
+ }
+ #[cfg(not(any(
+ target_os = "linux",
+ target_os = "windows",
+ all(target_os = "macos", target_arch = "aarch64", feature = "hvf")
+ )))]
{
None
}
diff --git a/src/hyperlight_hvf/Cargo.toml b/src/hyperlight_hvf/Cargo.toml
new file mode 100644
index 000000000..6c091be97
--- /dev/null
+++ b/src/hyperlight_hvf/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "hyperlight-hvf"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+description = """
+Shared Hypervisor.framework (HVF) core and surrogate IPC protocol (macOS, aarch64).
+Used by hyperlight-host (direct backend and surrogate client) and by the
+hvf_surrogate binary (surrogate server).
+"""
+
+[lints]
+workspace = true
+
+[dependencies]
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "2.0.18"
+
+[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]
+applevisor-sys = "1.0"
+hyperlight-common = { workspace = true, default-features = true, features = [ "std" ] }
+libc = "0.2.186"
diff --git a/src/hyperlight_hvf/src/core.rs b/src/hyperlight_hvf/src/core.rs
new file mode 100644
index 000000000..50c591906
--- /dev/null
+++ b/src/hyperlight_hvf/src/core.rs
@@ -0,0 +1,604 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! Thin wrapper over the Hypervisor.framework C API.
+//!
+//! Threading: HVF requires that a vCPU is created, run, and accessed
+//! (register reads/writes) from a single thread. The only exception is
+//! cancellation: `hv_vcpus_exit` may be called from any thread.
+
+use std::collections::HashMap;
+use std::ffi::c_void;
+
+use applevisor_sys::{
+ HV_MEMORY_EXEC, HV_MEMORY_READ, HV_MEMORY_WRITE, hv_error_t, hv_exit_reason_t,
+ hv_ipa_granule_t, hv_reg_t, hv_simd_fp_reg_t, hv_sys_reg_t, hv_vcpu_create, hv_vcpu_destroy,
+ hv_vcpu_exit_t, hv_vcpu_get_reg, hv_vcpu_get_simd_fp_reg, hv_vcpu_get_sys_reg, hv_vcpu_run,
+ hv_vcpu_set_reg, hv_vcpu_set_simd_fp_reg, hv_vcpu_set_sys_reg, hv_vcpu_t, hv_vcpus_exit,
+ hv_vm_config_create, hv_vm_config_set_ipa_granule, hv_vm_create, hv_vm_destroy, hv_vm_map,
+ hv_vm_unmap, os_release,
+};
+use hyperlight_common::outb::VmAction;
+use serde::{Deserialize, Serialize};
+
+/// ESR_EL2 exception classes (EC field, bits [31:26]) relevant to this backend.
+const EC_WFI_WFE: u64 = 0x01;
+const EC_INST_ABORT: u64 = 0x20; // instruction abort from a lower EL
+const EC_DATA_ABORT: u64 = 0x24; // data abort from a lower EL
+
+/// ESR_EL2 ISS fields for aborts.
+const ESR_ISV: u64 = 1 << 24; // instruction syndrome valid
+const ESR_SAS_SHIFT: u64 = 22; // syndrome access size (log2 bytes)
+const ESR_SRT_SHIFT: u64 = 16; // syndrome register transfer (X register index)
+const ESR_WNR: u64 = 1 << 6; // write not read
+
+/// Error returned by HVF operations.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
+pub enum HvfError {
+ /// A Hypervisor.framework call failed; payload is the `hv_return_t`.
+ #[error("HVF error: {0:#x}")]
+ Hv(u32),
+ /// A data abort to the IO page arrived without a valid instruction
+ /// syndrome, so the access details (register, size) cannot be decoded.
+ #[error("HVF data abort without valid instruction syndrome")]
+ NoInstructionSyndrome,
+ /// The 4 KiB IPA granule could not be configured. Hyperlight requires
+ /// it (the default 16 KiB granule is incompatible with 4 KiB-aligned
+ /// guest regions); the granule API is available since macOS 26.
+ #[error("HVF 4 KiB IPA granule is not supported (requires macOS 26 or later)")]
+ IpaGranuleUnsupported,
+}
+
+/// Convert an `hv_return_t` into a `Result`.
+fn check_hv(ret: i32) -> Result<(), HvfError> {
+ if ret == hv_error_t::HV_SUCCESS as i32 {
+ Ok(())
+ } else {
+ Err(HvfError::Hv(ret as u32))
+ }
+}
+
+/// General-purpose register selectors indexed by X-register number (X0..X30).
+const X_REGS: [hv_reg_t; 31] = [
+ hv_reg_t::X0,
+ hv_reg_t::X1,
+ hv_reg_t::X2,
+ hv_reg_t::X3,
+ hv_reg_t::X4,
+ hv_reg_t::X5,
+ hv_reg_t::X6,
+ hv_reg_t::X7,
+ hv_reg_t::X8,
+ hv_reg_t::X9,
+ hv_reg_t::X10,
+ hv_reg_t::X11,
+ hv_reg_t::X12,
+ hv_reg_t::X13,
+ hv_reg_t::X14,
+ hv_reg_t::X15,
+ hv_reg_t::X16,
+ hv_reg_t::X17,
+ hv_reg_t::X18,
+ hv_reg_t::X19,
+ hv_reg_t::X20,
+ hv_reg_t::X21,
+ hv_reg_t::X22,
+ hv_reg_t::X23,
+ hv_reg_t::X24,
+ hv_reg_t::X25,
+ hv_reg_t::X26,
+ hv_reg_t::X27,
+ hv_reg_t::X28,
+ hv_reg_t::X29,
+ hv_reg_t::X30,
+];
+
+/// SIMD/FP register selectors indexed by Q-register number (Q0..Q31).
+const Q_REGS: [hv_simd_fp_reg_t; 32] = [
+ hv_simd_fp_reg_t::Q0,
+ hv_simd_fp_reg_t::Q1,
+ hv_simd_fp_reg_t::Q2,
+ hv_simd_fp_reg_t::Q3,
+ hv_simd_fp_reg_t::Q4,
+ hv_simd_fp_reg_t::Q5,
+ hv_simd_fp_reg_t::Q6,
+ hv_simd_fp_reg_t::Q7,
+ hv_simd_fp_reg_t::Q8,
+ hv_simd_fp_reg_t::Q9,
+ hv_simd_fp_reg_t::Q10,
+ hv_simd_fp_reg_t::Q11,
+ hv_simd_fp_reg_t::Q12,
+ hv_simd_fp_reg_t::Q13,
+ hv_simd_fp_reg_t::Q14,
+ hv_simd_fp_reg_t::Q15,
+ hv_simd_fp_reg_t::Q16,
+ hv_simd_fp_reg_t::Q17,
+ hv_simd_fp_reg_t::Q18,
+ hv_simd_fp_reg_t::Q19,
+ hv_simd_fp_reg_t::Q20,
+ hv_simd_fp_reg_t::Q21,
+ hv_simd_fp_reg_t::Q22,
+ hv_simd_fp_reg_t::Q23,
+ hv_simd_fp_reg_t::Q24,
+ hv_simd_fp_reg_t::Q25,
+ hv_simd_fp_reg_t::Q26,
+ hv_simd_fp_reg_t::Q27,
+ hv_simd_fp_reg_t::Q28,
+ hv_simd_fp_reg_t::Q29,
+ hv_simd_fp_reg_t::Q30,
+ hv_simd_fp_reg_t::Q31,
+];
+
+/// General-purpose register state (EL1t guest: `sp` is SP_EL0).
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Regs {
+ /// X0..X30 (X29=FP, X30=LR)
+ pub x: [u64; 31],
+ /// SP_EL0 (the active stack pointer at EL1t)
+ pub sp: u64,
+ /// Program counter
+ pub pc: u64,
+ /// CPSR
+ pub pstate: u64,
+}
+
+/// SIMD/FP register state.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FpuState {
+ /// Q0..Q31
+ pub v: [u128; 32],
+ /// FPSR
+ pub fpsr: u32,
+ /// FPCR
+ pub fpcr: u32,
+}
+
+/// System register state needed by Hyperlight guests.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Sregs {
+ /// TTBR0_EL1
+ pub ttbr0_el1: u64,
+ /// TCR_EL1
+ pub tcr_el1: u64,
+ /// MAIR_EL1
+ pub mair_el1: u64,
+ /// SCTLR_EL1
+ pub sctlr_el1: u64,
+ /// CPACR_EL1
+ pub cpacr_el1: u64,
+ /// VBAR_EL1
+ pub vbar_el1: u64,
+ /// SP_EL1
+ pub sp_el1: u64,
+}
+
+/// Memory permissions for a guest mapping.
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Perms {
+ /// Guest may read
+ pub read: bool,
+ /// Guest may write
+ pub write: bool,
+ /// Guest may execute
+ pub exec: bool,
+}
+
+impl Perms {
+ fn to_hv_flags(self) -> u64 {
+ let mut flags = 0;
+ if self.read {
+ flags |= HV_MEMORY_READ;
+ }
+ if self.write {
+ flags |= HV_MEMORY_WRITE;
+ }
+ if self.exec {
+ flags |= HV_MEMORY_EXEC;
+ }
+ flags
+ }
+}
+
+/// The reasons a vCPU run can exit, decoded from HVF exit info.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum VmExit {
+ /// The guest wrote to the halt "port" of the IO page
+ Halt,
+ /// The guest wrote `data` to the given IO-page port
+ IoOut(u16, Vec),
+ /// The guest read from the given unmapped GPA
+ MmioRead(u64),
+ /// The guest wrote to the given unmapped GPA
+ MmioWrite(u64),
+ /// The run was cancelled via [`cancel`]
+ Cancelled,
+ /// Any other exit
+ Unknown(String),
+}
+
+/// Create the process-wide VM, requesting the 4 KiB IPA granule so that
+/// 4 KiB-aligned guest regions can be mapped (the granule API requires
+/// macOS 26; the default granule is 16 KiB, which Hyperlight's 4 KiB
+/// guest page granule is incompatible with). Falls back to the default
+/// configuration if the config object itself cannot be created.
+fn create_vm() -> Result<(), HvfError> {
+ // SAFETY: `hv_vm_config_create` returns a retained config object (or
+ // null), which is released below.
+ let config = unsafe { hv_vm_config_create() };
+ if !config.is_null() {
+ // SAFETY: `config` is a live VM config object.
+ let ret = unsafe {
+ let ret = hv_vm_config_set_ipa_granule(config, hv_ipa_granule_t::HV_IPA_GRANULE_4KB);
+ if ret == hv_error_t::HV_SUCCESS as i32 {
+ hv_vm_create(config)
+ } else {
+ ret
+ }
+ };
+ // SAFETY: `config` is no longer needed.
+ unsafe { os_release(config) };
+ return check_hv(ret).map_err(|e| match e {
+ // Most likely a pre-macOS 26 system without the granule API.
+ HvfError::Hv(_) => HvfError::IpaGranuleUnsupported,
+ e => e,
+ });
+ }
+ // SAFETY: a null config creates a VM with the default configuration.
+ check_hv(unsafe { hv_vm_create(std::ptr::null_mut()) })
+}
+
+/// Return `true` if Hypervisor.framework is available in this process.
+///
+/// Requires the calling binary to hold the `com.apple.security.hypervisor`
+/// entitlement. Note that HVF allows only one live VM per process.
+pub fn is_hypervisor_present() -> bool {
+ if create_vm().is_err() {
+ return false;
+ }
+ // SAFETY: the VM was just created above.
+ unsafe { hv_vm_destroy() == hv_error_t::HV_SUCCESS as i32 }
+}
+
+/// Force the given vCPU out of `hv_vcpu_run`.
+///
+/// Safe to call from any thread; a stale cancel of a destroyed vCPU fails
+/// harmlessly since vCPU IDs are kernel-validated.
+pub fn cancel(vcpu: hv_vcpu_t) -> Result<(), HvfError> {
+ // SAFETY: `hv_vcpus_exit` may be called from any thread.
+ check_hv(unsafe { hv_vcpus_exit(&vcpu, 1) })
+}
+
+/// A single-vCPU HVF virtual machine.
+///
+/// HVF has no slot concept for guest memory; `mappings` tracks the
+/// `(gpa, size)` of each slot given to [`Vm::map_memory`] so that slots can
+/// be unmapped and replaced.
+pub struct Vm {
+ vcpu: hv_vcpu_t,
+ /// Exit info written by `hv_vcpu_run` on the vCPU thread.
+ exit: *const hv_vcpu_exit_t,
+ /// Slot -> (guest physical address, size) of active mappings.
+ mappings: HashMap,
+}
+
+// SAFETY: `Vm` is `Send` so it can be moved as part of a sandbox, but all
+// vCPU operations (register access and `hv_vcpu_run`) must happen on the
+// thread that created the vCPU, and `*const hv_vcpu_exit_t` is only
+// dereferenced there.
+unsafe impl Send for Vm {}
+
+impl std::fmt::Debug for Vm {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Vm")
+ .field("vcpu", &self.vcpu)
+ .field("mappings", &self.mappings)
+ .finish()
+ }
+}
+
+impl Vm {
+ /// Create the process-wide VM and one vCPU on the current thread.
+ ///
+ /// Fails with `HV_BUSY` if a VM already exists in this process (HVF
+ /// allows only one).
+ pub fn new() -> Result {
+ create_vm()?;
+
+ let mut vcpu: hv_vcpu_t = 0;
+ let mut exit: *const hv_vcpu_exit_t = std::ptr::null();
+ // SAFETY: `vcpu` and `exit` are valid out-pointers; a null config
+ // creates a vCPU with the default configuration on the current thread.
+ if let Err(e) =
+ check_hv(unsafe { hv_vcpu_create(&mut vcpu, &mut exit, std::ptr::null_mut()) })
+ {
+ // Don't leak the process-wide VM on failure.
+ // SAFETY: the VM was just created above and has no vCPUs.
+ unsafe {
+ let _ = hv_vm_destroy();
+ }
+ return Err(e);
+ }
+
+ Ok(Self {
+ vcpu,
+ exit,
+ mappings: HashMap::new(),
+ })
+ }
+
+ /// The raw HVF vCPU ID (usable with [`cancel`] from any thread).
+ pub fn vcpu_id(&self) -> u64 {
+ self.vcpu
+ }
+
+ /// Map `size` bytes of host memory at `va` into the guest at `gpa`,
+ /// replacing any existing mapping for `slot`.
+ ///
+ /// # Safety
+ /// The caller must ensure `[va, va + size)` is valid memory that outlives
+ /// the mapping.
+ pub unsafe fn map_memory(
+ &mut self,
+ slot: u32,
+ gpa: u64,
+ va: usize,
+ size: usize,
+ perms: Perms,
+ ) -> Result<(), HvfError> {
+ if let Some((old_gpa, old_size)) = self.mappings.remove(&slot) {
+ // SAFETY: the range was mapped by this VM.
+ check_hv(unsafe { hv_vm_unmap(old_gpa, old_size) })?;
+ }
+ // SAFETY: upheld by the caller (see above).
+ check_hv(unsafe { hv_vm_map(va as *const c_void, gpa, size, perms.to_hv_flags()) })?;
+ self.mappings.insert(slot, (gpa, size));
+ Ok(())
+ }
+
+ /// Unmap the mapping previously created for `slot`. `gpa`/`size` are used
+ /// as a fallback when the slot is unknown.
+ pub fn unmap_memory(&mut self, slot: u32, gpa: u64, size: usize) -> Result<(), HvfError> {
+ let (gpa, size) = self.mappings.remove(&slot).unwrap_or((gpa, size));
+ // SAFETY: the range was mapped by this VM.
+ check_hv(unsafe { hv_vm_unmap(gpa, size) })
+ }
+
+ /// Read a general-purpose register by X-register index (0..=30).
+ fn get_x_reg(&self, index: u8) -> Result {
+ let mut value = 0;
+ // SAFETY: called on the vCPU thread with a valid out-pointer.
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, X_REGS[index as usize], &mut value) })?;
+ Ok(value)
+ }
+
+ /// Advance the guest program counter past an instruction of `len` bytes.
+ fn advance_pc(&self, len: u64) -> Result<(), HvfError> {
+ let mut pc = 0;
+ // SAFETY: called on the vCPU thread with a valid out-pointer.
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::PC, &mut pc) })?;
+ // SAFETY: called on the vCPU thread.
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::PC, pc + len) })
+ }
+
+ /// Run the vCPU until it exits, decoding HVF exit info into [`VmExit`].
+ pub fn run_vcpu(&mut self) -> Result {
+ loop {
+ // SAFETY: called on the vCPU thread; `self.exit` is written by
+ // the hypervisor before `hv_vcpu_run` returns.
+ check_hv(unsafe { hv_vcpu_run(self.vcpu) })?;
+ let exit = unsafe { &*self.exit };
+
+ match exit.reason {
+ hv_exit_reason_t::CANCELED => return Ok(VmExit::Cancelled),
+ hv_exit_reason_t::VTIMER_ACTIVATED => {
+ // The vtimer is masked automatically on this exit and
+ // Hyperlight guests do not use interrupts; keep running.
+ continue;
+ }
+ hv_exit_reason_t::UNKNOWN => {
+ return Ok(VmExit::Unknown("HVF exit reason UNKNOWN".to_string()));
+ }
+ hv_exit_reason_t::EXCEPTION => {
+ let esr = exit.exception.syndrome;
+ let ipa = exit.exception.physical_address;
+ match (esr >> 26) & 0x3f {
+ EC_WFI_WFE => {
+ // No interrupts are configured; treat WFI/WFE as a
+ // no-op and keep running.
+ self.advance_pc(4)?;
+ continue;
+ }
+ EC_INST_ABORT => return Ok(VmExit::MmioRead(ipa)),
+ EC_DATA_ABORT => {
+ let io_page_gpa =
+ const { hyperlight_common::layout::io_page().unwrap().0 };
+ let off = ipa.wrapping_sub(io_page_gpa);
+ let is_io_page = (off as usize) < hyperlight_common::vmem::PAGE_SIZE;
+ let is_write = esr & ESR_WNR != 0;
+
+ if is_io_page && is_write {
+ if esr & ESR_ISV == 0 {
+ // No instruction syndrome: we cannot tell
+ // which register holds the written data.
+ return Err(HvfError::NoInstructionSyndrome);
+ }
+ let len = 1usize << ((esr >> ESR_SAS_SHIFT) & 0x3);
+ let srt = ((esr >> ESR_SRT_SHIFT) & 0x1f) as u8;
+ let value = if srt == 31 {
+ // XZR as the source register
+ 0
+ } else {
+ let mask = u64::MAX >> (64 - (len as u64 * 8));
+ self.get_x_reg(srt)? & mask
+ };
+ // HVF traps before the access completes;
+ // skip the store instruction ourselves.
+ self.advance_pc(4)?;
+
+ let port = (off as usize) / core::mem::size_of::();
+ if port == VmAction::Halt as usize {
+ return Ok(VmExit::Halt);
+ }
+ return Ok(VmExit::IoOut(
+ port as u16,
+ value.to_le_bytes()[..len].to_vec(),
+ ));
+ }
+
+ if is_write {
+ return Ok(VmExit::MmioWrite(ipa));
+ }
+ return Ok(VmExit::MmioRead(ipa));
+ }
+ _ => {
+ return Ok(VmExit::Unknown(format!(
+ "HVF exception exit: syndrome={:#x} va={:#x} ipa={:#x}",
+ esr, exit.exception.virtual_address, ipa
+ )));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /// Read the general-purpose registers.
+ pub fn regs(&self) -> Result {
+ let mut x: [u64; 31] = [0; 31];
+ for (i, xi) in x.iter_mut().enumerate() {
+ *xi = self.get_x_reg(i as u8)?;
+ }
+ // SAFETY: called on the vCPU thread with valid out-pointers.
+ let mut sp = 0;
+ check_hv(unsafe { hv_vcpu_get_sys_reg(self.vcpu, hv_sys_reg_t::SP_EL0, &mut sp) })?;
+ let mut pc = 0;
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::PC, &mut pc) })?;
+ let mut pstate = 0;
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::CPSR, &mut pstate) })?;
+ Ok(Regs { x, sp, pc, pstate })
+ }
+
+ /// Write the general-purpose registers.
+ pub fn set_regs(&self, regs: &Regs) -> Result<(), HvfError> {
+ for (i, &value) in regs.x.iter().enumerate() {
+ // SAFETY: called on the vCPU thread.
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, X_REGS[i], value) })?;
+ }
+ // SAFETY: called on the vCPU thread.
+ check_hv(unsafe { hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::SP_EL0, regs.sp) })?;
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::PC, regs.pc) })?;
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::CPSR, regs.pstate) })?;
+ Ok(())
+ }
+
+ /// Read the SIMD/FP registers.
+ pub fn fpu(&self) -> Result {
+ let mut v: [u128; 32] = [0; 32];
+ for (i, vi) in v.iter_mut().enumerate() {
+ // SAFETY: called on the vCPU thread with a valid out-pointer.
+ check_hv(unsafe { hv_vcpu_get_simd_fp_reg(self.vcpu, Q_REGS[i], vi) })?;
+ }
+ let mut fpsr = 0;
+ // SAFETY: called on the vCPU thread with valid out-pointers.
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::FPSR, &mut fpsr) })?;
+ let mut fpcr = 0;
+ check_hv(unsafe { hv_vcpu_get_reg(self.vcpu, hv_reg_t::FPCR, &mut fpcr) })?;
+ Ok(FpuState {
+ v,
+ fpsr: fpsr as u32,
+ fpcr: fpcr as u32,
+ })
+ }
+
+ /// Write the SIMD/FP registers.
+ pub fn set_fpu(&self, fpu: &FpuState) -> Result<(), HvfError> {
+ for (i, &value) in fpu.v.iter().enumerate() {
+ // SAFETY: called on the vCPU thread.
+ check_hv(unsafe { hv_vcpu_set_simd_fp_reg(self.vcpu, Q_REGS[i], value) })?;
+ }
+ // SAFETY: called on the vCPU thread.
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::FPSR, fpu.fpsr as u64) })?;
+ check_hv(unsafe { hv_vcpu_set_reg(self.vcpu, hv_reg_t::FPCR, fpu.fpcr as u64) })?;
+ Ok(())
+ }
+
+ /// Read the system registers.
+ pub fn sregs(&self) -> Result {
+ // SAFETY: called on the vCPU thread with a valid out-pointer.
+ let get = |reg: hv_sys_reg_t| {
+ let mut value = 0;
+ check_hv(unsafe { hv_vcpu_get_sys_reg(self.vcpu, reg, &mut value) })?;
+ Ok(value)
+ };
+ Ok(Sregs {
+ ttbr0_el1: get(hv_sys_reg_t::TTBR0_EL1)?,
+ tcr_el1: get(hv_sys_reg_t::TCR_EL1)?,
+ mair_el1: get(hv_sys_reg_t::MAIR_EL1)?,
+ sctlr_el1: get(hv_sys_reg_t::SCTLR_EL1)?,
+ cpacr_el1: get(hv_sys_reg_t::CPACR_EL1)?,
+ vbar_el1: get(hv_sys_reg_t::VBAR_EL1)?,
+ sp_el1: get(hv_sys_reg_t::SP_EL1)?,
+ })
+ }
+
+ /// Write the system registers.
+ pub fn set_sregs(&self, sregs: &Sregs) -> Result<(), HvfError> {
+ // SAFETY: called on the vCPU thread.
+ let set = |reg: hv_sys_reg_t, value: u64| {
+ check_hv(unsafe { hv_vcpu_set_sys_reg(self.vcpu, reg, value) })
+ };
+ set(hv_sys_reg_t::TTBR0_EL1, sregs.ttbr0_el1)?;
+ set(hv_sys_reg_t::TCR_EL1, sregs.tcr_el1)?;
+ set(hv_sys_reg_t::MAIR_EL1, sregs.mair_el1)?;
+ set(hv_sys_reg_t::SCTLR_EL1, sregs.sctlr_el1)?;
+ set(hv_sys_reg_t::CPACR_EL1, sregs.cpacr_el1)?;
+ set(hv_sys_reg_t::VBAR_EL1, sregs.vbar_el1)?;
+ set(hv_sys_reg_t::SP_EL1, sregs.sp_el1)?;
+ Ok(())
+ }
+
+ /// Reset the vCPU to a clean state, emulating KVM's `KVM_ARM_VCPU_INIT`
+ /// (HVF has no equivalent operation): GP and FP registers are zeroed,
+ /// and the debug breakpoint/watchpoint pair 0 is cleared so no stale
+ /// breakpoints survive a snapshot restore. Only pair 0 is cleared —
+ /// Hyperlight guests use no other debug registers. Translation system
+ /// registers (TCR/TTBR/…) are NOT touched; the caller applies them
+ /// from the snapshot after the reset.
+ pub fn reset_vcpu(&self) -> Result<(), HvfError> {
+ self.set_regs(&Regs::default())?;
+ self.set_fpu(&FpuState::default())?;
+ // SAFETY: called on the vCPU thread.
+ unsafe {
+ check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGBVR0_EL1, 0))?;
+ check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGBCR0_EL1, 0))?;
+ check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGWVR0_EL1, 0))?;
+ check_hv(hv_vcpu_set_sys_reg(self.vcpu, hv_sys_reg_t::DBGWCR0_EL1, 0))?;
+ }
+ Ok(())
+ }
+}
+
+impl Drop for Vm {
+ fn drop(&mut self) {
+ // SAFETY: `hv_vcpu_destroy` must be called on the thread that created
+ // the vCPU (see module-level threading note). Errors are ignored: the
+ // VM and vCPU are per-process resources reclaimed at process exit.
+ unsafe {
+ let _ = hv_vcpu_destroy(self.vcpu);
+ let _ = hv_vm_destroy();
+ }
+ }
+}
diff --git a/src/hyperlight_hvf/src/lib.rs b/src/hyperlight_hvf/src/lib.rs
new file mode 100644
index 000000000..5e21c2ce1
--- /dev/null
+++ b/src/hyperlight_hvf/src/lib.rs
@@ -0,0 +1,32 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! Shared Hypervisor.framework (HVF) support for macOS on aarch64.
+//!
+//! - [`core`]: a thin, safe-ish wrapper over the HVF C API (VM/vCPU lifecycle,
+//! register access, vCPU run loop with exit decoding). Used both by the
+//! direct `hyperlight-host` backend and by the surrogate server.
+//! - [`proto`]: the IPC protocol between `hyperlight-host` (client) and the
+//! `hvf_surrogate` process (server) that owns a VM when multiple sandboxes
+//! must coexist in one host process (HVF allows only one VM per process).
+//!
+//! Everything in this crate is only meaningful on `aarch64-apple-darwin`;
+//! on all other targets the crate compiles empty.
+
+#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+pub mod core;
+#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
+pub mod proto;
diff --git a/src/hyperlight_hvf/src/proto.rs b/src/hyperlight_hvf/src/proto.rs
new file mode 100644
index 000000000..75ba7378b
--- /dev/null
+++ b/src/hyperlight_hvf/src/proto.rs
@@ -0,0 +1,221 @@
+/*
+Copyright 2026 The Hyperlight Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+//! IPC protocol between `hyperlight-host` (client) and the `hvf_surrogate`
+//! process (server) that owns an HVF VM on behalf of one sandbox.
+//!
+//! Transport: a `SOCK_STREAM` unix socket pair inherited by the surrogate.
+//! Frames are `u32` little-endian length-prefixed JSON bodies. Requests and
+//! responses are strictly serialized with one exception: [`Request::Cancel`]
+//! may be sent from another thread while a [`Request::RunVcpu`] is
+//! outstanding, and receives no response (the pending `RunVcpu` answers with
+//! [`Response::Exit`]`(`[`VmExit::Cancelled`]`)`).
+//!
+//! Guest memory is shared by reference, never copied: anonymous regions are
+//! POSIX shm objects (`shm_open`) and file regions are filesystem paths; the
+//! surrogate maps the same underlying object into its own address space and
+//! then into the guest.
+
+use std::io::{self, Read, Write};
+use std::path::PathBuf;
+
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Serialize};
+
+use crate::core::{FpuState, Perms, Regs, Sregs, VmExit};
+
+/// Current protocol version, exchanged in the [`Request::Hello`] handshake.
+pub const PROTO_VERSION: u32 = 1;
+
+/// Largest frame we are willing to read (16 MiB; real frames are a few KiB).
+const MAX_FRAME_SIZE: u32 = 16 * 1024 * 1024;
+
+/// How the surrogate can access the memory object backing a guest region.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Backing {
+ /// A POSIX shm object (created with `shm_open`), with the byte offset of
+ /// the usable region within the object.
+ Shm {
+ /// Object name as passed to `shm_open` (e.g. `/hl-`)
+ name: String,
+ /// Offset of the region start within the object
+ offset: u64,
+ },
+ /// A filesystem path (used for read-only file mappings), with the byte
+ /// offset of the region within the file.
+ File {
+ /// Path to the file
+ path: PathBuf,
+ /// Offset of the region start within the file
+ offset: u64,
+ },
+}
+
+/// A request from the host to the surrogate.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Request {
+ /// Protocol handshake; must be the first message on a connection.
+ Hello {
+ /// Client protocol version ([`PROTO_VERSION`])
+ version: u32,
+ },
+ /// Create the VM and vCPU. Must precede any other VM operation; the VM
+ /// stays alive until [`Request::DestroyVm`] (or process exit).
+ CreateVm,
+ /// Destroy the VM and vCPU, returning the surrogate to its idle state so
+ /// the process can be reused by another sandbox.
+ DestroyVm,
+ /// Map `size` bytes of `backing` into the guest at `gpa` for `slot`,
+ /// replacing any existing mapping for that slot.
+ MapMemory {
+ /// Mapping slot identifier
+ slot: u32,
+ /// Guest physical address
+ gpa: u64,
+ /// Region size in bytes
+ size: u64,
+ /// Guest permissions
+ perms: Perms,
+ /// The memory object backing the region
+ backing: Backing,
+ },
+ /// Unmap the region previously mapped for `slot` (`gpa`/`size` are a
+ /// fallback when the slot is unknown to the server).
+ UnmapMemory {
+ /// Mapping slot identifier
+ slot: u32,
+ /// Guest physical address
+ gpa: u64,
+ /// Region size in bytes
+ size: u64,
+ },
+ /// Run the vCPU until it exits. Responds with [`Response::Exit`].
+ RunVcpu,
+ /// Read the general-purpose registers. Responds with [`Response::Regs`].
+ GetRegs,
+ /// Write the general-purpose registers. Responds with [`Response::Ok`].
+ SetRegs(Box),
+ /// Read the SIMD/FP registers. Responds with [`Response::Fpu`].
+ GetFpu,
+ /// Write the SIMD/FP registers. Responds with [`Response::Ok`].
+ SetFpu(Box),
+ /// Read the system registers. Responds with [`Response::Sregs`].
+ GetSregs,
+ /// Write the system registers. Responds with [`Response::Ok`].
+ SetSregs(Sregs),
+ /// Reset the vCPU to a clean state (GP/FP registers zeroed, debug
+ /// breakpoint/watchpoint pair 0 cleared). Responds with
+ /// [`Response::Ok`]. Translation system registers are untouched; the
+ /// client applies them from the snapshot afterwards.
+ ResetVcpu,
+ /// Force the running vCPU out of `hv_vcpu_run`. May be sent while a
+ /// [`Request::RunVcpu`] is outstanding; receives no response.
+ Cancel,
+}
+
+/// A response from the surrogate to the host.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Response {
+ /// Protocol handshake answer.
+ Hello {
+ /// Server protocol version ([`PROTO_VERSION`])
+ version: u32,
+ },
+ /// The request succeeded with no payload.
+ Ok,
+ /// Answer to [`Request::GetRegs`].
+ Regs(Box),
+ /// Answer to [`Request::GetFpu`].
+ Fpu(Box),
+ /// Answer to [`Request::GetSregs`].
+ Sregs(Sregs),
+ /// Answer to [`Request::RunVcpu`].
+ Exit(VmExit),
+ /// The request failed.
+ Err(String),
+}
+
+/// Write one length-prefixed frame. The whole frame is serialized into a
+/// single buffer and written with one `write_all`, so that frames written
+/// from different threads sharing one writer cannot interleave on the wire.
+pub fn write_frame(w: &mut impl Write, msg: &T) -> io::Result<()> {
+ let body = serde_json::to_vec(msg).map_err(io::Error::other)?;
+ let len: u32 = body
+ .len()
+ .try_into()
+ .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "frame too large"))?;
+ let mut frame = Vec::with_capacity(4 + body.len());
+ frame.extend_from_slice(&len.to_le_bytes());
+ frame.extend_from_slice(&body);
+ w.write_all(&frame)?;
+ w.flush()
+}
+
+/// Read one length-prefixed frame. Returns `Ok(None)` on a clean EOF at a
+/// frame boundary (peer closed the connection).
+pub fn read_frame(r: &mut impl Read) -> io::Result