From e3a725df90c5a68fbe0642d97d9c5a5632f6eb03 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 18:32:17 +0100 Subject: [PATCH 1/5] Add macOS (hvf) and aarch64 support Upstream hyperlight-host 0.17.0 already ships the Hypervisor.framework driver and aarch64 register support, so the remaining work is on the hyperlight-js side: platform wiring, an architecture-derived guest target, and CI/packaging. Hypervisor and platform wiring: - Introduce cfg aliases (kvm, mshv3, hvf, whp, crashdump, gdb) in build.rs mirroring upstream's semantics, so platform code never tests a bare feature flag. Enable the `hvf` feature by default and scope `libc` to cfg(unix). - Gate `with_interrupt_retry_delay` on any(kvm, mshv3, hvf) rather than on the Linux-only drivers. - Add a mach-based thread CPU-time backend for macOS. js-host-api enables `monitor-cpu-time` unconditionally, so this has to compile on every host. Architecture generalisation: - Derive the guest target triple as `{arch}-hyperlight-none` in build.rs and the Justfile instead of hard-coding x86_64. - Add src/hyperlight-js-runtime/include/math.h. newlib excludes clang from its __builtin_* fast path, so math.h falls back to a sizeof dispatch whose never-taken `long double` branch is still code-generated. That is free on x86_64 (80-bit x87) but emits __extenddftf2/__extendsftf2 soft-float libcalls on aarch64, which fail to link against the guest sysroot. The shim uses #include_next and restores the builtin path under clang. Fix a pre-existing crashdump build failure: - `crashdump` builds are broken on main: the snapshot helpers take &self but call hyperlight-host APIs that require &mut self. Take &mut self at both call sites and gate them on the new `crashdump` alias, which also restricts them to x86_64 in line with upstream. CI and packaging: - Extend the dep_build matrix from 6 to 10 jobs, covering macOS/hvf on the self-hosted arm64 runners upstream already uses, plus Linux aarch64 KVM. These run the full test suite rather than build-only. - Publish an aarch64-apple-darwin binary and add the darwin-arm64 npm package. Not yet exercised on real macOS or Linux aarch64 hardware; CI is the first run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- .github/workflows/dep_build.yml | 29 +++- .github/workflows/npm-publish.yml | 79 ++++++----- Cargo.lock | 1 + Justfile | 15 +- README.md | 27 +++- docs/extending-runtime.md | 15 +- docs/guest-runtime-debugging.md | 4 + docs/release.md | 29 +++- src/hyperlight-js-runtime/include/math.h | 76 ++++++++++ src/hyperlight-js/Cargo.toml | 9 +- src/hyperlight-js/build.rs | 39 +++++- .../examples/runtime_debugging/main.rs | 12 +- src/hyperlight-js/src/sandbox/js_sandbox.rs | 8 +- .../src/sandbox/loaded_js_sandbox.rs | 8 +- .../src/sandbox/monitor/cpu_time.rs | 130 +++++++++++++++++- .../src/sandbox/sandbox_builder.rs | 16 ++- src/hyperlight-js/tests/native_modules.rs | 3 +- src/js-host-api/DEVELOPMENT.md | 1 + src/js-host-api/README.md | 2 +- src/js-host-api/npm/darwin-arm64/package.json | 27 ++++ src/js-host-api/package-lock.json | 1 + src/js-host-api/package.json | 4 +- 22 files changed, 462 insertions(+), 73 deletions(-) create mode 100644 src/hyperlight-js-runtime/include/math.h create mode 100644 src/js-host-api/npm/darwin-arm64/package.json diff --git a/.github/workflows/dep_build.yml b/.github/workflows/dep_build.yml index cc795cd1..02d99364 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: true matrix: - build: [windows-2025-debug, linux-kvm-debug, linux-hyperv3-debug, windows-2025-release, linux-kvm-release, linux-hyperv3-release] + build: [windows-2025-debug, linux-kvm-debug, linux-kvm-arm64-debug, linux-hyperv3-debug, macos-hvf-debug, windows-2025-release, linux-kvm-release, linux-kvm-arm64-release, linux-hyperv3-release, macos-hvf-release] include: - build: windows-2025-debug os: [self-hosted, Windows, X64, "1ES.Pool=hld-win2025-amd"] @@ -28,11 +28,21 @@ jobs: - build: linux-kvm-debug os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] hypervisor: kvm + arch: X64 + config: debug + - build: linux-kvm-arm64-debug + os: [self-hosted, Linux, arm64, kvm] + hypervisor: kvm + arch: arm64 config: debug - build: linux-hyperv3-debug os: [self-hosted, Linux, X64, "1ES.Pool=hld-azlinux3-mshv-amd"] hypervisor: hyperv3 config: debug + - build: macos-hvf-debug + os: [self-hosted, macos, arm64, hvf] + hypervisor: hvf + config: debug - build: windows-2025-release os: [self-hosted, Windows, X64, "1ES.Pool=hld-win2025-amd"] hypervisor: whp @@ -40,13 +50,26 @@ jobs: - build: linux-kvm-release os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] hypervisor: kvm + arch: X64 + config: release + - build: linux-kvm-arm64-release + os: [self-hosted, Linux, arm64, kvm] + hypervisor: kvm + arch: arm64 config: release - build: linux-hyperv3-release os: [self-hosted, Linux, X64, "1ES.Pool=hld-azlinux3-mshv-amd"] hypervisor: hyperv3 config: release - runs-on: ${{ fromJson( - format('["self-hosted", "{0}", "X64", "1ES.Pool=hld-{1}-amd", "JobId=build-{2}-{3}-{4}-{5}"]', + - build: macos-hvf-release + os: [self-hosted, macos, arm64, hvf] + hypervisor: hvf + config: release + runs-on: ${{ fromJson(matrix.hypervisor == 'hvf' + && '["self-hosted", "macos", "arm64", "hvf"]' + || matrix.arch == 'arm64' + && '["self-hosted", "Linux", "arm64", "kvm"]' + || format('["self-hosted", "{0}", "X64", "1ES.Pool=hld-{1}-amd", "JobId=build-{2}-{3}-{4}-{5}"]', matrix.hypervisor == 'whp' && 'Windows' || 'Linux', matrix.hypervisor == 'whp' && 'win2025' || matrix.hypervisor == 'hyperv3' && 'azlinux3-mshv' || 'kvm', matrix.build, diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a2328eb8..286f9924 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -41,6 +41,10 @@ concurrency: env: WORKING_DIR: src/js-host-api + # Comma-delimited npm package names that have not yet been published and + # therefore cannot have a trusted publisher configured. Remove each package + # in a follow-up PR after its first release and trusted-publisher setup. + FIRST_TIME_PACKAGES: ",@hyperlight-dev/js-host-api-darwin-arm64," jobs: build: @@ -57,6 +61,9 @@ jobs: - target: x86_64-pc-windows-msvc os: [self-hosted, Windows, X64, "1ES.Pool=hld-win2025-amd"] build_name: win32-x64-msvc + - target: aarch64-apple-darwin + os: macos-15 + build_name: darwin-arm64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 @@ -173,6 +180,12 @@ jobs: name: bindings-win32-x64-msvc path: ${{ env.WORKING_DIR }}/artifacts/win32-x64-msvc + - name: Download macOS arm64 artifact + uses: actions/download-artifact@v8 + with: + name: bindings-darwin-arm64 + path: ${{ env.WORKING_DIR }}/artifacts/darwin-arm64 + - name: Download JS bindings uses: actions/download-artifact@v8 with: @@ -189,9 +202,11 @@ jobs: mv artifacts/linux-x64-gnu/*.node npm/linux-x64-gnu/js-host-api.linux-x64-gnu.node mv artifacts/linux-x64-musl/*.node npm/linux-x64-musl/js-host-api.linux-x64-musl.node mv artifacts/win32-x64-msvc/*.node npm/win32-x64-msvc/js-host-api.win32-x64-msvc.node + mv artifacts/darwin-arm64/*.node npm/darwin-arm64/js-host-api.darwin-arm64.node ls -la npm/linux-x64-gnu/ ls -la npm/linux-x64-musl/ ls -la npm/win32-x64-msvc/ + ls -la npm/darwin-arm64/ - name: Set package versions working-directory: ${{ env.WORKING_DIR }} @@ -203,6 +218,7 @@ jobs: cd npm/linux-x64-gnu && npm version "$VERSION" --no-git-tag-version --allow-same-version cd ../linux-x64-musl && npm version "$VERSION" --no-git-tag-version --allow-same-version cd ../win32-x64-msvc && npm version "$VERSION" --no-git-tag-version --allow-same-version + cd ../darwin-arm64 && npm version "$VERSION" --no-git-tag-version --allow-same-version env: VERSION: ${{ inputs.version }} @@ -239,66 +255,55 @@ jobs: run: ./test-pack.sh # ── Authentication strategy ──────────────────────────────────── - # Production releases via CreateRelease.yml use trusted publishing - # (OIDC) — npm auto-detects the id-token and exchanges it for a - # short-lived publish credential. Provenance attestations are - # generated automatically. The --provenance flag is added explicitly - # so the build fails loudly if OIDC is misconfigured. - # - # Manual workflow_dispatch requires an NPM_TOKEN repo secret because - # trusted publishing is configured for CreateRelease.yml only. - # Provenance is NOT available for token-based publishing. - # You should almost never need to publish manually — if you do, - # see docs/release.md for the full (deliberately painful) steps. - - name: Validate NPM_TOKEN for manual dispatch - if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run }} + # Established packages use trusted publishing (OIDC). Packages listed + # in FIRST_TIME_PACKAGES use NPM_TOKEN for this release only because + # npm has no trusted-publisher configuration until the package exists. + # Token-published packages cannot receive npm provenance attestations. + - name: Validate NPM_TOKEN for token-published packages + if: ${{ !inputs.dry_run && (github.event_name == 'workflow_dispatch' || env.FIRST_TIME_PACKAGES != '') }} run: | if [ -z "$NPM_TOKEN" ]; then - echo "::error::NPM_TOKEN repo secret is required for manual workflow_dispatch publishing." - echo "::error::See docs/release.md 'Manual npm publishing (emergency only)' for instructions." + echo "::error::NPM_TOKEN repo secret is required for manual publishing or first-time packages." + echo "::error::See docs/release.md for the first-time package bootstrap procedure." exit 1 fi env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - # Set provenance flag once — OIDC path gets --provenance (fails loud - # if misconfigured), token path skips it (not supported). - - name: Set publish flags - id: publish-flags - run: | - if [ "${{ github.event_name }}" != "workflow_dispatch" ]; then - echo "provenance=--provenance" >> "$GITHUB_OUTPUT" - else - echo "provenance=" >> "$GITHUB_OUTPUT" - fi - - name: Publish Linux GNU package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }}/npm/linux-x64-gnu - run: npm publish --access public --ignore-scripts ${{ steps.publish-flags.outputs.provenance }} + run: npm publish --access public --ignore-scripts ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-gnu,')) && '' || '--provenance' }} env: - NODE_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.NPM_TOKEN || '' }} + NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-gnu,')) && secrets.NPM_TOKEN || '' }} - name: Publish Linux musl package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }}/npm/linux-x64-musl - run: npm publish --access public --ignore-scripts ${{ steps.publish-flags.outputs.provenance }} + run: npm publish --access public --ignore-scripts ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-musl,')) && '' || '--provenance' }} env: - NODE_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.NPM_TOKEN || '' }} + NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-musl,')) && secrets.NPM_TOKEN || '' }} - name: Publish Windows package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }}/npm/win32-x64-msvc - run: npm publish --access public --ignore-scripts ${{ steps.publish-flags.outputs.provenance }} + run: npm publish --access public --ignore-scripts ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-win32-x64-msvc,')) && '' || '--provenance' }} env: - NODE_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.NPM_TOKEN || '' }} + NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-win32-x64-msvc,')) && secrets.NPM_TOKEN || '' }} + + - name: Publish macOS arm64 package + if: ${{ !inputs.dry_run }} + working-directory: ${{ env.WORKING_DIR }}/npm/darwin-arm64 + run: npm publish --access public --ignore-scripts ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-darwin-arm64,')) && '' || '--provenance' }} + env: + NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-darwin-arm64,')) && secrets.NPM_TOKEN || '' }} - name: Publish main package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }} - run: npm publish --access public --ignore-scripts ${{ steps.publish-flags.outputs.provenance }} + run: npm publish --access public --ignore-scripts ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api,')) && '' || '--provenance' }} env: - NODE_AUTH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && secrets.NPM_TOKEN || '' }} + NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api,')) && secrets.NPM_TOKEN || '' }} - name: Verify all packages published if: ${{ !inputs.dry_run }} @@ -309,7 +314,8 @@ jobs: for pkg in "@hyperlight-dev/js-host-api" \ "@hyperlight-dev/js-host-api-linux-x64-gnu" \ "@hyperlight-dev/js-host-api-linux-x64-musl" \ - "@hyperlight-dev/js-host-api-win32-x64-msvc"; do + "@hyperlight-dev/js-host-api-win32-x64-msvc" \ + "@hyperlight-dev/js-host-api-darwin-arm64"; do if npm view "$pkg@$VERSION" version > /dev/null 2>&1; then echo "✅ $pkg@$VERSION published" else @@ -339,5 +345,8 @@ jobs: echo "--- @hyperlight-dev/js-host-api-win32-x64-msvc ---" npm pack ./npm/win32-x64-msvc --dry-run echo "" + echo "--- @hyperlight-dev/js-host-api-darwin-arm64 ---" + npm pack ./npm/darwin-arm64 --dry-run + echo "" echo "--- @hyperlight-dev/js-host-api ---" npm pack --dry-run \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 463f271b..fb5a7d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1740,6 +1740,7 @@ version = "0.4.0" dependencies = [ "anyhow", "cargo-hyperlight", + "cfg_aliases", "chrono", "clap", "criterion", diff --git a/Justfile b/Justfile index dc19e4f0..29a36727 100644 --- a/Justfile +++ b/Justfile @@ -8,7 +8,9 @@ latest-release:= if os() == "windows" {"$(git tag -l --sort=v:refname | select - PWD := replace(justfile_dir(), "\\", "/") # Set the HYPERLIGHT_CFLAGS so cargo-hyperlight applies them when building the runtimes: -# * include the stubs required by hyperlight-js-runtime +# * include the headers required by hyperlight-js-runtime (notably the +# shim that keeps newlib's classification macros off the long double path, +# which would otherwise need soft-float builtins the guest does not link) # * define __wasi__ as this disables threading support in quickjs export HYPERLIGHT_CFLAGS := \ "-I" + PWD + "/src/hyperlight-js-runtime/include " + \ @@ -18,6 +20,10 @@ export HYPERLIGHT_CFLAGS := \ # On Windows, use Ninja generator for CMake to avoid aws-lc-sys build issues with Visual Studio generator export CMAKE_GENERATOR := if os() == "windows" { "Ninja" } else { "" } +# The hyperlight guest target. The guest runs on the same architecture as the +# host, so derive it rather than hardcoding x86_64. +guest-target := arch() + "-hyperlight-none" + ensure-tools: cargo install cargo-hyperlight --locked @@ -41,6 +47,7 @@ check-license-headers: clippy target=default-target features="": (ensure-tools) cargo hyperlight clippy -p hyperlight-js-runtime \ + --target={{ guest-target }} \ --profile={{ if target == "debug" {"dev"} else { target } }} \ -- -D warnings cargo clippy --all-targets \ @@ -176,7 +183,7 @@ test-js-host-api target=default-target features="": (build-js-host-api target fe # so setting/unsetting the env var triggers a rebuild automatically. # Base path to the extended runtime fixture target directory -extended_runtime_target := replace(justfile_dir(), "\\", "/") + "/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target/x86_64-hyperlight-none" +extended_runtime_target := replace(justfile_dir(), "\\", "/") + "/src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target/" + guest-target test-native-modules target=default-target: (ensure-tools) (check-fixture-lock) (_test-native-modules-unit target) (_test-native-modules-build-guest target) (_test-native-modules-vm target) (_test-native-modules-restore target) @@ -187,6 +194,7 @@ _test-native-modules-unit target=default-target: [private] _test-native-modules-build-guest target=default-target: cargo hyperlight build \ + --target={{ guest-target }} \ --manifest-path src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.toml \ --profile={{ if target == "debug" {"dev"} else { target } }} \ --target-dir src/hyperlight-js-runtime/tests/fixtures/extended_runtime/target @@ -218,11 +226,12 @@ set-version version: cargo update \ --manifest-path src/hyperlight-js-runtime/tests/fixtures/extended_runtime/Cargo.toml \ -p hyperlight-js-runtime -p hyperlight-js-common - # npm: main + the 3 platform package.json versions (--ignore-scripts avoids needing node_modules) + # npm: main + the 4 platform package.json versions (--ignore-scripts avoids needing node_modules) cd src/js-host-api && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts cd src/js-host-api/npm/linux-x64-gnu && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts cd src/js-host-api/npm/linux-x64-musl && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts cd src/js-host-api/npm/win32-x64-msvc && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts + cd src/js-host-api/npm/darwin-arm64 && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts # Verify the npm lockfile cd src/js-host-api && npm ci --dry-run --omit=optional --ignore-scripts diff --git a/README.md b/README.md index 972a0604..83de8b09 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,37 @@ For Azure Linux: sudo dnf install clang18-tools-extra -y ``` -In addition on Linux you will need to install the `x86_64-unknown-none` target: +For macOS: ```bash + xcode-select --install +``` + +In addition on Linux and macOS you will need to install the bare-metal target matching +your architecture, which `cargo-hyperlight` derives the guest target from: + +```bash + # x86_64 hosts rustup target add x86_64-unknown-none + # Apple Silicon / other aarch64 hosts + rustup target add aarch64-unknown-none ``` +## Supported platforms + +| Host OS | Architecture | Hypervisor | +| ------- | ------------ | ---------- | +| Linux | x86_64 | KVM or MSHV | +| Linux | aarch64 | KVM | +| Windows | x86_64 | Windows Hypervisor Platform (WHP) | +| macOS | aarch64 | Hypervisor.framework | + +The guest runs on the same architecture as the host, so an aarch64 host builds +and runs an `aarch64-hyperlight-none` guest. + +Note that MSHV is x86_64-only in practice: `hyperlight-host` compiles an aarch64 +MSHV backend, but it is a stub whose hypervisor detection always reports absent. + ## Building To build the project, run: diff --git a/docs/extending-runtime.md b/docs/extending-runtime.md index 8a77ddbf..6e76911a 100644 --- a/docs/extending-runtime.md +++ b/docs/extending-runtime.md @@ -93,8 +93,12 @@ export HYPERLIGHT_CFLAGS=$(node -e " # Build the custom runtime for the hyperlight target cargo hyperlight build --manifest-path my-custom-runtime/Cargo.toml --release -# Tell hyperlight-js to embed the custom runtime (not the default one) -export HYPERLIGHT_JS_RUNTIME_PATH=my-custom-runtime/target/x86_64-hyperlight-none/release/my-custom-runtime +# Tell hyperlight-js to embed the custom runtime (not the default one). +# The guest target matches the host architecture: x86_64-hyperlight-none on +# x86_64, aarch64-hyperlight-none on Apple Silicon and other aarch64 hosts. +# Note: macOS `uname -m` prints `arm64`, so normalise it to Rust's `aarch64`. +GUEST_ARCH=$(uname -m | sed 's/^arm64$/aarch64/') +export HYPERLIGHT_JS_RUNTIME_PATH=my-custom-runtime/target/${GUEST_ARCH}-hyperlight-none/release/my-custom-runtime # Rebuild hyperlight-js so the embedded runtime is updated cargo build -p hyperlight-js --release @@ -245,8 +249,11 @@ export HYPERLIGHT_CFLAGS=$(node -e " # Build your custom runtime for the hyperlight target cargo hyperlight build --manifest-path my-custom-runtime/Cargo.toml --release -# Point hyperlight-js at your custom runtime binary -export HYPERLIGHT_JS_RUNTIME_PATH=my-custom-runtime/target/x86_64-hyperlight-none/release/my-custom-runtime +# Point hyperlight-js at your custom runtime binary (the guest target matches +# the host architecture — aarch64-hyperlight-none on Apple Silicon). +# Note: macOS `uname -m` prints `arm64`, so normalise it to Rust's `aarch64`. +GUEST_ARCH=$(uname -m | sed 's/^arm64$/aarch64/') +export HYPERLIGHT_JS_RUNTIME_PATH=my-custom-runtime/target/${GUEST_ARCH}-hyperlight-none/release/my-custom-runtime # Clean stale builds so build.rs re-embeds the runtime cd "${HYPERLIGHT_DIR}/src/hyperlight-js" && cargo clean -p hyperlight-js diff --git a/docs/guest-runtime-debugging.md b/docs/guest-runtime-debugging.md index 8d41d0b9..24fe2358 100644 --- a/docs/guest-runtime-debugging.md +++ b/docs/guest-runtime-debugging.md @@ -6,6 +6,10 @@ This guide provides instructions on how to debug `hyperlight-js-runtime` using G To enable debugging, you need to build the `hyperlight-js` library in **debug mode** and with the `gdb` feature enabled. This will include the necessary debug symbols and enable the GDB server in the runtime. +> **Note:** guest debugging is currently x86_64 only — `hyperlight-host` gates its `gdb` +> feature on `target_arch = "x86_64"`, so it is unavailable on aarch64 hosts such as +> Apple Silicon macOS. + When the `gdb` feature is enabled, you can specify a port for the GDB server to listen on when creating the sandbox. ```rust let proto_sandbox = SandboxBuilder::new() diff --git a/docs/release.md b/docs/release.md index 2d943b0b..b2744dc9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -68,11 +68,33 @@ Trusted publishing is configured on [npmjs.com](https://www.npmjs.com/) for each 3. Set **Organization**: `hyperlight-dev`, **Repository**: `hyperlight-js`, **Workflow**: `CreateRelease.yml` 4. Save -This must be done for all 4 packages: +This must be done for all 5 packages: - `@hyperlight-dev/js-host-api` - `@hyperlight-dev/js-host-api-linux-x64-gnu` - `@hyperlight-dev/js-host-api-linux-x64-musl` - `@hyperlight-dev/js-host-api-win32-x64-msvc` +- `@hyperlight-dev/js-host-api-darwin-arm64` + +> **Note:** Trusted publishers are configured per package, and npm cannot configure one for a +> package that does not exist yet. The publish workflow therefore has a temporary +> `FIRST_TIME_PACKAGES` list in `.github/workflows/npm-publish.yml`. Packages in that list use +> the `NPM_TOKEN` repository secret for their first release; all other packages continue to use +> trusted publishing (OIDC). Token-published packages do not receive npm provenance attestations. +> +> For a new package, add its full npm name to `FIRST_TIME_PACKAGES`, ensure the short-lived +> `NPM_TOKEN` secret is available, and release normally from the `CreateRelease` workflow. After +> the release, configure the package's GitHub Actions trusted publisher using the settings above, +> then open a follow-up PR that removes the package from `FIRST_TIME_PACKAGES` and regenerates +> `src/js-host-api/package-lock.json` with: +> +> ```console +> cd src/js-host-api +> npm install --package-lock-only --ignore-scripts --os=darwin --cpu=arm64 +> ``` +> +> Use the target package's platform and architecture for other packages. The regenerated lockfile +> adds the package's `resolved` URL and `integrity` hash. This follow-up PR is required because +> the hash cannot exist until npm has published the package; it is not a release-workflow failure. > **Note:** Trusted publishing only works via `CreateRelease.yml` (the production release path). Manual publishing is deliberately discouraged — see [Manual npm publishing (emergency only)](#manual-npm-publishing-emergency-only) below. @@ -91,11 +113,12 @@ If you need to publish npm packages manually via `workflow_dispatch`, you'll nee 1. **Temporarily allow token-based publishing on npmjs.com** - Go to each package on [npmjs.com](https://www.npmjs.com/) → Settings → Publishing access - Change from "Require two-factor authentication and disallow tokens" to "Require two-factor authentication or automation tokens" - - Do this for all 4 packages: + - Do this for all 5 packages: - `@hyperlight-dev/js-host-api` - `@hyperlight-dev/js-host-api-linux-x64-gnu` - `@hyperlight-dev/js-host-api-linux-x64-musl` - `@hyperlight-dev/js-host-api-win32-x64-msvc` + - `@hyperlight-dev/js-host-api-darwin-arm64` 2. **Create an npm automation token** - Go to [npmjs.com](https://www.npmjs.com/) → Access Tokens → Generate New Token → Granular Access Token @@ -117,5 +140,5 @@ If you need to publish npm packages manually via `workflow_dispatch`, you'll nee 5. **Clean up immediately after publishing** - Delete the `NPM_TOKEN` repo secret on GitHub → Settings → Secrets and variables → Actions - Revoke the npm token on npmjs.com → Access Tokens - - Re-enable "Require two-factor authentication and disallow tokens" on all 4 packages + - Re-enable "Require two-factor authentication and disallow tokens" on all 5 packages - Verify the packages published correctly: `npm view @hyperlight-dev/js-host-api versions` diff --git a/src/hyperlight-js-runtime/include/math.h b/src/hyperlight-js-runtime/include/math.h new file mode 100644 index 00000000..dbe93f4d --- /dev/null +++ b/src/hyperlight-js-runtime/include/math.h @@ -0,0 +1,76 @@ +/* +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. +*/ + +/* + * Shim over the guest sysroot's . + * + * The guest sysroot is newlib. Newlib defines the C99 floating-point + * classification macros with `__builtin_*` only for GCC — clang is explicitly + * excluded (see the `!defined(__clang__)` guard around its `__builtin_fpclassify` + * block), so under clang it falls back to a sizeof-dispatch macro of this shape: + * + * #define isnan(__x) ((sizeof(__x) == sizeof(float)) ? __isnanf(__x) + * : (sizeof(__x) == sizeof(double)) ? __isnand((double)(__x)) + * : __isnanl((long double)(__x))) + * + * The selection happens at compile time, but *every* branch is still type-checked + * and code-generated, including the `(long double)` cast of a `double` argument. + * + * On x86_64 that cast is free: `long double` is the 80-bit x87 type and the + * extension is a hardware instruction. On aarch64 `long double` is IEEE binary128, + * so the cast becomes a call to the soft-float builtin `__extenddftf2` (and + * `__extendsftf2` for floats), which the guest does not link — the guest links + * Rust's `compiler_builtins`, whose f128 routines are not enabled in the + * precompiled bare-metal sysroot. QuickJS calls `isnan`/`isfinite` heavily, so + * this shows up as a wall of undefined-symbol errors at link time. + * + * We are compiled by clang (cargo-hyperlight always drives a clang C compiler), + * and clang's `__builtin_*` classification builtins are type-generic and lower to + * plain FP instructions with no libcall, on both architectures. So redefine the + * macros to exactly what newlib itself uses on its GCC path. + * + * This file is reachable because the guest build passes + * `-I /include` ahead of the `-isystem` sysroot, so this + * header wins the lookup for `` and `#include_next` then pulls in the + * real newlib header behind it. + */ + +#ifndef HYPERLIGHT_JS_MATH_SHIM_H +#define HYPERLIGHT_JS_MATH_SHIM_H + +#include_next + +#ifdef __clang__ + +#undef fpclassify +#undef isfinite +#undef isinf +#undef isnan +#undef isnormal +#undef signbit + +#define fpclassify(__x) \ + (__builtin_fpclassify(FP_NAN, FP_INFINITE, FP_NORMAL, FP_SUBNORMAL, \ + FP_ZERO, __x)) +#define isfinite(__x) (__builtin_isfinite(__x)) +#define isinf(__x) (__builtin_isinf_sign(__x)) +#define isnan(__x) (__builtin_isnan(__x)) +#define isnormal(__x) (__builtin_isnormal(__x)) +#define signbit(__x) (__builtin_signbit(__x)) + +#endif /* __clang__ */ + +#endif /* HYPERLIGHT_JS_MATH_SHIM_H */ diff --git a/src/hyperlight-js/Cargo.toml b/src/hyperlight-js/Cargo.toml index ab76a7c0..a7e324a8 100644 --- a/src/hyperlight-js/Cargo.toml +++ b/src/hyperlight-js/Cargo.toml @@ -29,7 +29,7 @@ tracing = "0.1.44" # Optional dependencies for execution monitors tokio = { version = "1.52", features = ["rt-multi-thread", "time", "sync", "macros"] } -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc = { version = "0.2", optional = true } [target.'cfg(target_os = "windows")'.dependencies] @@ -37,6 +37,7 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ [build-dependencies] cargo-hyperlight = "0.1.14" +cfg_aliases = "0.2.1" serde_json = { version = "1.0" } serde = { version = "1.0", features = ["derive"] } @@ -67,12 +68,16 @@ tracing-tracy = { version = "0.11", features = ["timer-fallback", "ondemand"] } uuid = "1.23.3" [features] -default = ["function_call_metrics", "kvm", "mshv3"] +default = ["function_call_metrics", "kvm", "mshv3", "hvf"] crashdump = ["hyperlight-host/crashdump"] gdb = ["hyperlight-host/gdb"] function_call_metrics = [] +# Hypervisor backends. Each is only active on the platform that provides it +# (`kvm`/`mshv3` on Linux, `hvf` on macOS, WHP is always on for Windows), so +# enabling all three by default is what makes a single build work everywhere. kvm = ["hyperlight-host/kvm"] mshv3 = ["hyperlight-host/mshv3"] +hvf = ["hyperlight-host/hvf"] print_debug = ["hyperlight-host/print_debug"] trace_guest = ["hyperlight-host/trace_guest"] guest-call-stats = [] diff --git a/src/hyperlight-js/build.rs b/src/hyperlight-js/build.rs index 03abf368..77757220 100644 --- a/src/hyperlight-js/build.rs +++ b/src/hyperlight-js/build.rs @@ -30,8 +30,24 @@ use std::path::{Path, PathBuf}; use std::{env, fs}; fn main() { + // Mirror the hypervisor cfg aliases used by hyperlight-host so that + // `#[cfg(kvm)]` etc. mean "feature enabled *and* on the platform that + // provides that hypervisor". Never use `#[cfg(feature = "kvm")]` directly. + cfg_aliases::cfg_aliases! { + kvm: { all(feature = "kvm", target_os = "linux") }, + mshv3: { all(feature = "mshv3", target_os = "linux") }, + hvf: { all(feature = "hvf", target_os = "macos") }, + whp: { target_os = "windows" }, + // hyperlight-host only implements crash dumps and the gdb debug stub on + // x86_64, so mirror its aliases — otherwise enabling either feature on + // aarch64 (e.g. macOS) would expose a wrapper around a method that does + // not exist. + crashdump: { all(feature = "crashdump", target_arch = "x86_64") }, + gdb: { all(feature = "gdb", debug_assertions, target_arch = "x86_64") }, + } + if env::var("DOCS_RS").is_ok() { - // docs.rs runs offline, so we can't prepare the sysroot for x86_64-hyperlight-none in there. + // docs.rs runs offline, so we can't prepare the sysroot for the guest target in there. // just bundle an empty resource to make sure the docs build correctly. bundle_dummy(); return; @@ -122,8 +138,25 @@ fn find_target_dir() -> PathBuf { target_dir.to_path_buf() } +/// The hyperlight guest target triple to build the JS runtime for. +/// +/// The guest runs inside the VM on the same architecture as the host, so this is +/// derived from the host crate's target arch rather than hardcoded. We pass it to +/// `cargo hyperlight` explicitly instead of relying on its default, because its +/// default is the arch of the `cargo-hyperlight` binary itself, which need not +/// match the arch we are building the host for. +fn guest_target() -> String { + let arch = env::var("CARGO_CFG_TARGET_ARCH").expect("CARGO_CFG_TARGET_ARCH is not set"); + assert!( + matches!(arch.as_str(), "x86_64" | "aarch64"), + "unsupported host architecture for hyperlight-js: {arch}" + ); + format!("{arch}-hyperlight-none") +} + fn build_js_runtime() -> PathBuf { let profile = env::var_os("PROFILE").unwrap(); + let guest_target = guest_target(); // Get the current target directory. let target_dir = find_target_dir(); @@ -161,6 +194,8 @@ fn build_js_runtime() -> PathBuf { let mut cargo_cmd = cargo_hyperlight::cargo().unwrap(); let cmd = cargo_cmd .arg("build") + .arg("--target") + .arg(&guest_target) .arg("--profile") .arg(cargo_profile) .arg("-v") @@ -192,7 +227,7 @@ fn build_js_runtime() -> PathBuf { }); let resource = target_dir - .join("x86_64-hyperlight-none") + .join(&guest_target) .join(profile) .join("hyperlight-js-runtime"); diff --git a/src/hyperlight-js/examples/runtime_debugging/main.rs b/src/hyperlight-js/examples/runtime_debugging/main.rs index 7e564c52..4fdff44f 100644 --- a/src/hyperlight-js/examples/runtime_debugging/main.rs +++ b/src/hyperlight-js/examples/runtime_debugging/main.rs @@ -16,14 +16,14 @@ limitations under the License. use hyperlight_js::{Result, SandboxBuilder, Script}; fn builder() -> SandboxBuilder { - #[cfg(all(feature = "gdb", debug_assertions))] + #[cfg(gdb)] { SandboxBuilder::new() .with_guest_input_buffer_size(2 * 1024 * 1024) // 2 MiB .with_guest_heap_size(10 * 1024 * 1024) // 10 MiB .with_debugging_enabled(8080) // debugging on port 8080 } - #[cfg(not(all(feature = "gdb", debug_assertions)))] + #[cfg(not(gdb))] SandboxBuilder::new() } @@ -32,16 +32,16 @@ fn main() -> Result<()> { let proto_js_sandbox = builder().build()?; - #[cfg(all(feature = "gdb", debug_assertions))] + #[cfg(gdb)] println!("🪳 You can now connect to the GDB server from another terminal and set breakpoints in the hyperlight-js-runtime code to debug it. \x1b[1m $ gdb target/hyperlight-js-runtime/x86_64-hyperlight-none/debug/hyperlight-js-runtime -ex \"target remote localhost:8080\"\x1b[0m "); - #[cfg(all(feature = "gdb", debug_assertions))] + #[cfg(gdb)] println!("ℹ️ Execution will resume once you have connected to the GDB server and continued execution."); - #[cfg(not(all(feature = "gdb", debug_assertions)))] - println!("⚠️ The GDB feature is not enabled, build with `--features=gdb` and in debug mode."); + #[cfg(not(gdb))] + println!("⚠️ The GDB feature is not enabled, build with `--features=gdb` and in debug mode on an x86_64 host."); let mut sandbox = proto_js_sandbox.load_runtime()?; diff --git a/src/hyperlight-js/src/sandbox/js_sandbox.rs b/src/hyperlight-js/src/sandbox/js_sandbox.rs index 980bd83c..1d224dd1 100644 --- a/src/hyperlight-js/src/sandbox/js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/js_sandbox.rs @@ -346,6 +346,10 @@ impl JSSandbox { /// This is only available when the `crashdump` feature is enabled and then only if the sandbox /// is also configured to allow core dumps (which is the default behavior). /// + /// hyperlight-host only implements crash dumps on x86_64, so this method is not compiled on + /// other architectures (for example aarch64 macOS or Linux). + /// + /// /// This can be useful for generating a crash dump from gdb when trying to debug issues in the /// guest that dont cause crashes (e.g. a guest function that does not return) /// @@ -369,8 +373,8 @@ impl JSSandbox { /// ``` /// The crashdump should be available in crash dump directory (see `HYPERLIGHT_CORE_DUMP_DIR` env var). /// - #[cfg(feature = "crashdump")] - pub fn generate_crashdump(&self) -> Result<()> { + #[cfg(crashdump)] + pub fn generate_crashdump(&mut self) -> Result<()> { self.inner.generate_crashdump() } } diff --git a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs index dc04f036..2958c783 100644 --- a/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs +++ b/src/hyperlight-js/src/sandbox/loaded_js_sandbox.rs @@ -345,6 +345,10 @@ impl LoadedJSSandbox { /// This is only available when the `crashdump` feature is enabled and then only if the sandbox /// is also configured to allow core dumps (which is the default behavior). /// + /// hyperlight-host only implements crash dumps on x86_64, so this method is not compiled on + /// other architectures (for example aarch64 macOS or Linux). + /// + /// /// This can be useful for generating a crash dump from gdb when trying to debug issues in the /// guest that dont cause crashes (e.g. a guest function that does not return) /// @@ -368,8 +372,8 @@ impl LoadedJSSandbox { /// ``` /// The crashdump should be available in crash dump directory (see `HYPERLIGHT_CORE_DUMP_DIR` env var). /// - #[cfg(feature = "crashdump")] - pub fn generate_crashdump(&self) -> Result<()> { + #[cfg(crashdump)] + pub fn generate_crashdump(&mut self) -> Result<()> { self.inner.generate_crashdump() } } diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs index d1a133f2..92dbff24 100644 --- a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs @@ -60,6 +60,8 @@ use super::ExecutionMonitor; /// # Platform Support /// /// - **Linux**: Uses `pthread_getcpuclockid` and `clock_gettime` (nanosecond precision) +/// - **macOS**: Uses a mach thread port and `thread_info(THREAD_BASIC_INFO)`, summing +/// user + system time (microsecond precision). /// - **Windows**: Uses `QueryThreadCycleTime` (reference cycles at CPU base frequency). /// The timeout is converted to a cycle budget once at setup using the CPU's nominal /// frequency from the Windows registry (`HKLM\...\CentralProcessor\0\~MHz`). @@ -178,6 +180,7 @@ impl ExecutionMonitor for CpuTimeMonitor { /// unit-agnostic and just compares `u64` ticks against a deadline. /// /// - **Linux**: Ticks are nanoseconds (from `clock_gettime`) +/// - **macOS**: Ticks are nanoseconds (from `thread_info`, microsecond resolution) /// - **Windows**: Ticks are TSC reference cycles (from `QueryThreadCycleTime`) #[cfg(target_os = "linux")] pub(crate) struct ThreadCpuHandle { @@ -248,9 +251,134 @@ impl ThreadCpuHandle { } } +// --------------------------------------------------------------------------- +// macOS +// --------------------------------------------------------------------------- + +/// `mach_port_deallocate` is not re-exported by the `libc` crate, but it lives in +/// libSystem which is already linked, so declare it here. +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn mach_port_deallocate( + task: libc::mach_port_t, + name: libc::mach_port_t, + ) -> libc::kern_return_t; +} + +/// `libc::MACH_PORT_NULL` is typed `i32` while `mach_port_t` is `c_uint`, so use a +/// correctly typed constant instead. +#[cfg(target_os = "macos")] +const MACH_PORT_NULL: libc::mach_port_t = 0; + +/// Handle for reading a specific thread's CPU time on macOS. +/// +/// macOS has no `pthread_getcpuclockid`, and `CLOCK_THREAD_CPUTIME_ID` only ever +/// reports the *calling* thread — which is no use here, because the monitor future +/// is polled on a different thread from the vCPU thread it is watching. Instead we +/// capture a mach send right to the thread at setup time and query it with +/// `thread_info(THREAD_BASIC_INFO)`, which is readable from any thread. +/// +/// Ticks are nanoseconds, though the underlying counters only have microsecond +/// resolution. +#[cfg(target_os = "macos")] +pub(crate) struct ThreadCpuHandle { + // Send/Sync are correctly auto-derived here, unlike the Linux and Windows + // backends which need an explicit `unsafe impl`: `mach_port_t` is a bare + // `u32` name, not a pointer. Mach port rights are scoped to the task, so + // `thread_info` and `mach_port_deallocate` are safe to call on this name + // from any thread in the process. + thread_port: libc::mach_port_t, +} + +#[cfg(target_os = "macos")] +impl ThreadCpuHandle { + /// Create a handle for the current thread's CPU time. + pub fn for_current_thread() -> Option { + // mach_thread_self() returns a send right that we own and must release + // in Drop, unlike the Windows pseudo-handle from GetCurrentThread(). + let thread_port = unsafe { libc::mach_thread_self() }; + if thread_port == MACH_PORT_NULL { + tracing::warn!("[CPU_TIME] mach_thread_self() returned a null port"); + return None; + } + + let handle = Self { thread_port }; + + // Verify up-front that the port is actually queryable, so that a broken + // setup is reported as "no CPU monitoring" rather than failing mid-run. + handle.elapsed()?; + + Some(handle) + } + + /// Get the elapsed CPU ticks (nanoseconds) for this thread. + /// + /// This is the thread's cumulative user + system time, matching the absolute + /// semantics of the Linux backend. + pub fn elapsed(&self) -> Option { + // SAFETY: an all-zero thread_basic_info is a valid initial value; every + // field is a plain integer. thread_info() overwrites it on success. + let mut info: libc::thread_basic_info = unsafe { std::mem::zeroed() }; + let mut count = libc::THREAD_BASIC_INFO_COUNT; + + let result = unsafe { + libc::thread_info( + self.thread_port, + libc::THREAD_BASIC_INFO as libc::thread_flavor_t, + &mut info as *mut libc::thread_basic_info as libc::thread_info_t, + &mut count, + ) + }; + + // KERN_SUCCESS + if result != 0 { + tracing::warn!( + "[CPU_TIME] thread_info() failed with kern_return_t {}", + result + ); + return None; + } + + let to_nanos = |t: libc::time_value_t| { + (t.seconds.max(0) as u64) + .saturating_mul(1_000_000_000) + .saturating_add((t.microseconds.max(0) as u64).saturating_mul(1_000)) + }; + + Some(to_nanos(info.user_time).saturating_add(to_nanos(info.system_time))) + } + + /// Convert a `Duration` timeout into a tick budget in the platform's native unit. + /// + /// On macOS, ticks are nanoseconds so this is an identity conversion. + pub fn deadline_for(&self, timeout: Duration) -> Option { + Some(timeout.as_nanos() as u64) + } + + /// Convert ticks to approximate nanoseconds (for logging and sleep calculations). + /// + /// On macOS, ticks are nanoseconds so this is an identity conversion. + pub fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { + ticks + } +} + +#[cfg(target_os = "macos")] +impl Drop for ThreadCpuHandle { + fn drop(&mut self) { + if self.thread_port != MACH_PORT_NULL { + // Release the send right acquired by mach_thread_self(). + unsafe { mach_port_deallocate(libc::mach_task_self(), self.thread_port) }; + } + } +} + +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + #[cfg(target_os = "windows")] use windows_sys::Win32::System::WindowsProgramming::QueryThreadCycleTime; - #[cfg(target_os = "windows")] pub(crate) struct ThreadCpuHandle { thread_handle: windows_sys::Win32::Foundation::HANDLE, diff --git a/src/hyperlight-js/src/sandbox/sandbox_builder.rs b/src/hyperlight-js/src/sandbox/sandbox_builder.rs index cffa161e..00f48928 100644 --- a/src/hyperlight-js/src/sandbox/sandbox_builder.rs +++ b/src/hyperlight-js/src/sandbox/sandbox_builder.rs @@ -13,7 +13,7 @@ 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. */ -#[cfg(target_os = "linux")] +#[cfg(any(kvm, mshv3, hvf))] use std::time::Duration; use hyperlight_host::sandbox::SandboxConfiguration; @@ -124,7 +124,10 @@ impl SandboxBuilder { /// Sets the interrupt retry delay /// This controls the delay between sending signals to the VCPU thread to interrupt it. - #[cfg(target_os = "linux")] + /// + /// Only available for the hypervisor backends that use retrying interrupts + /// (KVM and MSHV on Linux, Hypervisor.framework on macOS). + #[cfg(any(kvm, mshv3, hvf))] pub fn with_interrupt_retry_delay(mut self, delay: Duration) -> Self { self.config.set_interrupt_retry_delay(delay); self @@ -138,7 +141,7 @@ impl SandboxBuilder { /// Enable or disable crashdump generation for the sandbox /// When enabled, core dumps will be generated when the guest crashes /// This requires the `crashdump` feature to be enabled - #[cfg(feature = "crashdump")] + #[cfg(crashdump)] pub fn with_crashdump_enabled(mut self, enabled: bool) -> Self { self.config.set_guest_core_dump(enabled); self @@ -157,9 +160,10 @@ impl SandboxBuilder { /// .expect("Failed to build sandbox"); /// ``` /// # Note: - /// This method is only available when the `gdb` feature is enabled - /// and the code is compiled in debug mode. - #[cfg(all(feature = "gdb", debug_assertions))] + /// This method is only available when the `gdb` feature is enabled, the + /// code is compiled in debug mode, and the target architecture is x86_64. + /// hyperlight-host only implements the gdb debug stub on x86_64. + #[cfg(gdb)] pub fn with_debugging_enabled(mut self, port: u16) -> Self { let debug_info = hyperlight_host::sandbox::config::DebugInfo { port }; self.config.set_guest_debug_info(debug_info); diff --git a/src/hyperlight-js/tests/native_modules.rs b/src/hyperlight-js/tests/native_modules.rs index 28dc0f6e..d8b3596f 100644 --- a/src/hyperlight-js/tests/native_modules.rs +++ b/src/hyperlight-js/tests/native_modules.rs @@ -17,7 +17,8 @@ limitations under the License. //! Integration tests for custom native modules in the Hyperlight VM. //! //! These tests require a custom runtime (the `extended_runtime` fixture) -//! built for `x86_64-hyperlight-none` and embedded in `hyperlight-js` via +//! built for the host's guest target (`x86_64-hyperlight-none` on x86_64, +//! `aarch64-hyperlight-none` on aarch64) and embedded in `hyperlight-js` via //! `HYPERLIGHT_JS_RUNTIME_PATH`. They are marked `#[ignore]` because they //! cannot run with a normal `cargo test`. //! diff --git a/src/js-host-api/DEVELOPMENT.md b/src/js-host-api/DEVELOPMENT.md index fe9cf17d..8ad5b9a2 100644 --- a/src/js-host-api/DEVELOPMENT.md +++ b/src/js-host-api/DEVELOPMENT.md @@ -66,6 +66,7 @@ The npm release consists of the following packages: | `@hyperlight-dev/js-host-api-linux-x64-gnu` | Linux x86_64 (glibc) native binary | | `@hyperlight-dev/js-host-api-linux-x64-musl` | Linux x86_64 (musl/Alpine) native binary | | `@hyperlight-dev/js-host-api-win32-x64-msvc` | Windows x86_64 native binary | +| `@hyperlight-dev/js-host-api-darwin-arm64` | macOS aarch64 (Apple Silicon) native binary | ### How Platform Selection Works diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index 18bfa605..d1a39439 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -821,7 +821,7 @@ dependencies, custom namespaces, multiple handlers sharing a module, and ## Requirements - **Node.js** >= 18 -- **Linux** (x86_64, glibc or musl) or **Windows** (x86_64) +- **Linux** (x86_64, glibc or musl), **Windows** (x86_64), or **macOS** (Apple Silicon / aarch64) ## License diff --git a/src/js-host-api/npm/darwin-arm64/package.json b/src/js-host-api/npm/darwin-arm64/package.json new file mode 100644 index 00000000..ed98e78e --- /dev/null +++ b/src/js-host-api/npm/darwin-arm64/package.json @@ -0,0 +1,27 @@ +{ + "name": "@hyperlight-dev/js-host-api-darwin-arm64", + "version": "0.4.0", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "js-host-api.darwin-arm64.node", + "files": [ + "js-host-api.darwin-arm64.node" + ], + "description": "Node.js API bindings for Hyperlight JS - macOS arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/hyperlight-dev/hyperlight-js.git" + }, + "homepage": "https://github.com/hyperlight-dev/hyperlight-js#readme", + "bugs": { + "url": "https://github.com/hyperlight-dev/hyperlight-js/issues" + }, + "engines": { + "node": ">= 18" + } +} diff --git a/src/js-host-api/package-lock.json b/src/js-host-api/package-lock.json index fd20cc40..3b2ad802 100644 --- a/src/js-host-api/package-lock.json +++ b/src/js-host-api/package-lock.json @@ -19,6 +19,7 @@ "node": ">= 18" }, "optionalDependencies": { + "@hyperlight-dev/js-host-api-darwin-arm64": "0.4.0", "@hyperlight-dev/js-host-api-linux-x64-gnu": "0.4.0", "@hyperlight-dev/js-host-api-linux-x64-musl": "0.4.0", "@hyperlight-dev/js-host-api-win32-x64-msvc": "0.4.0" diff --git a/src/js-host-api/package.json b/src/js-host-api/package.json index 57850159..c24ae371 100644 --- a/src/js-host-api/package.json +++ b/src/js-host-api/package.json @@ -30,11 +30,13 @@ "targets": [ "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", - "x86_64-pc-windows-msvc" + "x86_64-pc-windows-msvc", + "aarch64-apple-darwin" ] }, "license": "Apache-2.0", "optionalDependencies": { + "@hyperlight-dev/js-host-api-darwin-arm64": "0.4.0", "@hyperlight-dev/js-host-api-linux-x64-gnu": "0.4.0", "@hyperlight-dev/js-host-api-linux-x64-musl": "0.4.0", "@hyperlight-dev/js-host-api-win32-x64-msvc": "0.4.0" From 6791f66c717605bd1be178d677f30f06a745c68d Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 19:38:00 +0100 Subject: [PATCH 2/5] Harden macOS llvm-tools setup against rustup download races The self-hosted Mac minis are shared and non-ephemeral, so concurrent jobs race on ~/.rustup/downloads and rustup intermittently dies with "could not rename 'downloaded' file from .partial". Skip the download when llvm-ar is already present and retry with backoff otherwise, failing loudly if it still cannot be found. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- .github/workflows/dep_build.yml | 32 +++++++++++++++++++++++++++++++ .github/workflows/npm-publish.yml | 27 ++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/.github/workflows/dep_build.yml b/.github/workflows/dep_build.yml index 02d99364..8ecae067 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -87,6 +87,38 @@ jobs: rust-toolchain: "1.90" just-version: "1.51" + # Unlike upstream hyperlight, which builds its guests once per architecture + # and ships them to the test jobs as artifacts, hyperlight-js builds the JS + # guest runtime from build.rs during every `cargo build`. That makes a + # bare-metal ELF toolchain a hard requirement on macOS too. + # + # macOS's system archiver cannot produce ELF archives. cargo-hyperlight + # prefers llvm-ar on macOS for exactly that reason, but silently falls back + # to /usr/bin/ar when llvm-ar is missing from PATH, which yields a guest + # link with every QuickJS and libc symbol undefined. The runners have no + # package manager, so take llvm-ar from rustup's llvm-tools component, + # which is version-matched to the pinned toolchain. + - name: Set up LLVM guest toolchain (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + llvm_bin="$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n 's/^host: //p')/bin" + for attempt in 1 2 3; do + if [ -x "$llvm_bin/llvm-ar" ]; then + break + fi + echo "Installing llvm-tools (attempt $attempt)" + rustup component add llvm-tools || true + [ -x "$llvm_bin/llvm-ar" ] || sleep $((attempt * 10)) + done + if [ ! -x "$llvm_bin/llvm-ar" ]; then + echo "llvm-ar not found in $llvm_bin" >&2 + exit 1 + fi + echo "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version + - name: install nodejs uses: actions/setup-node@v7 with: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 286f9924..510c716e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -83,6 +83,33 @@ jobs: cache: 'npm' cache-dependency-path: 'src/js-host-api/package-lock.json' + # Building js-host-api runs hyperlight-js's build.rs, which builds the JS + # guest runtime for a bare-metal ELF target. macOS's system archiver + # cannot produce ELF archives, and cargo-hyperlight silently falls back to + # it when llvm-ar is absent from PATH, leaving every QuickJS and libc + # symbol undefined at the guest link. Take llvm-ar from rustup's + # llvm-tools component so this does not depend on a package manager. + - name: Set up LLVM guest toolchain (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + llvm_bin="$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n 's/^host: //p')/bin" + for attempt in 1 2 3; do + if [ -x "$llvm_bin/llvm-ar" ]; then + break + fi + echo "Installing llvm-tools (attempt $attempt)" + rustup component add llvm-tools || true + [ -x "$llvm_bin/llvm-ar" ] || sleep $((attempt * 10)) + done + if [ ! -x "$llvm_bin/llvm-ar" ]; then + echo "llvm-ar not found in $llvm_bin" >&2 + exit 1 + fi + echo "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version + - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} run: npm ci --ignore-scripts --omit=optional From 081e9853ade743794559cbdb8e9c89486707eb6d Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:04:35 +0100 Subject: [PATCH 3/5] Fix macOS clippy failures in the mach CPU-time backend The macOS job is the first thing that has ever compiled this code, and clippy runs with -D warnings, so three diagnostics failed the lint step: a doc comment on an extern block (rustdoc does not document those), and libc's mach_thread_self / mach_task_self both being deprecated in favour of the mach2 crate. Declare mach_thread_self alongside the existing mach_port_deallocate declaration rather than taking a new dependency for two symbols. mach_task_self is a C macro over the mach_task_self_ global, so declare that global directly; it is only ever read, so an immutable extern static is sufficient. Demote the extern block's doc comment to a plain comment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- src/hyperlight-js/src/sandbox/monitor/cpu_time.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs index 92dbff24..20b036a9 100644 --- a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs @@ -255,14 +255,20 @@ impl ThreadCpuHandle { // macOS // --------------------------------------------------------------------------- -/// `mach_port_deallocate` is not re-exported by the `libc` crate, but it lives in -/// libSystem which is already linked, so declare it here. +// These mach symbols are either not re-exported by the `libc` crate or are +// deprecated there in favour of the `mach2` crate. They live in libSystem, which +// is already linked, so declare the three we need rather than take a new +// dependency for them. `mach_task_self` is a C macro over the `mach_task_self_` +// global, so declare the global itself; it is only ever read. #[cfg(target_os = "macos")] unsafe extern "C" { fn mach_port_deallocate( task: libc::mach_port_t, name: libc::mach_port_t, ) -> libc::kern_return_t; + fn mach_thread_self() -> libc::mach_port_t; + #[allow(non_upper_case_globals)] + static mach_task_self_: libc::mach_port_t; } /// `libc::MACH_PORT_NULL` is typed `i32` while `mach_port_t` is `c_uint`, so use a @@ -296,7 +302,7 @@ impl ThreadCpuHandle { pub fn for_current_thread() -> Option { // mach_thread_self() returns a send right that we own and must release // in Drop, unlike the Windows pseudo-handle from GetCurrentThread(). - let thread_port = unsafe { libc::mach_thread_self() }; + let thread_port = unsafe { mach_thread_self() }; if thread_port == MACH_PORT_NULL { tracing::warn!("[CPU_TIME] mach_thread_self() returned a null port"); return None; @@ -368,7 +374,7 @@ impl Drop for ThreadCpuHandle { fn drop(&mut self) { if self.thread_port != MACH_PORT_NULL { // Release the send right acquired by mach_thread_self(). - unsafe { mach_port_deallocate(libc::mach_task_self(), self.thread_port) }; + unsafe { mach_port_deallocate(mach_task_self_, self.thread_port) }; } } } From 10e130ee4cdf9c3f879717f3672a7a47b74391e3 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:33:57 +0100 Subject: [PATCH 4/5] Sign macOS test binaries with the hypervisor entitlement Every sandbox test failed on macOS with HyperlightVmError(Create(Vm(CreateVm(CreateVmFd(HvfError(0xfae94007)))))). 0xfae94007 is HV_DENIED: Hypervisor.framework refuses hv_vm_create() unless the calling process carries com.apple.security.hypervisor, which is mandatory on Apple Silicon and which plain cargo test binaries do not have. Add a cargo target runner for macOS that ad-hoc codesigns each binary with that entitlement before executing it, mirroring dev/macos-sign-and-run.sh in hyperlight-dev/hyperlight. Cargo resolves a runner path containing a separator relative to the config file rather than the working directory, so a single root .cargo/config.toml also covers the recipes that cd into src/hyperlight-js. The guest target is unaffected because the runner is scoped to cfg(target_os = "macos"). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- .cargo/config.toml | 6 ++++++ dev/macos-entitlements.plist | 8 ++++++++ dev/macos-sign-and-run.sh | 11 +++++++++++ 3 files changed, 25 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 dev/macos-entitlements.plist create mode 100755 dev/macos-sign-and-run.sh diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..ecfa7fba --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +# Hypervisor.framework requires the com.apple.security.hypervisor entitlement, +# so run macOS host binaries through a wrapper that ad-hoc signs them first. +# Cargo resolves this path relative to this file, not the working directory, +# so it also applies to the recipes that cd into src/hyperlight-js. +[target.'cfg(target_os = "macos")'] +runner = "dev/macos-sign-and-run.sh" diff --git a/dev/macos-entitlements.plist b/dev/macos-entitlements.plist new file mode 100644 index 00000000..c2ef1a38 --- /dev/null +++ b/dev/macos-entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.hypervisor + + + diff --git a/dev/macos-sign-and-run.sh b/dev/macos-sign-and-run.sh new file mode 100755 index 00000000..c27d3ce7 --- /dev/null +++ b/dev/macos-sign-and-run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Cargo target runner for macOS. +# +# Hypervisor.framework refuses hv_vm_create() with HV_DENIED (0xfae94007) +# unless the calling process carries the com.apple.security.hypervisor +# entitlement, so ad-hoc sign each test/bench/example binary before running +# it. Mirrors the same script in hyperlight-dev/hyperlight. +set -Eeuo pipefail + +codesign -f -s - --entitlements "$(dirname "$0")/macos-entitlements.plist" "$1" +exec "$@" From 2ae81beff8ab8edae0bcc822252bd617f583ff76 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:49:06 +0100 Subject: [PATCH 5/5] Sign node for Hypervisor.framework on macOS The cargo runner added in .cargo/config.toml only signs binaries cargo launches, so the js-host-api suite -- which runs under node via vitest -- still failed with HV_DENIED: 115 failed, 17 passed, the survivors being the tests that never create a VM. Copy node into RUNNER_TEMP, ad-hoc sign the copy with the hypervisor entitlement and prepend it to PATH. npm resolves node through a /usr/bin/env shebang and entitlements apply per exec, so vitest and its workers pick the signed copy up. Sign a private copy rather than the shared tool cache because these runners are not ephemeral. npm-publish.yml needs no equivalent: it only builds and publishes, and never creates a VM. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- .github/workflows/dep_build.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/dep_build.yml b/.github/workflows/dep_build.yml index 8ecae067..0a5c7712 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -126,6 +126,23 @@ jobs: cache: 'npm' cache-dependency-path: 'src/js-host-api/package-lock.json' + # The cargo runner in .cargo/config.toml only signs binaries cargo itself + # launches, so the js-host-api tests -- which run under node via vitest -- + # still hit HV_DENIED. Sign a private copy rather than the shared tool + # cache: these runners are not ephemeral. + - name: Sign node for Hypervisor.framework (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + signed_bin="$RUNNER_TEMP/node-signed" + mkdir -p "$signed_bin" + cp "$(command -v node)" "$signed_bin/node" + codesign -f -s - --entitlements dev/macos-entitlements.plist "$signed_bin/node" + codesign -d --entitlements - "$signed_bin/node" + echo "$signed_bin" >> "$GITHUB_PATH" + "$signed_bin/node" --version + - name: fmt run: just fmt-check