From 855d2ac1a3361899bb6bebd660ca72279844cb5c Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 18:32:17 +0100 Subject: [PATCH 1/6] 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 f10f84a4..03de0103 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1749,6 +1749,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 7f1de0bd..8de963b8 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 8aec0c01..2b60cb24 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 ef0cc58b7937487e97e88c4113233cda0e69a9d7 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 19:38:00 +0100 Subject: [PATCH 2/6] 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 7f72d4f1aa367b175714dae5bfa12a9641d494e4 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:04:35 +0100 Subject: [PATCH 3/6] 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 a57227145ec7cdd6bf9ab8ef7aea8bc12209c854 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:33:57 +0100 Subject: [PATCH 4/6] 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 92a0a2427a33d43b7d197dfcf61de5532da391fd Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 16 Sep 2026 20:49:06 +0100 Subject: [PATCH 5/6] 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 From 35f931167c7e2788c1a9d545cff2c6159ac8fb36 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Mon, 28 Sep 2026 20:26:16 +0100 Subject: [PATCH 6/6] Address PR review feedback Fix release publishing and validation, make CI architecture and LLVM setup explicit, and split CPU-time implementations by platform. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- .github/workflows/CreateRelease.yml | 1 + .github/workflows/dep_build.yml | 48 +- .github/workflows/npm-publish.yml | 53 +- docs/release.md | 9 +- rust-toolchain.toml | 1 + .../src/sandbox/monitor/cpu_time.rs | 658 ------------------ .../src/sandbox/monitor/cpu_time/linux.rs | 71 ++ .../src/sandbox/monitor/cpu_time/macos.rs | 96 +++ .../src/sandbox/monitor/cpu_time/mod.rs | 256 +++++++ .../src/sandbox/monitor/cpu_time/windows.rs | 169 +++++ src/js-host-api/test-pack.sh | 41 +- 11 files changed, 701 insertions(+), 702 deletions(-) delete mode 100644 src/hyperlight-js/src/sandbox/monitor/cpu_time.rs create mode 100644 src/hyperlight-js/src/sandbox/monitor/cpu_time/linux.rs create mode 100644 src/hyperlight-js/src/sandbox/monitor/cpu_time/macos.rs create mode 100644 src/hyperlight-js/src/sandbox/monitor/cpu_time/mod.rs create mode 100644 src/hyperlight-js/src/sandbox/monitor/cpu_time/windows.rs diff --git a/.github/workflows/CreateRelease.yml b/.github/workflows/CreateRelease.yml index aa989b79..db8fd8c4 100644 --- a/.github/workflows/CreateRelease.yml +++ b/.github/workflows/CreateRelease.yml @@ -82,6 +82,7 @@ jobs: publish-npm-packages: needs: [build, benchmarks, set-version] uses: ./.github/workflows/npm-publish.yml + secrets: inherit with: version: ${{ needs.set-version.outputs.version }} dry_run: ${{ needs.set-version.outputs.dry_run != 'false' }} diff --git a/.github/workflows/dep_build.yml b/.github/workflows/dep_build.yml index 0a5c7712..d55b26d6 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -24,6 +24,7 @@ jobs: - build: windows-2025-debug os: [self-hosted, Windows, X64, "1ES.Pool=hld-win2025-amd"] hypervisor: whp + arch: X64 config: debug - build: linux-kvm-debug os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] @@ -38,14 +39,17 @@ jobs: - build: linux-hyperv3-debug os: [self-hosted, Linux, X64, "1ES.Pool=hld-azlinux3-mshv-amd"] hypervisor: hyperv3 + arch: X64 config: debug - build: macos-hvf-debug os: [self-hosted, macos, arm64, hvf] hypervisor: hvf + arch: arm64 config: debug - build: windows-2025-release os: [self-hosted, Windows, X64, "1ES.Pool=hld-win2025-amd"] hypervisor: whp + arch: X64 config: release - build: linux-kvm-release os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] @@ -60,17 +64,20 @@ jobs: - build: linux-hyperv3-release os: [self-hosted, Linux, X64, "1ES.Pool=hld-azlinux3-mshv-amd"] hypervisor: hyperv3 + arch: X64 config: release - build: macos-hvf-release os: [self-hosted, macos, arm64, hvf] hypervisor: hvf + arch: arm64 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}"]', + || format('["self-hosted", "{0}", "{1}", "1ES.Pool=hld-{2}-amd", "JobId=build-{3}-{4}-{5}-{6}"]', matrix.hypervisor == 'whp' && 'Windows' || 'Linux', + matrix.arch, matrix.hypervisor == 'whp' && 'win2025' || matrix.hypervisor == 'hyperv3' && 'azlinux3-mshv' || 'kvm', matrix.build, github.run_id, @@ -95,29 +102,36 @@ jobs: # 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. + # link with every QuickJS and libc symbol undefined. Prefer a provisioned + # llvm-ar when the runner image supplies one; otherwise use the active Rust + # toolchain's llvm-tools component, installing it if necessary. - 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 + if command -v llvm-ar >/dev/null 2>&1; then + echo "Using provisioned llvm-ar from PATH" + llvm-ar --version + else + 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)" + if ! rustup component add llvm-tools; then + echo "llvm-tools installation attempt $attempt failed" >&2 + fi + [ -x "$llvm_bin/llvm-ar" ] || sleep $((attempt * 10)) + done + if [ ! -x "$llvm_bin/llvm-ar" ]; then + echo "llvm-ar not found on PATH or in $llvm_bin" >&2 + exit 1 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 + echo "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version fi - echo "$llvm_bin" >> "$GITHUB_PATH" - "$llvm_bin/llvm-ar" --version - name: install nodejs uses: actions/setup-node@v7 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 510c716e..51b1a992 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -30,6 +30,9 @@ on: required: false type: boolean default: false + secrets: + NPM_TOKEN: + required: false permissions: contents: read @@ -87,28 +90,35 @@ jobs: # 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. + # symbol undefined at the guest link. Prefer a provisioned llvm-ar and + # fall back to the active Rust toolchain's llvm-tools component. - 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 + if command -v llvm-ar >/dev/null 2>&1; then + echo "Using provisioned llvm-ar from PATH" + llvm-ar --version + else + 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)" + if ! rustup component add llvm-tools; then + echo "llvm-tools installation attempt $attempt failed" >&2 + fi + [ -x "$llvm_bin/llvm-ar" ] || sleep $((attempt * 10)) + done + if [ ! -x "$llvm_bin/llvm-ar" ]; then + echo "llvm-ar not found on PATH or in $llvm_bin" >&2 + exit 1 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 + echo "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version fi - echo "$llvm_bin" >> "$GITHUB_PATH" - "$llvm_bin/llvm-ar" --version - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} @@ -280,12 +290,13 @@ jobs: - name: Validate packages working-directory: ${{ env.WORKING_DIR }} run: ./test-pack.sh + env: + REQUIRE_MACOS_PACKAGE: "1" # ── Authentication strategy ──────────────────────────────────── # 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: | @@ -300,35 +311,35 @@ jobs: - 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 ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-gnu,')) && '' || '--provenance' }} + run: npm publish --access public --ignore-scripts --provenance env: 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 ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-linux-x64-musl,')) && '' || '--provenance' }} + run: npm publish --access public --ignore-scripts --provenance env: 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 ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api-win32-x64-msvc,')) && '' || '--provenance' }} + run: npm publish --access public --ignore-scripts --provenance env: 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' }} + run: npm publish --access public --ignore-scripts --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 ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api,')) && '' || '--provenance' }} + run: npm publish --access public --ignore-scripts --provenance env: NODE_AUTH_TOKEN: ${{ (github.event_name == 'workflow_dispatch' || contains(env.FIRST_TIME_PACKAGES, ',@hyperlight-dev/js-host-api,')) && secrets.NPM_TOKEN || '' }} diff --git a/docs/release.md b/docs/release.md index b2744dc9..e09d3fa4 100644 --- a/docs/release.md +++ b/docs/release.md @@ -55,7 +55,7 @@ Pushing a `vX.Y.Z` tag is the **only** manual trigger you need — you do **not* 3. **Publishes the crates to crates.io** — in dependency order (`hyperlight-js-common` → `hyperlight-js-runtime` → `hyperlight-js`). Verify on the [hyperlight-js page on crates.io](https://crates.io/crates/hyperlight-js). 4. **Publishes the npm packages to npmjs.com** — `@hyperlight-dev/js-host-api` and its platform-specific binary packages, with their versions set from the tag. Verify on the [npmjs.com package page](https://www.npmjs.com/package/@hyperlight-dev/js-host-api). -Both crates.io and npm publishing use trusted publishing (OIDC), so no `NPM_TOKEN` or crates.io token secret is needed for the `CreateRelease` workflow. Provenance attestations are generated automatically for the npm packages. +Both crates.io and established npm packages use trusted publishing (OIDC), so they do not need long-lived publishing tokens. A new npm package needs a short-lived `NPM_TOKEN` for its first release, as described below. Provenance attestations are generated for every npm package. > **Note:** Only a `vX.Y.Z` **tag** push triggers a real release. Pushing to `main`, or running the workflow manually with **Run workflow**, performs a **dry run** — it builds and validates everything but publishes nothing. @@ -79,12 +79,13 @@ This must be done for all 5 packages: > 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. +> trusted publishing (OIDC). Both authentication paths publish with npm provenance. > > 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 +> delete the `NPM_TOKEN` repository secret, revoke the short-lived npm token, 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 @@ -106,7 +107,7 @@ Once the PR is merged, then you should follow the instructions above. In this in ## Manual npm publishing (emergency only) -> ⚠️ **Do not use this for regular releases.** Use the `CreateRelease` workflow instead. Manual publishing bypasses OIDC trusted publishing and will **not** generate provenance attestations — meaning publish will show up without the "Published via trusted publishing" badge on npmjs.com. Only use this if the automated release pipeline is broken and you need to ship an urgent fix. +> ⚠️ **Do not use this for regular releases.** Use the `CreateRelease` workflow instead. Manual publishing uses `NPM_TOKEN` instead of OIDC trusted publishing. It still generates provenance attestations, but the release will not show the "Published via trusted publishing" badge on npmjs.com. Only use this if the automated release pipeline is broken and you need to ship an urgent fix. If you need to publish npm packages manually via `workflow_dispatch`, you'll need to: diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 73328e05..93d3914a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,3 @@ [toolchain] channel = "1.90" +components = ["clippy", "llvm-tools", "rustfmt"] diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs deleted file mode 100644 index 20b036a9..00000000 --- a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs +++ /dev/null @@ -1,658 +0,0 @@ -/* -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. -*/ -//! CPU time based execution monitor. -//! -//! This module provides monitoring based on actual CPU execution time rather than -//! wall-clock time, making it more accurate for billing and resistant to time-wasting -//! attacks that use sleep/blocking calls. -//! -//! For comprehensive protection, combine with [`WallClockMonitor`] via a tuple to -//! catch both compute-bound abuse and resource exhaustion: -//! -//! ```text -//! let monitor = ( -//! WallClockMonitor::new(Duration::from_secs(5))?, -//! CpuTimeMonitor::new(Duration::from_millis(500))?, -//! ); -//! ``` - -use std::future::Future; -use std::time::Duration; - -use hyperlight_host::{HyperlightError, Result}; - -use super::ExecutionMonitor; - -/// Monitors handler execution using CPU time. -/// -/// Terminates execution if the handler consumes more CPU time than the configured limit. -/// This measures actual computation time, not time spent blocked or waiting. -/// -/// # Combining with Wall-Clock Monitoring -/// -/// `CpuTimeMonitor` only catches compute-bound abuse. To also catch resource exhaustion -/// (where a guest holds host resources without burning CPU), combine with -/// [`WallClockMonitor`] as a tuple: -/// -/// ```text -/// let monitor = ( -/// WallClockMonitor::new(Duration::from_secs(5))?, -/// CpuTimeMonitor::new(Duration::from_millis(500))?, -/// ); -/// ``` -/// -/// The tuple races both monitors — whichever fires first terminates execution, -/// and the winning monitor's name is logged. -/// -/// # 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`). -/// Monitoring compares raw cycle counts directly. -/// Accuracy depends on invariant TSC support but should be good on modern CPUs. -/// -/// # Example -/// -/// ```text -/// use hyperlight_js::CpuTimeMonitor; -/// use std::time::Duration; -/// -/// let monitor = CpuTimeMonitor::new(Duration::from_millis(100))?; -/// let result = sandbox.handle_event_with_monitor("handler", "{}".to_string(), &monitor, None)?; -/// ``` -#[derive(Debug, Clone)] -pub struct CpuTimeMonitor { - cpu_timeout: Duration, -} - -impl CpuTimeMonitor { - /// Create a new CPU time monitor. - /// - /// # Arguments - /// - /// * `cpu_timeout` - Maximum CPU time allowed for execution. - /// - /// # Errors - /// - /// Returns an error if `cpu_timeout` is zero. - pub fn new(cpu_timeout: Duration) -> Result { - if cpu_timeout.is_zero() { - return Err(HyperlightError::Error( - "cpu_timeout must be non-zero".to_string(), - )); - } - Ok(Self { cpu_timeout }) - } -} - -impl ExecutionMonitor for CpuTimeMonitor { - fn get_monitor(&self) -> Result + Send + 'static> { - // Capture CPU time handle on the calling thread - let cpu_handle = ThreadCpuHandle::for_current_thread().ok_or_else(|| { - HyperlightError::Error("Failed to get CPU time handle for current thread".to_string()) - })?; - - let cpu_timeout = self.cpu_timeout; - - // Compute deadline in platform-native ticks (nanos on Linux, TSC cycles on Windows). - // This conversion is done once here, not on every poll iteration. - let start_ticks = cpu_handle - .elapsed() - .ok_or_else(|| HyperlightError::Error("Failed to read initial CPU time".to_string()))?; - let tick_budget = cpu_handle.deadline_for(cpu_timeout).ok_or_else(|| { - HyperlightError::Error("Failed to compute CPU tick deadline".to_string()) - })?; - let deadline = start_ticks.saturating_add(tick_budget); - - Ok(async move { - loop { - // Read current ticks in the platform's native unit - let current = match cpu_handle.elapsed() { - Some(t) => t, - None => { - // CPU time reading failed mid-execution. Log the error - // and return immediately to trigger termination (fail-closed). - tracing::error!( - "Failed to read CPU time — terminating execution (fail-closed)" - ); - return; - } - }; - - if current >= deadline { - let elapsed_ticks = current.saturating_sub(start_ticks); - let elapsed_ms = cpu_handle.ticks_to_approx_nanos(elapsed_ticks) / 1_000_000; - tracing::warn!( - cpu_elapsed_ms = elapsed_ms, - cpu_timeout_ms = cpu_timeout.as_millis() as u64, - "CPU time limit exceeded, terminating execution" - ); - return; - } - - // Adaptive sleep: half of remaining time, clamped to reasonable bounds. - // Tokio's timer wheel resolution is ~1ms, so that's our effective floor. - // The maximum keeps polls frequent enough for reasonable deadline accuracy. - // Convert remaining ticks to approximate nanos for the sleep Duration. - const MIN_POLL_INTERVAL: Duration = Duration::from_millis(1); - const MAX_POLL_INTERVAL: Duration = Duration::from_millis(10); - const ADAPTIVE_DIVISOR: u64 = 2; - - let remaining = deadline.saturating_sub(current); - let remaining_nanos = cpu_handle.ticks_to_approx_nanos(remaining); - let sleep_duration = Duration::from_nanos(remaining_nanos / ADAPTIVE_DIVISOR) - .clamp(MIN_POLL_INTERVAL, MAX_POLL_INTERVAL); - - super::sleep(sleep_duration).await; - } - }) - } - - fn name(&self) -> &'static str { - "cpu-time" - } -} - -// ============================================================================ -// Platform-specific ThreadCpuHandle implementations -// ============================================================================ - -/// Handle for reading CPU time of a specific thread. -/// -/// Each platform works in its own native tick unit — the monitor loop is -/// 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 { - clock_id: libc::clockid_t, -} - -// SAFETY: ThreadCpuHandle contains a clock_id obtained from pthread_getcpuclockid. -// The clock_id is process-scoped and remains valid for the lifetime of the thread. -// clock_gettime() with a thread CPU clock is explicitly safe to call from any thread -// per POSIX specification - it reads the CPU time counter for the specified thread. -#[cfg(target_os = "linux")] -unsafe impl Send for ThreadCpuHandle {} -#[cfg(target_os = "linux")] -unsafe impl Sync for ThreadCpuHandle {} - -#[cfg(target_os = "linux")] -impl ThreadCpuHandle { - /// Create a handle for the current thread's CPU time. - pub fn for_current_thread() -> Option { - use libc::{pthread_getcpuclockid, pthread_self}; - - let thread_id = unsafe { pthread_self() }; - let mut clock_id: libc::clockid_t = 0; - - let result = unsafe { pthread_getcpuclockid(thread_id, &mut clock_id) }; - if result != 0 { - return None; - } - - Some(Self { clock_id }) - } - - /// Get the elapsed CPU ticks for this thread. - /// - /// On Linux, ticks are nanoseconds (the native unit of `clock_gettime`). - pub fn elapsed(&self) -> Option { - use libc::{clock_gettime, timespec}; - - if self.clock_id == 0 { - return None; - } - - let mut ts = timespec { - tv_sec: 0, - tv_nsec: 0, - }; - - let result = unsafe { clock_gettime(self.clock_id, &mut ts) }; - if result != 0 { - return None; - } - - Some((ts.tv_sec as u64) * 1_000_000_000 + (ts.tv_nsec as u64)) - } - - /// Convert a `Duration` timeout into a tick budget in the platform's native unit. - /// - /// On Linux, 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 Linux, ticks are nanoseconds so this is an identity conversion. - pub fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { - ticks - } -} - -// --------------------------------------------------------------------------- -// macOS -// --------------------------------------------------------------------------- - -// 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 -/// 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 { 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(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, - /// Cached start cycles for relative measurement - start_cycles: u64, -} - -/// Cached CPU frequency in MHz, read from the Windows registry once per process. -#[cfg(target_os = "windows")] -static CPU_FREQUENCY_MHZ: std::sync::OnceLock> = std::sync::OnceLock::new(); - -/// Read the CPU's nominal frequency in MHz from the Windows registry. -/// -/// Reads `HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0\~MHz`. -/// This is the processor's base/rated frequency and matches the tick rate -/// of `QueryThreadCycleTime` (which uses the invariant TSC on modern CPUs). -/// -#[cfg(target_os = "windows")] -fn read_cpu_frequency_mhz() -> Option { - use windows_sys::Win32::System::Registry::{ - RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_LOCAL_MACHINE, KEY_READ, REG_DWORD, - }; - - // Null-terminated UTF-16 strings for registry path and value name. - // Path: HARDWARE\DESCRIPTION\System\CentralProcessor\0 (the trailing \0 is - // the subkey named "0", i.e. the first logical processor; the final \0 is - // the null terminator required by the Win32 API). - let subkey: Vec = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0\0" - .encode_utf16() - .collect(); - let value_name: Vec = "~MHz\0".encode_utf16().collect(); - - let mut hkey: windows_sys::Win32::System::Registry::HKEY = std::ptr::null_mut(); - let result = - unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, subkey.as_ptr(), 0, KEY_READ, &mut hkey) }; - if result != 0 { - tracing::warn!("[CPU_TIME] Failed to open registry key for CPU frequency"); - return None; - } - - let mut mhz: u32 = 0; - let mut data_size: u32 = std::mem::size_of::() as u32; - let mut data_type: u32 = 0; - - let result = unsafe { - RegQueryValueExW( - hkey, - value_name.as_ptr(), - std::ptr::null(), - &mut data_type, - &mut mhz as *mut u32 as *mut u8, - &mut data_size, - ) - }; - - unsafe { RegCloseKey(hkey) }; - - if result != 0 || data_type != REG_DWORD || mhz == 0 { - tracing::warn!( - result = result, - data_type = data_type, - "[CPU_TIME] Failed to read CPU frequency from registry" - ); - return None; - } - - tracing::debug!( - cpu_frequency_mhz = mhz, - "[CPU_TIME] Read CPU base frequency from registry" - ); - - Some(mhz) -} - -/// Get the cached CPU frequency in MHz, reading from registry on first call. -#[cfg(target_os = "windows")] -fn get_cpu_frequency_mhz() -> Option { - *CPU_FREQUENCY_MHZ.get_or_init(read_cpu_frequency_mhz) -} - -// SAFETY: ThreadCpuHandle contains a real thread handle obtained via DuplicateHandle. -// Unlike the pseudo-handle from GetCurrentThread(), a duplicated handle is valid -// for use from any thread. QueryThreadCycleTime() is explicitly thread-safe when -// called with a valid thread handle. The handle is properly closed in Drop. -#[cfg(target_os = "windows")] -unsafe impl Send for ThreadCpuHandle {} -#[cfg(target_os = "windows")] -unsafe impl Sync for ThreadCpuHandle {} - -#[cfg(target_os = "windows")] -impl ThreadCpuHandle { - /// Create a handle for the current thread's CPU time. - /// - /// Uses `QueryThreadCycleTime` with the CPU's nominal frequency from the - /// Windows registry for cycle-based measurement. - pub fn for_current_thread() -> Option { - use windows_sys::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS}; - use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThread}; - - // Ensure CPU frequency is available (read from registry, once per process) - if get_cpu_frequency_mhz().is_none() { - tracing::warn!( - "[CPU_TIME] Could not read CPU frequency from registry, \ - CPU time monitoring unavailable" - ); - return None; - } - - // GetCurrentThread returns a pseudo-handle that can't be used from other threads. - // We need to duplicate it to get a real handle. - let pseudo_handle = unsafe { GetCurrentThread() }; - let process = unsafe { GetCurrentProcess() }; - let mut real_handle: windows_sys::Win32::Foundation::HANDLE = std::ptr::null_mut(); - - let result = unsafe { - DuplicateHandle( - process, - pseudo_handle, - process, - &mut real_handle, - 0, - 0, // FALSE - DUPLICATE_SAME_ACCESS, - ) - }; - - if result == 0 { - return None; - } - - // Capture starting cycle count - let mut start_cycles: u64 = 0; - if unsafe { QueryThreadCycleTime(real_handle, &mut start_cycles) } == 0 { - // Clean up handle if we can't get cycles - unsafe { windows_sys::Win32::Foundation::CloseHandle(real_handle) }; - return None; - } - - Some(Self { - thread_handle: real_handle, - start_cycles, - }) - } - - /// Get the elapsed CPU ticks for this thread. - /// - /// On Windows, ticks are raw TSC reference cycles from `QueryThreadCycleTime`. - /// No conversion is performed — the monitor works directly in the platform's - /// native unit, converting only for logging via `ticks_to_approx_nanos`. - pub fn elapsed(&self) -> Option { - if self.thread_handle.is_null() { - return None; - } - - let mut current_cycles: u64 = 0; - let result = unsafe { QueryThreadCycleTime(self.thread_handle, &mut current_cycles) }; - - if result == 0 { - return None; - } - - Some(current_cycles.saturating_sub(self.start_cycles)) - } - - /// Convert a `Duration` timeout into a tick budget in the platform's native unit. - /// - /// On Windows, converts nanoseconds to TSC reference cycles using the CPU's - /// nominal frequency from the registry. This is done once at monitor setup, - /// not on every poll. - pub fn deadline_for(&self, timeout: Duration) -> Option { - let freq_mhz = get_cpu_frequency_mhz()? as u64; - let nanos = timeout.as_nanos() as u64; - // cycles = nanos * freq_mhz / 1000 - // (inverse of: nanos = cycles * 1000 / freq_mhz) - Some(nanos.saturating_mul(freq_mhz) / 1_000) - } - - /// Convert ticks to approximate nanoseconds (for logging and sleep calculations). - /// - /// On Windows, converts TSC reference cycles to nanoseconds using the CPU's - /// nominal frequency. Precision is not critical — this is used for - /// human-readable log output and adaptive sleep duration clamping. - pub fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { - match get_cpu_frequency_mhz() { - Some(freq_mhz) => ticks.saturating_mul(1_000) / freq_mhz as u64, - None => 0, - } - } -} - -#[cfg(target_os = "windows")] -impl Drop for ThreadCpuHandle { - fn drop(&mut self) { - if !self.thread_handle.is_null() { - unsafe { - windows_sys::Win32::Foundation::CloseHandle(self.thread_handle); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_thread_cpu_handle_for_current_thread() { - let handle = ThreadCpuHandle::for_current_thread(); - assert!(handle.is_some(), "CPU time handle should be available"); - } - - #[test] - fn test_thread_cpu_handle_elapsed() { - let handle = ThreadCpuHandle::for_current_thread().unwrap(); - - // Do a small amount of CPU work - let mut sum: u64 = 0; - for i in 0..1_000_000u64 { - sum = sum.wrapping_add(i); - } - std::hint::black_box(sum); - - let ticks = handle.elapsed(); - assert!(ticks.is_some(), "Should be able to read CPU time"); - // Even small amounts of work should register measurable ticks - assert!( - ticks.unwrap() > 0, - "Elapsed ticks should be non-zero after doing work" - ); - } - - #[test] - fn test_zero_duration_rejected() { - let result = CpuTimeMonitor::new(Duration::ZERO); - assert!(result.is_err(), "Zero duration should be rejected"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("non-zero"), - "Error should mention non-zero: {err}" - ); - } - - #[test] - #[cfg(target_os = "windows")] - fn test_cpu_time_precision() { - // Test that we can measure sub-millisecond CPU time on Windows. - // elapsed() returns raw TSC cycles; convert via ticks_to_approx_nanos for assertions. - let handle = ThreadCpuHandle::for_current_thread().unwrap(); - - // Do ~1ms of CPU work (rough estimate) - let mut sum: u64 = 0; - for i in 0..500_000u64 { - sum = sum.wrapping_add(i); - } - std::hint::black_box(sum); - - let ticks = handle.elapsed().unwrap(); - let time_ns = handle.ticks_to_approx_nanos(ticks); - let time_ms = time_ns as f64 / 1_000_000.0; - - // Should register something measurable (even if not exactly 1ms) - println!( - "Measured CPU time: {:.3}ms ({} ns, {} ticks)", - time_ms, time_ns, ticks - ); - - // Should be non-zero and less than 100ms (sanity check) - assert!(time_ns > 0, "CPU time should be non-zero"); - assert!(time_ns < 100_000_000, "CPU time should be less than 100ms"); - } -} diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time/linux.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time/linux.rs new file mode 100644 index 00000000..af2ac44d --- /dev/null +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time/linux.rs @@ -0,0 +1,71 @@ +/* +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. +*/ + +use std::time::Duration; + +/// Handle for reading CPU time of a specific thread. +pub(crate) struct ThreadCpuHandle { + clock_id: libc::clockid_t, +} + +// SAFETY: The process-scoped clock ID remains valid for the thread's lifetime, +// and POSIX permits reading a thread CPU clock from another thread. +unsafe impl Send for ThreadCpuHandle {} +unsafe impl Sync for ThreadCpuHandle {} + +impl ThreadCpuHandle { + pub(crate) fn for_current_thread() -> Option { + use libc::{pthread_getcpuclockid, pthread_self}; + + let thread_id = unsafe { pthread_self() }; + let mut clock_id: libc::clockid_t = 0; + + let result = unsafe { pthread_getcpuclockid(thread_id, &mut clock_id) }; + if result != 0 { + return None; + } + + Some(Self { clock_id }) + } + + pub(crate) fn elapsed(&self) -> Option { + use libc::{clock_gettime, timespec}; + + if self.clock_id == 0 { + return None; + } + + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + + let result = unsafe { clock_gettime(self.clock_id, &mut ts) }; + if result != 0 { + return None; + } + + Some((ts.tv_sec as u64) * 1_000_000_000 + (ts.tv_nsec as u64)) + } + + pub(crate) fn deadline_for(&self, timeout: Duration) -> Option { + Some(timeout.as_nanos() as u64) + } + + pub(crate) fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { + ticks + } +} diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time/macos.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time/macos.rs new file mode 100644 index 00000000..872aa5e0 --- /dev/null +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time/macos.rs @@ -0,0 +1,96 @@ +/* +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. +*/ + +use std::time::Duration; + +// These symbols live in libSystem but are not all re-exported by libc. +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; +} + +const MACH_PORT_NULL: libc::mach_port_t = 0; + +/// Handle for reading a specific thread's CPU time using a Mach send right. +pub(crate) struct ThreadCpuHandle { + thread_port: libc::mach_port_t, +} + +impl ThreadCpuHandle { + pub(crate) fn for_current_thread() -> Option { + 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; + } + + let handle = Self { thread_port }; + handle.elapsed()?; + + Some(handle) + } + + pub(crate) fn elapsed(&self) -> Option { + 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, + ) + }; + + 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))) + } + + pub(crate) fn deadline_for(&self, timeout: Duration) -> Option { + Some(timeout.as_nanos() as u64) + } + + pub(crate) fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { + ticks + } +} + +impl Drop for ThreadCpuHandle { + fn drop(&mut self) { + if self.thread_port != MACH_PORT_NULL { + unsafe { mach_port_deallocate(mach_task_self_, self.thread_port) }; + } + } +} diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time/mod.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time/mod.rs new file mode 100644 index 00000000..e37503ea --- /dev/null +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time/mod.rs @@ -0,0 +1,256 @@ +/* +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. +*/ +//! CPU time based execution monitor. +//! +//! This module provides monitoring based on actual CPU execution time rather than +//! wall-clock time, making it more accurate for billing and resistant to time-wasting +//! attacks that use sleep/blocking calls. +//! +//! For comprehensive protection, combine with [`WallClockMonitor`] via a tuple to +//! catch both compute-bound abuse and resource exhaustion: +//! +//! ```text +//! let monitor = ( +//! WallClockMonitor::new(Duration::from_secs(5))?, +//! CpuTimeMonitor::new(Duration::from_millis(500))?, +//! ); +//! ``` + +use std::future::Future; +use std::time::Duration; + +use hyperlight_host::{HyperlightError, Result}; + +use super::ExecutionMonitor; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +pub(crate) use linux::ThreadCpuHandle; +#[cfg(target_os = "macos")] +pub(crate) use macos::ThreadCpuHandle; +#[cfg(target_os = "windows")] +pub(crate) use windows::ThreadCpuHandle; + +/// Monitors handler execution using CPU time. +/// +/// Terminates execution if the handler consumes more CPU time than the configured limit. +/// This measures actual computation time, not time spent blocked or waiting. +/// +/// # Combining with Wall-Clock Monitoring +/// +/// `CpuTimeMonitor` only catches compute-bound abuse. To also catch resource exhaustion +/// (where a guest holds host resources without burning CPU), combine with +/// [`WallClockMonitor`] as a tuple: +/// +/// ```text +/// let monitor = ( +/// WallClockMonitor::new(Duration::from_secs(5))?, +/// CpuTimeMonitor::new(Duration::from_millis(500))?, +/// ); +/// ``` +/// +/// The tuple races both monitors — whichever fires first terminates execution, +/// and the winning monitor's name is logged. +/// +/// # 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`). +/// Monitoring compares raw cycle counts directly. +/// Accuracy depends on invariant TSC support but should be good on modern CPUs. +/// +/// # Example +/// +/// ```text +/// use hyperlight_js::CpuTimeMonitor; +/// use std::time::Duration; +/// +/// let monitor = CpuTimeMonitor::new(Duration::from_millis(100))?; +/// let result = sandbox.handle_event_with_monitor("handler", "{}".to_string(), &monitor, None)?; +/// ``` +#[derive(Debug, Clone)] +pub struct CpuTimeMonitor { + cpu_timeout: Duration, +} + +impl CpuTimeMonitor { + /// Create a new CPU time monitor. + /// + /// # Arguments + /// + /// * `cpu_timeout` - Maximum CPU time allowed for execution. + /// + /// # Errors + /// + /// Returns an error if `cpu_timeout` is zero. + pub fn new(cpu_timeout: Duration) -> Result { + if cpu_timeout.is_zero() { + return Err(HyperlightError::Error( + "cpu_timeout must be non-zero".to_string(), + )); + } + Ok(Self { cpu_timeout }) + } +} + +impl ExecutionMonitor for CpuTimeMonitor { + fn get_monitor(&self) -> Result + Send + 'static> { + // Capture CPU time handle on the calling thread + let cpu_handle = ThreadCpuHandle::for_current_thread().ok_or_else(|| { + HyperlightError::Error("Failed to get CPU time handle for current thread".to_string()) + })?; + + let cpu_timeout = self.cpu_timeout; + + // Compute deadline in platform-native ticks (nanos on Linux, TSC cycles on Windows). + // This conversion is done once here, not on every poll iteration. + let start_ticks = cpu_handle + .elapsed() + .ok_or_else(|| HyperlightError::Error("Failed to read initial CPU time".to_string()))?; + let tick_budget = cpu_handle.deadline_for(cpu_timeout).ok_or_else(|| { + HyperlightError::Error("Failed to compute CPU tick deadline".to_string()) + })?; + let deadline = start_ticks.saturating_add(tick_budget); + + Ok(async move { + loop { + // Read current ticks in the platform's native unit + let current = match cpu_handle.elapsed() { + Some(t) => t, + None => { + // CPU time reading failed mid-execution. Log the error + // and return immediately to trigger termination (fail-closed). + tracing::error!( + "Failed to read CPU time — terminating execution (fail-closed)" + ); + return; + } + }; + + if current >= deadline { + let elapsed_ticks = current.saturating_sub(start_ticks); + let elapsed_ms = cpu_handle.ticks_to_approx_nanos(elapsed_ticks) / 1_000_000; + tracing::warn!( + cpu_elapsed_ms = elapsed_ms, + cpu_timeout_ms = cpu_timeout.as_millis() as u64, + "CPU time limit exceeded, terminating execution" + ); + return; + } + + // Adaptive sleep: half of remaining time, clamped to reasonable bounds. + // Tokio's timer wheel resolution is ~1ms, so that's our effective floor. + // The maximum keeps polls frequent enough for reasonable deadline accuracy. + // Convert remaining ticks to approximate nanos for the sleep Duration. + const MIN_POLL_INTERVAL: Duration = Duration::from_millis(1); + const MAX_POLL_INTERVAL: Duration = Duration::from_millis(10); + const ADAPTIVE_DIVISOR: u64 = 2; + + let remaining = deadline.saturating_sub(current); + let remaining_nanos = cpu_handle.ticks_to_approx_nanos(remaining); + let sleep_duration = Duration::from_nanos(remaining_nanos / ADAPTIVE_DIVISOR) + .clamp(MIN_POLL_INTERVAL, MAX_POLL_INTERVAL); + + super::sleep(sleep_duration).await; + } + }) + } + + fn name(&self) -> &'static str { + "cpu-time" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_thread_cpu_handle_for_current_thread() { + let handle = ThreadCpuHandle::for_current_thread(); + assert!(handle.is_some(), "CPU time handle should be available"); + } + + #[test] + fn test_thread_cpu_handle_elapsed() { + let handle = ThreadCpuHandle::for_current_thread().unwrap(); + + // Do a small amount of CPU work + let mut sum: u64 = 0; + for i in 0..1_000_000u64 { + sum = sum.wrapping_add(i); + } + std::hint::black_box(sum); + + let ticks = handle.elapsed(); + assert!(ticks.is_some(), "Should be able to read CPU time"); + // Even small amounts of work should register measurable ticks + assert!( + ticks.unwrap() > 0, + "Elapsed ticks should be non-zero after doing work" + ); + } + + #[test] + fn test_zero_duration_rejected() { + let result = CpuTimeMonitor::new(Duration::ZERO); + assert!(result.is_err(), "Zero duration should be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("non-zero"), + "Error should mention non-zero: {err}" + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn test_cpu_time_precision() { + // Test that we can measure sub-millisecond CPU time on Windows. + // elapsed() returns raw TSC cycles; convert via ticks_to_approx_nanos for assertions. + let handle = ThreadCpuHandle::for_current_thread().unwrap(); + + // Do ~1ms of CPU work (rough estimate) + let mut sum: u64 = 0; + for i in 0..500_000u64 { + sum = sum.wrapping_add(i); + } + std::hint::black_box(sum); + + let ticks = handle.elapsed().unwrap(); + let time_ns = handle.ticks_to_approx_nanos(ticks); + let time_ms = time_ns as f64 / 1_000_000.0; + + // Should register something measurable (even if not exactly 1ms) + println!( + "Measured CPU time: {:.3}ms ({} ns, {} ticks)", + time_ms, time_ns, ticks + ); + + // Should be non-zero and less than 100ms (sanity check) + assert!(time_ns > 0, "CPU time should be non-zero"); + assert!(time_ns < 100_000_000, "CPU time should be less than 100ms"); + } +} diff --git a/src/hyperlight-js/src/sandbox/monitor/cpu_time/windows.rs b/src/hyperlight-js/src/sandbox/monitor/cpu_time/windows.rs new file mode 100644 index 00000000..5cf9bd5f --- /dev/null +++ b/src/hyperlight-js/src/sandbox/monitor/cpu_time/windows.rs @@ -0,0 +1,169 @@ +/* +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. +*/ + +use std::sync::OnceLock; +use std::time::Duration; + +use windows_sys::Win32::System::WindowsProgramming::QueryThreadCycleTime; + +pub(crate) struct ThreadCpuHandle { + thread_handle: windows_sys::Win32::Foundation::HANDLE, + start_cycles: u64, +} + +static CPU_FREQUENCY_MHZ: OnceLock> = OnceLock::new(); + +fn read_cpu_frequency_mhz() -> Option { + use windows_sys::Win32::System::Registry::{ + RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY_LOCAL_MACHINE, KEY_READ, REG_DWORD, + }; + + let subkey: Vec = "HARDWARE\\DESCRIPTION\\System\\CentralProcessor\\0\0" + .encode_utf16() + .collect(); + let value_name: Vec = "~MHz\0".encode_utf16().collect(); + + let mut hkey: windows_sys::Win32::System::Registry::HKEY = std::ptr::null_mut(); + let result = + unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, subkey.as_ptr(), 0, KEY_READ, &mut hkey) }; + if result != 0 { + tracing::warn!("[CPU_TIME] Failed to open registry key for CPU frequency"); + return None; + } + + let mut mhz: u32 = 0; + let mut data_size: u32 = std::mem::size_of::() as u32; + let mut data_type: u32 = 0; + + let result = unsafe { + RegQueryValueExW( + hkey, + value_name.as_ptr(), + std::ptr::null(), + &mut data_type, + &mut mhz as *mut u32 as *mut u8, + &mut data_size, + ) + }; + + unsafe { RegCloseKey(hkey) }; + + if result != 0 || data_type != REG_DWORD || mhz == 0 { + tracing::warn!( + result = result, + data_type = data_type, + "[CPU_TIME] Failed to read CPU frequency from registry" + ); + return None; + } + + tracing::debug!( + cpu_frequency_mhz = mhz, + "[CPU_TIME] Read CPU base frequency from registry" + ); + + Some(mhz) +} + +fn get_cpu_frequency_mhz() -> Option { + *CPU_FREQUENCY_MHZ.get_or_init(read_cpu_frequency_mhz) +} + +// SAFETY: The duplicated thread handle is valid across threads and is closed in Drop. +unsafe impl Send for ThreadCpuHandle {} +unsafe impl Sync for ThreadCpuHandle {} + +impl ThreadCpuHandle { + pub(crate) fn for_current_thread() -> Option { + use windows_sys::Win32::Foundation::{DuplicateHandle, DUPLICATE_SAME_ACCESS}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThread}; + + if get_cpu_frequency_mhz().is_none() { + tracing::warn!( + "[CPU_TIME] Could not read CPU frequency from registry, \ + CPU time monitoring unavailable" + ); + return None; + } + + let pseudo_handle = unsafe { GetCurrentThread() }; + let process = unsafe { GetCurrentProcess() }; + let mut real_handle = std::ptr::null_mut(); + + let result = unsafe { + DuplicateHandle( + process, + pseudo_handle, + process, + &mut real_handle, + 0, + 0, + DUPLICATE_SAME_ACCESS, + ) + }; + + if result == 0 { + return None; + } + + let mut start_cycles = 0; + if unsafe { QueryThreadCycleTime(real_handle, &mut start_cycles) } == 0 { + unsafe { windows_sys::Win32::Foundation::CloseHandle(real_handle) }; + return None; + } + + Some(Self { + thread_handle: real_handle, + start_cycles, + }) + } + + pub(crate) fn elapsed(&self) -> Option { + if self.thread_handle.is_null() { + return None; + } + + let mut current_cycles = 0; + if unsafe { QueryThreadCycleTime(self.thread_handle, &mut current_cycles) } == 0 { + return None; + } + + Some(current_cycles.saturating_sub(self.start_cycles)) + } + + pub(crate) fn deadline_for(&self, timeout: Duration) -> Option { + let freq_mhz = get_cpu_frequency_mhz()? as u64; + let nanos = timeout.as_nanos() as u64; + Some(nanos.saturating_mul(freq_mhz) / 1_000) + } + + pub(crate) fn ticks_to_approx_nanos(&self, ticks: u64) -> u64 { + match get_cpu_frequency_mhz() { + Some(freq_mhz) => ticks.saturating_mul(1_000) / freq_mhz as u64, + None => 0, + } + } +} + +impl Drop for ThreadCpuHandle { + fn drop(&mut self) { + if !self.thread_handle.is_null() { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.thread_handle); + } + } + } +} diff --git a/src/js-host-api/test-pack.sh b/src/js-host-api/test-pack.sh index 9bb6f739..6e1a77cc 100755 --- a/src/js-host-api/test-pack.sh +++ b/src/js-host-api/test-pack.sh @@ -12,10 +12,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PACK_DIR="/tmp/hyperlight-npm-test-pack" INSTALL_DIR="/tmp/hyperlight-npm-test-install" +MACOS_INSTALL_DIR="/tmp/hyperlight-npm-test-install-macos" +REQUIRE_MACOS_PACKAGE="${REQUIRE_MACOS_PACKAGE:-0}" # ── Cleanup ────────────────────────────────────────────────────────── -rm -rf "${PACK_DIR}" "${INSTALL_DIR}" -mkdir -p "${PACK_DIR}" "${INSTALL_DIR}" +rm -rf "${PACK_DIR}" "${INSTALL_DIR}" "${MACOS_INSTALL_DIR}" +mkdir -p "${PACK_DIR}" "${INSTALL_DIR}" "${MACOS_INSTALL_DIR}" cd "${SCRIPT_DIR}" @@ -38,12 +40,28 @@ else exit 1 fi +if ls npm/darwin-arm64/*.node 1>/dev/null 2>&1; then + HAS_MACOS_PACKAGE=1 +elif [ "${REQUIRE_MACOS_PACKAGE}" = "1" ]; then + echo "❌ Error: No macOS arm64 .node binary found in npm/darwin-arm64/." >&2 + exit 1 +else + HAS_MACOS_PACKAGE=0 +fi + # ── Step 1: Pack platform package ─────────────────────────────────── echo "📦 Packing platform package (linux-x64-gnu)..." PLATFORM_TGZ=$(npm pack ./npm/linux-x64-gnu --pack-destination "${PACK_DIR}" 2>/dev/null) PLATFORM_TGZ_PATH="${PACK_DIR}/${PLATFORM_TGZ}" echo " → ${PLATFORM_TGZ_PATH}" +if [ "${HAS_MACOS_PACKAGE}" = "1" ]; then + echo "📦 Packing platform package (darwin-arm64)..." + MACOS_TGZ=$(npm pack ./npm/darwin-arm64 --pack-destination "${PACK_DIR}" 2>/dev/null) + MACOS_TGZ_PATH="${PACK_DIR}/${MACOS_TGZ}" + echo " → ${MACOS_TGZ_PATH}" +fi + # ── Step 2: Pack main package ─────────────────────────────────────── echo "📦 Packing main package..." MAIN_TGZ=$(npm pack --pack-destination "${PACK_DIR}" 2>/dev/null) @@ -103,6 +121,25 @@ else exit 1 fi +if [ "${HAS_MACOS_PACKAGE}" = "1" ]; then + echo "" + echo "✅ Validating macOS arm64 platform package contents..." + MACOS_FILES=$(tar tzf "${MACOS_TGZ_PATH}") + if echo "${MACOS_FILES}" | grep -q '^package/js-host-api\.darwin-arm64\.node$'; then + echo " ✅ macOS arm64 .node binary present" + else + echo " ❌ MISSING: macOS arm64 .node binary" >&2 + exit 1 + fi + + echo "" + echo "📥 Installing macOS arm64 tarball into ${MACOS_INSTALL_DIR}..." + cd "${MACOS_INSTALL_DIR}" + npm init -y --silent >/dev/null 2>&1 + npm install "${MACOS_TGZ_PATH}" --no-save --force --ignore-scripts 2>&1 | sed 's/^/ /' + test -f "node_modules/@hyperlight-dev/js-host-api-darwin-arm64/js-host-api.darwin-arm64.node" +fi + # ── Step 6: Install from tarballs into a clean directory ──────────── echo "" echo "📥 Installing from tarballs into ${INSTALL_DIR}..."