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/.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 cc795cd1..d55b26d6 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -19,35 +19,65 @@ 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"] hypervisor: whp + arch: X64 config: debug - 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 + 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"] 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 + arch: X64 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 + 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}", "{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, @@ -64,6 +94,45 @@ 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. 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 + 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 "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version + fi + - name: install nodejs uses: actions/setup-node@v7 with: @@ -71,6 +140,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 diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a2328eb8..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 @@ -41,6 +44,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 +64,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 @@ -76,6 +86,40 @@ 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. 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 + 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 "$llvm_bin" >> "$GITHUB_PATH" + "$llvm_bin/llvm-ar" --version + fi + - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} run: npm ci --ignore-scripts --omit=optional @@ -173,6 +217,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 +239,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 +255,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 }} @@ -237,68 +290,58 @@ jobs: - name: Validate packages working-directory: ${{ env.WORKING_DIR }} run: ./test-pack.sh + env: + REQUIRE_MACOS_PACKAGE: "1" # ── 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. + - 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 --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 --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 --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 --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 --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 +352,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 +383,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/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 "$@" 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..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. @@ -68,11 +68,34 @@ 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). 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, +> 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 +> 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. @@ -84,18 +107,19 @@ 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: 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 +141,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/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-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 deleted file mode 100644 index d1a133f2..00000000 --- a/src/hyperlight-js/src/sandbox/monitor/cpu_time.rs +++ /dev/null @@ -1,524 +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) -/// - **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`) -/// - **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 - } -} - -#[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/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" 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}..."