diff --git a/.github/workflows/dep_build.yml b/.github/workflows/dep_build.yml index 056dd293..b79155c0 100644 --- a/.github/workflows/dep_build.yml +++ b/.github/workflows/dep_build.yml @@ -19,13 +19,18 @@ jobs: strategy: fail-fast: true matrix: - 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] + build: [windows-2025-debug, windows-arm64-debug, linux-kvm-debug, linux-kvm-arm64-debug, linux-hyperv3-debug, macos-hvf-debug, windows-2025-release, windows-arm64-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: windows-arm64-debug + os: [self-hosted, Windows, ARM64, "1ES.Pool=hl-windows-arm"] + hypervisor: whp + arch: arm64 + config: debug - build: linux-kvm-debug os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] hypervisor: kvm @@ -51,6 +56,11 @@ jobs: hypervisor: whp arch: X64 config: release + - build: windows-arm64-release + os: [self-hosted, Windows, ARM64, "1ES.Pool=hl-windows-arm"] + hypervisor: whp + arch: arm64 + config: release - build: linux-kvm-release os: [self-hosted, Linux, X64, "1ES.Pool=hld-kvm-amd"] hypervisor: kvm @@ -71,7 +81,10 @@ jobs: hypervisor: hvf arch: arm64 config: release - runs-on: ${{ fromJson(matrix.hypervisor == 'hvf' + runs-on: ${{ fromJson(matrix.hypervisor == 'whp' && matrix.arch == 'arm64' + && format('["self-hosted", "Windows", "ARM64", "1ES.Pool=hl-windows-arm", "JobId=build-{0}-{1}-{2}-{3}"]', + matrix.build, github.run_id, github.run_number, github.run_attempt) + || matrix.hypervisor == 'hvf' && '["self-hosted", "macos", "arm64", "hvf"]' || matrix.arch == 'arm64' && '["self-hosted", "Linux", "arm64", "kvm"]' @@ -137,6 +150,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: '22' + architecture: ${{ matrix.arch == 'arm64' && 'arm64' || 'x64' }} cache: 'npm' cache-dependency-path: 'src/js-host-api/package-lock.json' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index afe490f8..e617557d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -47,7 +47,7 @@ env: # 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,@hyperlight-dev/js-host-api-linux-arm64-gnu," + FIRST_TIME_PACKAGES: ",@hyperlight-dev/js-host-api-darwin-arm64,@hyperlight-dev/js-host-api-linux-arm64-gnu,@hyperlight-dev/js-host-api-win32-arm64-msvc," jobs: build: @@ -64,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-pc-windows-msvc + os: [self-hosted, Windows, ARM64, "1ES.Pool=hl-windows-arm"] + build_name: win32-arm64-msvc - target: aarch64-apple-darwin os: macos-15 build_name: darwin-arm64 @@ -86,6 +89,7 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: '22' + architecture: ${{ startsWith(matrix.target, 'aarch64-') && 'arm64' || 'x64' }} cache: 'npm' cache-dependency-path: 'src/js-host-api/package-lock.json' @@ -162,9 +166,124 @@ jobs: ${{ env.WORKING_DIR }}/index.d.ts if-no-files-found: error - validate-linux-arm64: - needs: build - runs-on: [self-hosted, Linux, arm64, kvm] + package-targets: + runs-on: ubuntu-latest + outputs: + all: ${{ steps.packages.outputs.all }} + first_time: ${{ steps.packages.outputs.first_time }} + steps: + - id: packages + shell: bash + env: + FIRST_TIME_PACKAGES: ${{ env.FIRST_TIME_PACKAGES }} + run: | + python - "$GITHUB_OUTPUT" <<'PY' + import json + import os + targets = [ + { + "package_name": "@hyperlight-dev/js-host-api-linux-x64-gnu", + "artifact_name": "bindings-linux-x64-gnu", + "artifact_dir": "linux-x64-gnu", + "node_arch": "x64", + "runner": ["self-hosted", "Linux", "X64", "1ES.Pool=hld-kvm-amd"], + "shell": "bash", + "verify_command": "test \"$(uname -m)\" = \"x86_64\"\ntest \"$(node -p process.arch)\" = \"x64\"", + "prepare_command": "mv artifacts/linux-x64-gnu/*.node npm/linux-x64-gnu/js-host-api.linux-x64-gnu.node\nmv js-bindings/index.js index.js\nmv js-bindings/index.d.ts index.d.ts", + "version_command": "npm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\ncd npm/linux-x64-gnu\nnpm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts", + "update_dependency_command": "node -e \"const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.optionalDependencies['@hyperlight-dev/js-host-api-linux-x64-gnu'] = process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\\\n');\"", + "validate_command": "./test-pack.sh", + "validate_env": {"PLATFORM_PACKAGE": "linux-x64-gnu"}, + }, + { + "package_name": "@hyperlight-dev/js-host-api-linux-x64-musl", + "artifact_name": "bindings-linux-x64-musl", + "artifact_dir": "linux-x64-musl", + "node_arch": "x64", + "runner": ["self-hosted", "Linux", "X64", "1ES.Pool=hld-kvm-amd"], + "shell": "bash", + "verify_command": "test \"$(uname -m)\" = \"x86_64\"\ntest \"$(node -p process.arch)\" = \"x64\"", + "prepare_command": "mv artifacts/linux-x64-musl/*.node npm/linux-x64-musl/js-host-api.linux-x64-musl.node\nmv js-bindings/index.js index.js\nmv js-bindings/index.d.ts index.d.ts", + "version_command": "npm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\ncd npm/linux-x64-musl\nnpm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts", + "update_dependency_command": "node -e \"const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.optionalDependencies['@hyperlight-dev/js-host-api-linux-x64-musl'] = process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\\\n');\"", + "validate_command": "./test-pack.sh", + "validate_env": {"PLATFORM_PACKAGE": "linux-x64-musl"}, + }, + { + "package_name": "@hyperlight-dev/js-host-api-win32-x64-msvc", + "artifact_name": "bindings-win32-x64-msvc", + "artifact_dir": "win32-x64-msvc", + "node_arch": "x64", + "runner": ["self-hosted", "Windows", "X64", "1ES.Pool=hld-win2025-amd"], + "shell": "pwsh", + "verify_command": "$platform = node -p \"process.platform + '-' + process.arch\"\nif ($LASTEXITCODE -ne 0 -or $platform -ne 'win32-x64') { throw 'Windows x64 package validation requires native x64 Node.js on Windows' }", + "prepare_command": "Move-Item artifacts\\win32-x64-msvc\\*.node npm\\win32-x64-msvc\\js-host-api.win32-x64-msvc.node\nMove-Item js-bindings\\index.js index.js\nMove-Item js-bindings\\index.d.ts index.d.ts", + "version_command": "npm version \"$env:VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\nPush-Location npm\\win32-x64-msvc\ntry { npm version \"$env:VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts } finally { Pop-Location }", + "update_dependency_command": "$pkg = Get-Content package.json -Raw | ConvertFrom-Json; $pkg.optionalDependencies.'@hyperlight-dev/js-host-api-win32-x64-msvc' = $env:VERSION; $pkg | ConvertTo-Json -Depth 100 | Set-Content package.json", + "validate_command": ".\\test-pack.ps1", + "validate_env": {}, + }, + { + "package_name": "@hyperlight-dev/js-host-api-win32-arm64-msvc", + "artifact_name": "bindings-win32-arm64-msvc", + "artifact_dir": "win32-arm64-msvc", + "node_arch": "arm64", + "runner": ["self-hosted", "Windows", "ARM64", "1ES.Pool=hl-windows-arm"], + "shell": "pwsh", + "verify_command": "$platform = node -p \"process.platform + '-' + process.arch\"\nif ($LASTEXITCODE -ne 0 -or $platform -ne 'win32-arm64') { throw 'Windows ARM64 package validation requires native ARM64 Node.js on Windows' }", + "prepare_command": "Move-Item artifacts\\win32-arm64-msvc\\*.node npm\\win32-arm64-msvc\\js-host-api.win32-arm64-msvc.node\nMove-Item js-bindings\\index.js index.js\nMove-Item js-bindings\\index.d.ts index.d.ts", + "version_command": "npm version \"$env:VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\nPush-Location npm\\win32-arm64-msvc\ntry { npm version \"$env:VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts } finally { Pop-Location }", + "update_dependency_command": "$pkg = Get-Content package.json -Raw | ConvertFrom-Json; $pkg.optionalDependencies.'@hyperlight-dev/js-host-api-win32-arm64-msvc' = $env:VERSION; $pkg | ConvertTo-Json -Depth 100 | Set-Content package.json", + "validate_command": ".\\test-pack.ps1", + "validate_env": {}, + }, + { + "package_name": "@hyperlight-dev/js-host-api-darwin-arm64", + "artifact_name": "bindings-darwin-arm64", + "artifact_dir": "darwin-arm64", + "node_arch": "arm64", + "runner": "macos-15", + "shell": "bash", + "verify_command": "test \"$(uname -m)\" = \"arm64\"\ntest \"$(node -p process.arch)\" = \"arm64\"", + "prepare_command": "mv artifacts/darwin-arm64/*.node npm/darwin-arm64/js-host-api.darwin-arm64.node\nmv js-bindings/index.js index.js\nmv js-bindings/index.d.ts index.d.ts", + "version_command": "npm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\ncd npm/darwin-arm64\nnpm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts", + "update_dependency_command": "node -e \"const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.optionalDependencies['@hyperlight-dev/js-host-api-darwin-arm64'] = process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\\\n');\"", + "validate_command": "./test-pack.sh", + "validate_env": {"PLATFORM_PACKAGE": "darwin-arm64", "REQUIRE_MACOS_PACKAGE": "1"}, + }, + { + "package_name": "@hyperlight-dev/js-host-api-linux-arm64-gnu", + "artifact_name": "bindings-linux-arm64-gnu", + "artifact_dir": "linux-arm64-gnu", + "node_arch": "arm64", + "runner": ["self-hosted", "Linux", "arm64", "kvm"], + "shell": "bash", + "verify_command": "test \"$(uname -m)\" = \"aarch64\"\ntest \"$(node -p process.arch)\" = \"arm64\"", + "prepare_command": "mv artifacts/linux-arm64-gnu/*.node npm/linux-arm64-gnu/js-host-api.linux-arm64-gnu.node\nmv js-bindings/index.js index.js\nmv js-bindings/index.d.ts index.d.ts", + "version_command": "npm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts\ncd npm/linux-arm64-gnu\nnpm version \"$VERSION\" --no-git-tag-version --allow-same-version --ignore-scripts", + "update_dependency_command": "node -e \"const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); pkg.optionalDependencies['@hyperlight-dev/js-host-api-linux-arm64-gnu'] = process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\\\\n');\"", + "validate_command": "./test-pack.sh", + "validate_env": {"PLATFORM_PACKAGE": "linux-arm64-gnu"}, + }, + ] + first_time = {pkg.strip() for pkg in os.environ.get('FIRST_TIME_PACKAGES', '').split(',') if pkg.strip()} + with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as fh: + fh.write(f"all={json.dumps(targets)}\n") + fh.write(f"first_time={json.dumps([t for t in targets if t['package_name'] in first_time])}\n") + PY + + validate-native: + if: ${{ needs.build.result == 'success' && needs.package-targets.outputs.first_time != '[]' }} + needs: [build, package-targets] + strategy: + fail-fast: true + matrix: + include: ${{ fromJSON(needs.package-targets.outputs.first_time) }} + runs-on: ${{ matrix.runner }} + # Step-level shell does not support expressions; job defaults do. + defaults: + run: + shell: ${{ matrix.shell }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -172,19 +291,18 @@ jobs: uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: '22.22.3' + architecture: ${{ matrix.node_arch }} cache: 'npm' cache-dependency-path: 'src/js-host-api/package-lock.json' - - name: Verify Linux arm64 runner - run: | - test "$(uname -m)" = "aarch64" - test "$(node -p process.arch)" = "arm64" + - name: Verify native runner + run: ${{ matrix.verify_command }} - - name: Download Linux arm64 GNU artifact + - name: Download native artifact uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: - name: bindings-linux-arm64-gnu - path: ${{ env.WORKING_DIR }}/artifacts/linux-arm64-gnu + name: ${{ matrix.artifact_name }} + path: ${{ env.WORKING_DIR }}/artifacts/${{ matrix.artifact_dir }} - name: Download JS bindings uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 @@ -192,42 +310,32 @@ jobs: name: js-bindings path: ${{ env.WORKING_DIR }}/js-bindings - - name: Prepare Linux arm64 package + - name: Prepare native package working-directory: ${{ env.WORKING_DIR }} - run: | - mv artifacts/linux-arm64-gnu/*.node npm/linux-arm64-gnu/js-host-api.linux-arm64-gnu.node - mv js-bindings/index.js index.js - mv js-bindings/index.d.ts index.d.ts + run: ${{ matrix.prepare_command }} - name: Set package versions working-directory: ${{ env.WORKING_DIR }} - run: | - npm version "$VERSION" --no-git-tag-version --allow-same-version --ignore-scripts - cd npm/linux-arm64-gnu - npm version "$VERSION" --no-git-tag-version --allow-same-version --ignore-scripts env: VERSION: ${{ inputs.version }} + run: ${{ matrix.version_command }} - - name: Update Linux arm64 optional dependency version + - name: Update optional dependency version working-directory: ${{ env.WORKING_DIR }} - run: | - node -e " - const fs = require('fs'); - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.optionalDependencies['@hyperlight-dev/js-host-api-linux-arm64-gnu'] = process.env.VERSION; - fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " env: VERSION: ${{ inputs.version }} + run: ${{ matrix.update_dependency_command }} - - name: Validate Linux arm64 release tarballs + - name: Validate native release tarballs working-directory: ${{ env.WORKING_DIR }} - run: ./test-pack.sh env: - PLATFORM_PACKAGE: linux-arm64-gnu + PLATFORM_PACKAGE: ${{ matrix.artifact_dir }} + REQUIRE_MACOS_PACKAGE: ${{ contains(matrix.package_name, 'darwin') && '1' || '' }} + run: ${{ matrix.validate_command }} publish: - needs: [build, validate-linux-arm64] + if: ${{ always() && needs.build.result == 'success' && (needs.validate-native.result == 'success' || needs.validate-native.result == 'skipped') }} + needs: [build, validate-native] # Trusted publishing (OIDC) requires GitHub-hosted runners. # The publish job only downloads pre-built artifacts and runs npm publish, # so it does not need self-hosted runners with KVM/Hyperlight. @@ -290,6 +398,12 @@ jobs: name: bindings-darwin-arm64 path: ${{ env.WORKING_DIR }}/artifacts/darwin-arm64 + - name: Download Windows arm64 artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: bindings-win32-arm64-msvc + path: ${{ env.WORKING_DIR }}/artifacts/win32-arm64-msvc + - name: Download Linux arm64 GNU artifact uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: @@ -312,11 +426,13 @@ 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/win32-arm64-msvc/*.node npm/win32-arm64-msvc/js-host-api.win32-arm64-msvc.node mv artifacts/darwin-arm64/*.node npm/darwin-arm64/js-host-api.darwin-arm64.node mv artifacts/linux-arm64-gnu/*.node npm/linux-arm64-gnu/js-host-api.linux-arm64-gnu.node ls -la npm/linux-x64-gnu/ ls -la npm/linux-x64-musl/ ls -la npm/win32-x64-msvc/ + ls -la npm/win32-arm64-msvc/ ls -la npm/darwin-arm64/ ls -la npm/linux-arm64-gnu/ @@ -330,6 +446,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 ../win32-arm64-msvc && npm version "$VERSION" --no-git-tag-version --allow-same-version cd ../darwin-arm64 && npm version "$VERSION" --no-git-tag-version --allow-same-version cd ../linux-arm64-gnu && npm version "$VERSION" --no-git-tag-version --allow-same-version env: @@ -412,6 +529,13 @@ jobs: 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 Windows arm64 package + if: ${{ !inputs.dry_run }} + working-directory: ${{ env.WORKING_DIR }}/npm/win32-arm64-msvc + 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-arm64-msvc,')) && secrets.NPM_TOKEN || '' }} + - name: Publish Linux arm64 GNU package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }}/npm/linux-arm64-gnu @@ -436,6 +560,7 @@ jobs: "@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-win32-arm64-msvc" \ "@hyperlight-dev/js-host-api-darwin-arm64" \ "@hyperlight-dev/js-host-api-linux-arm64-gnu"; do if npm view "$pkg@$VERSION" version > /dev/null 2>&1; then @@ -467,6 +592,9 @@ 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-win32-arm64-msvc ---" + npm pack ./npm/win32-arm64-msvc --dry-run + echo "" echo "--- @hyperlight-dev/js-host-api-darwin-arm64 ---" npm pack ./npm/darwin-arm64 --dry-run echo "" diff --git a/Justfile b/Justfile index ab63346a..562a1140 100644 --- a/Justfile +++ b/Justfile @@ -210,11 +210,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 5 platform package.json versions (--ignore-scripts avoids needing node_modules) + # npm: main + the 6 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/win32-arm64-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 cd src/js-host-api/npm/linux-arm64-gnu && npm version {{ version }} --no-git-tag-version --allow-same-version --ignore-scripts # Verify the npm lockfile diff --git a/README.md b/README.md index 80d9ba67..c25b40e2 100644 --- a/README.md +++ b/README.md @@ -57,16 +57,6 @@ to `PATH`, or add the active Rust toolchain's LLVM directory: $env:Path = "$(rustc --print sysroot)\lib\rustlib\$hostTriple\bin;$env:Path" ``` -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 | @@ -74,6 +64,7 @@ your architecture, which `cargo-hyperlight` derives the guest target from: | Linux | x86_64 | KVM or MSHV | | Linux | aarch64 | KVM | | Windows | x86_64 | Windows Hypervisor Platform (WHP) | +| Windows | aarch64 | Windows Hypervisor Platform (WHP) | | macOS | aarch64 | Hypervisor.framework | The guest runs on the same architecture as the host, so an aarch64 host builds diff --git a/docs/release.md b/docs/release.md index 8285f81d..1b11a50c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -10,7 +10,7 @@ Do this with the `just set-version` recipe. **Always use this instead of bumping - every workspace crate's `version` and the root `Cargo.lock`, - the excluded `extended_runtime` fixture's own `Cargo.lock` (a bare `cargo set-version` can't reach it, and a stale one fails the `native_modules --locked` build), -- the npm main package and the five platform packages, +- the npm main package and the six platform packages, - the root package version in `src/js-host-api/package-lock.json`. Keep `optionalDependencies` on the latest published version so `npm ci` can use @@ -73,6 +73,7 @@ This must be done for all 6 packages: - `@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-win32-arm64-msvc` - `@hyperlight-dev/js-host-api-darwin-arm64` - `@hyperlight-dev/js-host-api-linux-arm64-gnu` @@ -120,6 +121,7 @@ If you need to publish npm packages manually via `workflow_dispatch`, you'll nee - `@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-win32-arm64-msvc` - `@hyperlight-dev/js-host-api-darwin-arm64` - `@hyperlight-dev/js-host-api-linux-arm64-gnu` diff --git a/src/hyperlight-js/build.rs b/src/hyperlight-js/build.rs index 81845ff2..da6713d8 100644 --- a/src/hyperlight-js/build.rs +++ b/src/hyperlight-js/build.rs @@ -31,11 +31,7 @@ use std::{env, fs}; use serde_json::Value; mod runtime_build; -use runtime_build::{runtime_source, select_binary, RuntimeSource}; - -// cargo-hyperlight supplies libc headers. QuickJS still needs threading disabled -// and the monotonic clock definitions enabled. -const QUICKJS_CFLAGS: &str = "-D__wasi__=1 -D_POSIX_MONOTONIC_CLOCK"; +use runtime_build::{guest_cflags, runtime_source, select_binary, RuntimeSource}; fn main() { // Mirror the hypervisor cfg aliases used by hyperlight-host so that @@ -230,7 +226,7 @@ fn build_js_runtime(custom: Option) -> PathBuf { .canonicalize() .expect("hyperlight-js-runtime include directory not found"); println!("cargo:rerun-if-changed={}", include_dir.display()); - let cflags = format!("-I{} {QUICKJS_CFLAGS}", include_dir.display()).replace("\\", "\\\\"); + let cflags = guest_cflags(&include_dir); let mut cargo_cmd = cargo_hyperlight::cargo().unwrap(); let cmd = cargo_cmd diff --git a/src/hyperlight-js/runtime_build.rs b/src/hyperlight-js/runtime_build.rs index 83a81e92..c0b71c23 100644 --- a/src/hyperlight-js/runtime_build.rs +++ b/src/hyperlight-js/runtime_build.rs @@ -15,10 +15,20 @@ limitations under the License. */ use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde_json::Value; +// cargo-hyperlight supplies libc headers. QuickJS still needs threading disabled +// and the monotonic clock definitions enabled. +const QUICKJS_CFLAGS: &str = "-D__wasi__=1 -D_POSIX_MONOTONIC_CLOCK"; + +pub(crate) fn guest_cflags(include_dir: &Path) -> String { + // Clang must see the runtime's math shim before the sysroot headers. + // Use forward slashes, as in the Justfile: escaped backslashes lose the include path. + format!("-I{} {QUICKJS_CFLAGS}", include_dir.display()).replace('\\', "/") +} + #[derive(Debug, PartialEq)] pub(crate) enum RuntimeSource { Default, @@ -56,7 +66,27 @@ pub(crate) fn select_binary(package: &Value) -> Result { mod tests { use serde_json::json; - use super::{runtime_source, select_binary, RuntimeSource}; + use super::{guest_cflags, runtime_source, select_binary, RuntimeSource}; + + #[test] + fn guest_cflags_normalize_canonical_windows_include_path() { + assert_eq!( + guest_cflags(std::path::Path::new( + r"\\?\C:\repo\src\hyperlight-js-runtime\include" + )), + "-I//?/C:/repo/src/hyperlight-js-runtime/include -D__wasi__=1 -D_POSIX_MONOTONIC_CLOCK" + ); + } + + #[test] + fn guest_cflags_preserve_unix_include_path() { + assert_eq!( + guest_cflags(std::path::Path::new( + "/repo/src/hyperlight-js-runtime/include" + )), + "-I/repo/src/hyperlight-js-runtime/include -D__wasi__=1 -D_POSIX_MONOTONIC_CLOCK" + ); + } #[test] fn absent_or_empty_manifest_selects_default_source() { diff --git a/src/js-host-api/.npmignore b/src/js-host-api/.npmignore index 5a232bbd..a5cdd2a3 100644 --- a/src/js-host-api/.npmignore +++ b/src/js-host-api/.npmignore @@ -30,6 +30,7 @@ src/ Cargo.toml test-examples.sh test-pack.sh +test-pack.ps1 # Exclude artifacts directory (only used during CI) artifacts/ diff --git a/src/js-host-api/DEVELOPMENT.md b/src/js-host-api/DEVELOPMENT.md index c7277cf6..6c0f4c45 100644 --- a/src/js-host-api/DEVELOPMENT.md +++ b/src/js-host-api/DEVELOPMENT.md @@ -48,6 +48,13 @@ just build-all just test-all release ``` +Tests register runtime and loaded sandboxes with `trackSandbox()` from +`tests/test-helpers.js` so they are disposed after each test, including wrappers +returned by `unload()`. Do not rely on JavaScript garbage collection to release +VMs. Windows ARM64 WHP currently supports only 64 concurrent partitions; Vitest +also runs test files serially on that host because Hyperlight's partition counter +is process-local. + ## Publishing to npm The package is published to npmjs.com as `@hyperlight-dev/js-host-api` with platform-specific binary packages using [npm trusted publishing (OIDC)](https://docs.npmjs.com/trusted-publishers). @@ -66,6 +73,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-win32-arm64-msvc` | Windows aarch64 native binary (WHP) | | `@hyperlight-dev/js-host-api-darwin-arm64` | macOS aarch64 (Apple Silicon) native binary | | `@hyperlight-dev/js-host-api-linux-arm64-gnu` | Linux aarch64 (glibc) native binary | diff --git a/src/js-host-api/README.md b/src/js-host-api/README.md index ddda059b..c8417e3e 100644 --- a/src/js-host-api/README.md +++ b/src/js-host-api/README.md @@ -833,7 +833,7 @@ dependencies, custom namespaces, multiple handlers sharing a module, and ## Requirements - **Node.js** >= 18 -- **Linux** (x86_64, glibc or musl), **Windows** (x86_64), or **macOS** (Apple Silicon / aarch64) +- **Linux** (x86_64, glibc or musl; aarch64, glibc), **Windows** (x86_64 or aarch64), or **macOS** (Apple Silicon / aarch64) ## License diff --git a/src/js-host-api/npm/win32-arm64-msvc/package.json b/src/js-host-api/npm/win32-arm64-msvc/package.json new file mode 100644 index 00000000..177a763d --- /dev/null +++ b/src/js-host-api/npm/win32-arm64-msvc/package.json @@ -0,0 +1,27 @@ +{ + "name": "@hyperlight-dev/js-host-api-win32-arm64-msvc", + "version": "0.4.0", + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "main": "js-host-api.win32-arm64-msvc.node", + "files": [ + "js-host-api.win32-arm64-msvc.node" + ], + "description": "Node.js API bindings for Hyperlight JS - Windows ARM64 msvc", + "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 b4212079..9fbd5ca1 100644 --- a/src/js-host-api/package-lock.json +++ b/src/js-host-api/package-lock.json @@ -23,6 +23,7 @@ "@hyperlight-dev/js-host-api-linux-arm64-gnu": "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-arm64-msvc": "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 cf6134c1..a2d1b6ca 100644 --- a/src/js-host-api/package.json +++ b/src/js-host-api/package.json @@ -31,6 +31,7 @@ "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc", + "aarch64-pc-windows-msvc", "aarch64-apple-darwin", "aarch64-unknown-linux-gnu" ] @@ -41,6 +42,7 @@ "@hyperlight-dev/js-host-api-linux-arm64-gnu": "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-arm64-msvc": "0.4.0", "@hyperlight-dev/js-host-api-win32-x64-msvc": "0.4.0" }, "devDependencies": { diff --git a/src/js-host-api/test-pack.ps1 b/src/js-host-api/test-pack.ps1 new file mode 100644 index 00000000..99b07863 --- /dev/null +++ b/src/js-host-api/test-pack.ps1 @@ -0,0 +1,65 @@ +# Validate Windows ARM64 release tarballs on a native ARM64 host with WHP enabled. +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +$platform = node -p "process.platform + '-' + process.arch" +if ($LASTEXITCODE -ne 0 -or $platform -ne "win32-arm64") { + throw "Windows ARM64 package validation requires native ARM64 Node.js on Windows" +} + +$packageDir = Join-Path $PSScriptRoot "npm\win32-arm64-msvc" +$binaryName = "js-host-api.win32-arm64-msvc.node" +$binaryPath = Join-Path $packageDir $binaryName +if (-not (Test-Path $binaryPath)) { + Copy-Item (Join-Path $PSScriptRoot $binaryName) $binaryPath +} + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hyperlight-npm-test-" + [guid]::NewGuid()) +$null = New-Item -ItemType Directory -Path $tempDir +Push-Location $PSScriptRoot +try { + $platformPack = npm pack $packageDir --pack-destination $tempDir --json | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to pack Windows ARM64 package" } + if ($binaryName -notin $platformPack.files.path) { + throw "Windows ARM64 package is missing $binaryName" + } + + $mainPack = npm pack --pack-destination $tempDir --json | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw "Failed to pack main package" } + foreach ($file in @("package.json", "lib.js", "lib.mjs", "index.js", "index.d.ts", "lib.d.ts")) { + if ($file -notin $mainPack.files.path) { + throw "Main package is missing $file" + } + } + foreach ($file in $mainPack.files.path) { + if ($file -match '^(src|tests|node_modules|target)/|^Cargo\.toml$|\.node$') { + throw "Main package contains unexpected file: $file" + } + } + + Set-Location $tempDir + npm init -y --silent + if ($LASTEXITCODE -ne 0) { throw "Failed to initialize consumer project" } + npm install (Join-Path $tempDir $platformPack.filename) (Join-Path $tempDir $mainPack.filename) --ignore-scripts + if ($LASTEXITCODE -ne 0) { throw "Failed to install release tarballs" } + + node -e @' +const assert = require('node:assert/strict'); +const { SandboxBuilder } = require('@hyperlight-dev/js-host-api'); +async function main() { + assert.equal(typeof SandboxBuilder, 'function'); + const proto = await new SandboxBuilder().build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler('hello', 'function handler(event) { event.greeting = "Hello from Hyperlight!"; return event; }'); + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('hello', {}, { gc: false }); + assert.equal(result.greeting, 'Hello from Hyperlight!'); + console.log('Windows ARM64 release tarballs loaded and executed a WHP sandbox successfully'); +} +main().catch(error => { console.error(error); process.exit(1); }); +'@ + if ($LASTEXITCODE -ne 0) { throw "Windows ARM64 WHP smoke test failed" } +} finally { + Pop-Location + Remove-Item -LiteralPath $tempDir -Recurse -Force +} diff --git a/src/js-host-api/tests/host-functions.test.js b/src/js-host-api/tests/host-functions.test.js index f3ea53af..a716025e 100644 --- a/src/js-host-api/tests/host-functions.test.js +++ b/src/js-host-api/tests/host-functions.test.js @@ -4,7 +4,7 @@ // sandboxed code can call via `import * as from "host:"`. import { describe, it, expect, beforeEach } from 'vitest'; import { SandboxBuilder } from '../lib.js'; -import { expectThrowsWithCode } from './test-helpers.js'; +import { expectThrowsWithCode, trackSandbox } from './test-helpers.js'; // ── Helpers ────────────────────────────────────────────────────────── @@ -20,9 +20,9 @@ import { expectThrowsWithCode } from './test-helpers.js'; async function buildLoadedSandbox(registerFns, handlerScript) { const proto = await new SandboxBuilder().build(); registerFns(proto); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', handlerScript); - return sandbox.getLoadedSandbox(); + return trackSandbox(sandbox.getLoadedSandbox()); } // ── HostModule ──────────────────────────────────────────────── @@ -96,9 +96,9 @@ describe('Host function invocation', () => { ]) { const proto = await builder.build(); proto.hostModule('host').register('echo', (value) => value); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', source); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); expect(await loaded.callHandler('handler', { value: payload })).toEqual({ value: payload, @@ -406,9 +406,9 @@ describe('Multi-sandbox isolation', () => { async function buildTaggedSandbox(tag, hostFnImpl, handlerScript) { const proto = await new SandboxBuilder().build(); hostFnImpl(proto); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', handlerScript); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); return { loaded, tag }; } diff --git a/src/js-host-api/tests/sandbox.test.js b/src/js-host-api/tests/sandbox.test.js index 9c6ba238..e059eba7 100644 --- a/src/js-host-api/tests/sandbox.test.js +++ b/src/js-host-api/tests/sandbox.test.js @@ -1,7 +1,7 @@ // Basic sandbox functionality tests import { describe, it, expect, beforeEach, vi } from 'vitest'; import { SandboxBuilder } from '../lib.js'; -import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js'; +import { expectThrowsWithCode, expectRejectsWithCode, trackSandbox } from './test-helpers.js'; // ── SandboxBuilder ─────────────────────────────────────────────────── @@ -87,14 +87,14 @@ describe('ProtoJSSandbox', () => { it('should load the JavaScript runtime', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); expect(sandbox).toBeDefined(); }); it('should throw CONSUMED on double loadRuntime()', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - await proto.loadRuntime(); + await trackSandbox(proto.loadRuntime()); await expectRejectsWithCode(proto.loadRuntime(), 'ERR_CONSUMED'); }); }); @@ -107,7 +107,7 @@ describe('JSSandbox', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - sandbox = await proto.loadRuntime(); + sandbox = await trackSandbox(proto.loadRuntime()); }); it('should add a handler without throwing', () => { @@ -138,7 +138,7 @@ describe('JSSandbox', () => { it('should get a loaded sandbox', async () => { sandbox.addHandler('handler', 'function handler(e) { return e; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); expect(loaded).toBeDefined(); }); @@ -149,13 +149,13 @@ describe('JSSandbox', () => { it('should throw CONSUMED on double getLoadedSandbox()', async () => { sandbox.addHandler('handler', 'function handler(e) { return e; }'); - await sandbox.getLoadedSandbox(); + await trackSandbox(sandbox.getLoadedSandbox()); await expectRejectsWithCode(sandbox.getLoadedSandbox(), 'ERR_CONSUMED'); }); it('should throw CONSUMED on operations after getLoadedSandbox()', async () => { sandbox.addHandler('handler', 'function handler(e) { return e; }'); - await sandbox.getLoadedSandbox(); + await trackSandbox(sandbox.getLoadedSandbox()); expectThrowsWithCode( () => sandbox.addHandler('another', 'function handler(e) { return e; }'), 'ERR_CONSUMED' @@ -171,7 +171,7 @@ describe('LoadedJSSandbox', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', ` @@ -181,7 +181,7 @@ describe('LoadedJSSandbox', () => { } ` ); - loaded = await sandbox.getLoadedSandbox(); + loaded = await trackSandbox(sandbox.getLoadedSandbox()); }); it('should handle events and return correct data', async () => { @@ -223,17 +223,17 @@ describe('LoadedJSSandbox', () => { }); it('should unload back to JSSandbox', async () => { - const jsSandbox = await loaded.unload(); + const jsSandbox = await trackSandbox(loaded.unload()); expect(jsSandbox).toBeDefined(); }); it('should throw CONSUMED on double unload()', async () => { - await loaded.unload(); + await trackSandbox(loaded.unload()); await expectRejectsWithCode(loaded.unload(), 'ERR_CONSUMED'); }); it('should throw CONSUMED on callHandler after unload', async () => { - await loaded.unload(); + await trackSandbox(loaded.unload()); await expectRejectsWithCode( loaded.callHandler('handler', {}, { gc: false }), 'ERR_CONSUMED' @@ -260,7 +260,7 @@ describe('Calculator example', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', ` @@ -283,7 +283,7 @@ describe('Calculator example', () => { } ` ); - loaded = await sandbox.getLoadedSandbox(); + loaded = await trackSandbox(sandbox.getLoadedSandbox()); }); it('should add numbers', async () => { @@ -329,7 +329,7 @@ describe('JSSandbox.dispose()', () => { it('should make subsequent method calls throw ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.dispose(); @@ -342,7 +342,7 @@ describe('JSSandbox.dispose()', () => { it('should be a no-op when called twice', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.dispose(); sandbox.dispose(); // should not throw @@ -351,7 +351,7 @@ describe('JSSandbox.dispose()', () => { it('should make poisoned getter throw ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.dispose(); @@ -363,9 +363,9 @@ describe('LoadedJSSandbox.dispose()', () => { it('should make subsequent callHandler reject with ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return { ok: true }; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.dispose(); @@ -375,9 +375,9 @@ describe('LoadedJSSandbox.dispose()', () => { it('should be a no-op when called twice', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.dispose(); await loaded.dispose(); // should not throw @@ -386,9 +386,9 @@ describe('LoadedJSSandbox.dispose()', () => { it('should make poisoned getter throw ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.dispose(); @@ -398,9 +398,9 @@ describe('LoadedJSSandbox.dispose()', () => { it('should make interruptHandle getter throw ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.dispose(); @@ -410,9 +410,9 @@ describe('LoadedJSSandbox.dispose()', () => { it('should make lastCallStats getter throw ERR_CONSUMED', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.dispose(); @@ -426,11 +426,11 @@ describe('LoadedJSSandbox.unload()', () => { it('should make poisoned getter throw ERR_CONSUMED after unload', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); - await loaded.unload(); + await trackSandbox(loaded.unload()); expectThrowsWithCode(() => loaded.poisoned, 'ERR_CONSUMED'); }); @@ -438,11 +438,11 @@ describe('LoadedJSSandbox.unload()', () => { it('should make interruptHandle getter throw ERR_CONSUMED after unload', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); - await loaded.unload(); + await trackSandbox(loaded.unload()); expectThrowsWithCode(() => loaded.interruptHandle, 'ERR_CONSUMED'); }); @@ -450,11 +450,11 @@ describe('LoadedJSSandbox.unload()', () => { it('should make lastCallStats getter throw ERR_CONSUMED after unload', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler() { return {}; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); - await loaded.unload(); + await trackSandbox(loaded.unload()); expectThrowsWithCode(() => loaded.lastCallStats, 'ERR_CONSUMED'); }); @@ -475,7 +475,7 @@ describe('setHostPrintFn', () => { messages.push(msg); }); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', `function handler(event) { @@ -483,7 +483,7 @@ describe('setHostPrintFn', () => { return event; }` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.callHandler('handler', {}); expect(messages.join('')).toContain('Hello from guest!'); @@ -495,7 +495,7 @@ describe('setHostPrintFn', () => { messages.push(msg); }); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', `function handler(event) { @@ -505,7 +505,7 @@ describe('setHostPrintFn', () => { return event; }` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.callHandler('handler', {}); const combined = messages.join(''); @@ -521,7 +521,7 @@ describe('setHostPrintFn', () => { .setHostPrintFn((msg) => firstMessages.push(msg)) .setHostPrintFn((msg) => secondMessages.push(msg)); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', `function handler(event) { @@ -529,7 +529,7 @@ describe('setHostPrintFn', () => { return event; }` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await loaded.callHandler('handler', {}); expect(firstMessages.length).toBe(0); @@ -541,7 +541,7 @@ describe('setHostPrintFn', () => { throw new Error('print callback exploded'); }); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', `function handler(event) { @@ -549,7 +549,7 @@ describe('setHostPrintFn', () => { return { survived: true }; }` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); // Spy on console.error to suppress noise and verify the error is logged const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/js-host-api/tests/test-helpers.js b/src/js-host-api/tests/test-helpers.js index a3cf649b..ab9a020e 100644 --- a/src/js-host-api/tests/test-helpers.js +++ b/src/js-host-api/tests/test-helpers.js @@ -1,11 +1,36 @@ -// ── Test helpers for structured error code assertions ───────────────── +// ── Test helpers for sandbox cleanup and error code assertions ──────── // // Vitest's built-in `.toThrow()` only matches on error messages, not on // the `error.code` property that our lib.js wrapper sets. These helpers // provide a clean way to assert that a function throws (or a promise // rejects) with a specific `error.code` value. -import { expect } from 'vitest'; +import { afterEach, expect } from 'vitest'; + +const sandboxes = []; + +// V8 garbage collection need not run between tests. Dispose every runtime/loaded +// wrapper explicitly so live VMs do not accumulate past WHP's ARM64 limit of 64. +// Track each ownership transition, including unload(); consumed wrappers have +// idempotent dispose(), so cleanup is safe even after a test consumes one. +export async function trackSandbox(promise) { + const sandbox = await promise; + sandboxes.push(sandbox); + return sandbox; +} + +afterEach(async () => { + const results = await Promise.allSettled( + sandboxes.splice(0).map(async (sandbox) => sandbox.dispose()) + ); + const errors = results.filter((result) => result.status === 'rejected'); + if (errors.length) { + throw new AggregateError( + errors.map((result) => result.reason), + 'Failed to dispose test sandboxes' + ); + } +}); /** * Assert that `fn` throws synchronously with the given `error.code`. diff --git a/src/js-host-api/tests/test-helpers.test.js b/src/js-host-api/tests/test-helpers.test.js new file mode 100644 index 00000000..61cb556f --- /dev/null +++ b/src/js-host-api/tests/test-helpers.test.js @@ -0,0 +1,39 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { trackSandbox } from './test-helpers.js'; + +describe('test sandbox cleanup', () => { + const disposed = []; + let asyncDisposed = false; + const disposeLoaded = async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + asyncDisposed = true; + }; + + afterAll(() => { + expect(disposed).toEqual(['runtime', 'consumed', 'failed']); + expect(asyncDisposed).toBe(true); + }); + + it('returns the original runtime wrapper', async () => { + const sandbox = { dispose: () => disposed.push('runtime') }; + expect(await trackSandbox(Promise.resolve(sandbox))).toBe(sandbox); + }); + + it('can clean up an already-consumed wrapper', async () => { + await trackSandbox(Promise.resolve({ dispose: () => disposed.push('consumed') })); + }); + + it('preserves failures during sandbox creation', async () => { + const error = new Error('sandbox creation failed'); + await expect(trackSandbox(Promise.reject(error))).rejects.toBe(error); + }); + + it.fails('cleans up even when a test fails', async () => { + await trackSandbox(Promise.resolve({ dispose: () => disposed.push('failed') })); + throw new Error('deliberate test failure'); + }); + + it('awaits asynchronous disposal', async () => { + await trackSandbox(Promise.resolve({ dispose: disposeLoaded })); + }); +}); diff --git a/src/js-host-api/tests/timeout.test.js b/src/js-host-api/tests/timeout.test.js index 69c196d0..3080452a 100644 --- a/src/js-host-api/tests/timeout.test.js +++ b/src/js-host-api/tests/timeout.test.js @@ -1,7 +1,7 @@ // Timeout and interrupt tests import { describe, it, expect, beforeEach } from 'vitest'; import { SandboxBuilder } from '../lib.js'; -import { expectRejectsWithCode } from './test-helpers.js'; +import { expectRejectsWithCode, trackSandbox } from './test-helpers.js'; describe('Wall Clock Timeout', () => { let loaded; @@ -10,7 +10,7 @@ describe('Wall Clock Timeout', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', ` @@ -29,7 +29,7 @@ describe('Wall Clock Timeout', () => { } ` ); - loaded = await sandbox.getLoadedSandbox(); + loaded = await trackSandbox(sandbox.getLoadedSandbox()); snapshot = await loaded.snapshot(); }); @@ -114,7 +114,7 @@ describe('CPU Time Timeout', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', ` @@ -132,7 +132,7 @@ describe('CPU Time Timeout', () => { } ` ); - loaded = await sandbox.getLoadedSandbox(); + loaded = await trackSandbox(sandbox.getLoadedSandbox()); }); it('should complete fast handler before CPU timeout', async () => { @@ -182,7 +182,7 @@ describe('Combined Monitors (CPU + Wall Clock)', () => { beforeEach(async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler( 'handler', ` @@ -200,7 +200,7 @@ describe('Combined Monitors (CPU + Wall Clock)', () => { } ` ); - loaded = await sandbox.getLoadedSandbox(); + loaded = await trackSandbox(sandbox.getLoadedSandbox()); snapshot = await loaded.snapshot(); }); @@ -366,9 +366,9 @@ describe('Interrupt Handle', () => { it('should provide an interrupt handle', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler(e) { return e; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); // interruptHandle is now a getter, not a method const handle = loaded.interruptHandle; @@ -379,7 +379,7 @@ describe('Interrupt Handle', () => { it('should kill a running handler via interruptHandle', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); // Handler that runs for 10 seconds — plenty of time for kill() to fire sandbox.addHandler( 'handler', @@ -391,7 +391,7 @@ describe('Interrupt Handle', () => { } ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const handle = loaded.interruptHandle; // Start the handler, then kill it from a timer @@ -406,9 +406,9 @@ describe('Interrupt Handle', () => { it('should reject empty handler name on callHandler', async () => { const builder = new SandboxBuilder(); const proto = await builder.build(); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addHandler('handler', 'function handler(e) { return e; }'); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); await expectRejectsWithCode( loaded.callHandler('', {}, { wallClockTimeoutMs: 1000 }), diff --git a/src/js-host-api/tests/user-modules.test.js b/src/js-host-api/tests/user-modules.test.js index dda72cf2..77622d29 100644 --- a/src/js-host-api/tests/user-modules.test.js +++ b/src/js-host-api/tests/user-modules.test.js @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { SandboxBuilder } from '../lib.js'; -import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js'; +import { expectThrowsWithCode, expectRejectsWithCode, trackSandbox } from './test-helpers.js'; // ── Helpers ────────────────────────────────────────────────────────── @@ -16,7 +16,7 @@ import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js'; */ async function createSandbox() { const proto = await new SandboxBuilder().build(); - return proto.loadRuntime(); + return trackSandbox(proto.loadRuntime()); } // ── Module registration ────────────────────────────────────────────── @@ -86,7 +86,7 @@ describe('Module registration', () => { function handler(e) { e.x = x; return e; } ` ); - await sandbox.getLoadedSandbox(); + await trackSandbox(sandbox.getLoadedSandbox()); expectThrowsWithCode( () => sandbox.addModule('another', 'export const y = 2;'), 'ERR_CONSUMED' @@ -118,7 +118,7 @@ describe('Handler importing user module', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', { a: 6, b: 7 }); expect(result.sum).toBe(13); expect(result.product).toBe(42); @@ -138,7 +138,7 @@ describe('Handler importing user module', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', { a: 10, b: 20 }); expect(result.result).toBe(30); }); @@ -176,7 +176,7 @@ describe('Inter-module imports', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', { radius: 5 }); expect(result.area).toBeCloseTo(78.53975, 3); }); @@ -205,7 +205,7 @@ describe('Inter-module imports', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', { data: 'hello' }); expect(result.hash).toBeTruthy(); expect(typeof result.hash).toBe('string'); @@ -220,7 +220,7 @@ describe('User module importing host functions', () => { const proto = await new SandboxBuilder().build(); proto.hostModule('db').register('lookup', (id) => ({ id, name: `User ${id}` })); - const sandbox = await proto.loadRuntime(); + const sandbox = await trackSandbox(proto.loadRuntime()); sandbox.addModule( 'enricher', @@ -244,7 +244,7 @@ describe('User module importing host functions', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', { userId: 42 }); expect(result.userName).toBe('User 42'); }); @@ -273,7 +273,7 @@ describe('Module state retention', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const r1 = await loaded.callHandler('handler', {}); expect(r1.count).toBe(1); const r2 = await loaded.callHandler('handler', {}); @@ -308,7 +308,7 @@ describe('Multiple handlers sharing a module', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const r1 = await loaded.callHandler('doubler', { x: 5 }); expect(r1.result).toBe(10); const r2 = await loaded.callHandler('tripler', { x: 5 }); @@ -356,7 +356,7 @@ describe('Cross-handler mutable state sharing', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); // writer increments → count=1 const r1 = await loaded.callHandler('writer', {}); @@ -412,7 +412,7 @@ describe('Cross-handler mutable state sharing', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); // Write two entries via the 'put' handler await loaded.callHandler('put', { key: 'name', value: 'Hyperlight' }); @@ -442,12 +442,12 @@ describe('Unload and reload with modules', () => { ` ); - let loaded = await sandbox.getLoadedSandbox(); + let loaded = await trackSandbox(sandbox.getLoadedSandbox()); let result = await loaded.callHandler('handler', { x: 5 }); expect(result.result).toBe(6); // 5 + 1 // Unload, swap module, reload - const sandbox2 = await loaded.unload(); + const sandbox2 = await trackSandbox(loaded.unload()); sandbox2.addModule('math', 'export function compute(x) { return x * 2; }'); sandbox2.addHandler( 'handler', @@ -457,7 +457,7 @@ describe('Unload and reload with modules', () => { ` ); - loaded = await sandbox2.getLoadedSandbox(); + loaded = await trackSandbox(sandbox2.getLoadedSandbox()); result = await loaded.callHandler('handler', { x: 5 }); expect(result.result).toBe(10); // 5 * 2 }); @@ -542,7 +542,7 @@ describe('Snapshot and restore with modules', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const r1 = await loaded.callHandler('handler', {}); expect(r1.count).toBe(1); @@ -595,7 +595,7 @@ describe('Circular module dependencies', () => { ` ); - const loaded = await sandbox.getLoadedSandbox(); + const loaded = await trackSandbox(sandbox.getLoadedSandbox()); const result = await loaded.callHandler('handler', {}); expect(result.xy).toBe('XY'); expect(result.yx).toBe('YX'); diff --git a/src/js-host-api/vitest.config.js b/src/js-host-api/vitest.config.js index f97140f8..cc45a17e 100644 --- a/src/js-host-api/vitest.config.js +++ b/src/js-host-api/vitest.config.js @@ -4,6 +4,12 @@ export default defineConfig({ test: { // Test files pattern include: ['tests/**/*.test.js'], + // Windows ARM64 WHP currently supports only 64 concurrent partitions. + // Hyperlight's permit counter is process-local, so separate Vitest workers + // cannot coordinate that limit. Run one file at a time on this host. + ...(process.platform === 'win32' && process.arch === 'arm64' + ? { fileParallelism: false, maxWorkers: 1 } + : {}), // Increase timeout for sandbox operations (some involve busy loops) testTimeout: 30000, },