From dc9827c8dc3ec334ea2ad1297cf8a5c3a4c6af95 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 20 Oct 2026 21:19:23 +0100 Subject: [PATCH 1/6] Support Windows/WHP aarch64 Signed-off-by: Simon Davies --- .github/workflows/dep_build.yml | 18 +- .github/workflows/npm-publish.yml | 192 +++++++++++++++--- Justfile | 3 +- README.md | 11 +- docs/release.md | 4 +- src/js-host-api/.npmignore | 1 + src/js-host-api/DEVELOPMENT.md | 1 + src/js-host-api/README.md | 2 +- .../npm/win32-arm64-msvc/package.json | 27 +++ src/js-host-api/package-lock.json | 1 + src/js-host-api/package.json | 2 + src/js-host-api/test-pack.ps1 | 65 ++++++ 12 files changed, 280 insertions(+), 47 deletions(-) create mode 100644 src/js-host-api/npm/win32-arm64-msvc/package.json create mode 100644 src/js-host-api/test-pack.ps1 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..c43d0dec 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,119 @@ 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: ${{ fromJSON(needs.package-targets.outputs.first_time) }} + runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -172,19 +286,19 @@ 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 + shell: ${{ matrix.shell }} + 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 +306,36 @@ 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 + shell: ${{ matrix.shell }} + 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 + shell: ${{ matrix.shell }} 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'); - " + shell: ${{ matrix.shell }} 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 + shell: ${{ matrix.shell }} 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/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..3b6e3234 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-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 +} From d5782fce77512a564a9647c4fdbaedc4c5565e02 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 21 Oct 2026 20:54:38 +0100 Subject: [PATCH 2/6] Fix Windows guest header path normalization Use forward slashes so Clang finds the runtime math shim on Windows instead of generating unsupported float128 conversion calls. Cover canonical Windows and unchanged Unix paths in build-helper tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 11c94998-3ea5-4723-a359-9352ce7c25fe Signed-off-by: Simon Davies --- src/hyperlight-js/build.rs | 8 ++----- src/hyperlight-js/runtime_build.rs | 34 ++++++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 8 deletions(-) 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() { From e9ddcf405084c6fa5d4be706f937860cfb5877e9 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 21 Oct 2026 21:03:05 +0100 Subject: [PATCH 3/6] Fix native npm validation workflow contexts Use the supported job-default shell context so GitHub accepts the publishing workflow. Expand generated native targets through matrix include while preserving platform commands and shell selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 11c94998-3ea5-4723-a359-9352ce7c25fe Signed-off-by: Simon Davies --- .github/workflows/npm-publish.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index c43d0dec..e617557d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -277,8 +277,13 @@ jobs: needs: [build, package-targets] strategy: fail-fast: true - matrix: ${{ fromJSON(needs.package-targets.outputs.first_time) }} + 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 @@ -291,7 +296,6 @@ jobs: cache-dependency-path: 'src/js-host-api/package-lock.json' - name: Verify native runner - shell: ${{ matrix.shell }} run: ${{ matrix.verify_command }} - name: Download native artifact @@ -308,26 +312,22 @@ jobs: - name: Prepare native package working-directory: ${{ env.WORKING_DIR }} - shell: ${{ matrix.shell }} run: ${{ matrix.prepare_command }} - name: Set package versions working-directory: ${{ env.WORKING_DIR }} - shell: ${{ matrix.shell }} env: VERSION: ${{ inputs.version }} run: ${{ matrix.version_command }} - name: Update optional dependency version working-directory: ${{ env.WORKING_DIR }} - shell: ${{ matrix.shell }} env: VERSION: ${{ inputs.version }} run: ${{ matrix.update_dependency_command }} - name: Validate native release tarballs working-directory: ${{ env.WORKING_DIR }} - shell: ${{ matrix.shell }} env: PLATFORM_PACKAGE: ${{ matrix.artifact_dir }} REQUIRE_MACOS_PACKAGE: ${{ contains(matrix.package_name, 'darwin') && '1' || '' }} From ec0f236f4139b460782aaf3a8874764df6d9361f Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 22 Oct 2026 12:30:59 +0100 Subject: [PATCH 4/6] Fix Windows ARM64 test sandbox exhaustion Dispose test sandboxes after each test instead of relying on garbage collection, including wrappers returned by ownership transitions. Serialize Vitest files on Windows ARM64 because WHP limits concurrent partitions and Hyperlight's partition accounting is process-local. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- src/js-host-api/DEVELOPMENT.md | 7 ++ src/js-host-api/tests/host-functions.test.js | 14 ++-- src/js-host-api/tests/sandbox.test.js | 88 ++++++++++---------- src/js-host-api/tests/test-helpers.js | 29 ++++++- src/js-host-api/tests/test-helpers.test.js | 39 +++++++++ src/js-host-api/tests/timeout.test.js | 26 +++--- src/js-host-api/tests/user-modules.test.js | 36 ++++---- src/js-host-api/vitest.config.js | 6 ++ 8 files changed, 161 insertions(+), 84 deletions(-) create mode 100644 src/js-host-api/tests/test-helpers.test.js diff --git a/src/js-host-api/DEVELOPMENT.md b/src/js-host-api/DEVELOPMENT.md index 3b6e3234..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). 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, }, From 7e876aaed6cd5a8d4fe5f444361a30286597aa7a Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 22 Oct 2026 18:55:46 +0100 Subject: [PATCH 5/6] revert unnecessary changes Signed-off-by: Simon Davies --- src/hyperlight-js/build.rs | 8 +++++-- src/hyperlight-js/runtime_build.rs | 34 ++---------------------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/src/hyperlight-js/build.rs b/src/hyperlight-js/build.rs index da6713d8..81845ff2 100644 --- a/src/hyperlight-js/build.rs +++ b/src/hyperlight-js/build.rs @@ -31,7 +31,11 @@ use std::{env, fs}; use serde_json::Value; mod runtime_build; -use runtime_build::{guest_cflags, runtime_source, select_binary, RuntimeSource}; +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"; fn main() { // Mirror the hypervisor cfg aliases used by hyperlight-host so that @@ -226,7 +230,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 = guest_cflags(&include_dir); + let cflags = format!("-I{} {QUICKJS_CFLAGS}", include_dir.display()).replace("\\", "\\\\"); 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 c0b71c23..83a81e92 100644 --- a/src/hyperlight-js/runtime_build.rs +++ b/src/hyperlight-js/runtime_build.rs @@ -15,20 +15,10 @@ limitations under the License. */ use std::ffi::OsString; -use std::path::{Path, PathBuf}; +use std::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, @@ -66,27 +56,7 @@ pub(crate) fn select_binary(package: &Value) -> Result { mod tests { use serde_json::json; - 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" - ); - } + use super::{runtime_source, select_binary, RuntimeSource}; #[test] fn absent_or_empty_manifest_selects_default_source() { From da4491450322634015295fb5f630e72a5aeafeeb Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Thu, 22 Oct 2026 20:31:58 +0100 Subject: [PATCH 6/6] Normalize Windows guest include path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Simon Davies --- src/hyperlight-js/build.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hyperlight-js/build.rs b/src/hyperlight-js/build.rs index 81845ff2..d86ca3f9 100644 --- a/src/hyperlight-js/build.rs +++ b/src/hyperlight-js/build.rs @@ -230,7 +230,8 @@ 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("\\", "\\\\"); + // Forward slashes keep Clang's include path intact so it finds the ARM math shim. + let cflags = format!("-I{} {QUICKJS_CFLAGS}", include_dir.display()).replace('\\', "/"); let mut cargo_cmd = cargo_hyperlight::cargo().unwrap(); let cmd = cargo_cmd