mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Bound V8's stack scan by Node's own stack in Node islands (#1898)
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
name: libnode development pack (Linux x64, macOS)
|
||||
description: >-
|
||||
Provide a libnode pack for the Node island tests and export
|
||||
SCRIPTC_LIBNODE: restored from the cache, or on a miss assembled from the
|
||||
pinned, checksum-verified inputs in packages/libnode-common/dev-pack.json
|
||||
for the runner's platform (a prebuilt libnode and Node's headers; the
|
||||
bridge builds in seconds). Temporary until the NI-13 packs exist. Fails
|
||||
the job if no pack results: the island tests must never skip in CI.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Name the runner's platform
|
||||
id: platform
|
||||
shell: bash
|
||||
run: node -p '`name=${process.platform}-${process.arch}`' >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v4
|
||||
id: libnode
|
||||
with:
|
||||
path: ${{ runner.temp }}/libnode-dev-pack
|
||||
key: libnode-dev-pack-${{ steps.platform.outputs.name }}-${{ hashFiles('packages/libnode-common/dev-pack.json', 'packages/libnode-common/build-pack.mjs', 'packages/libnode-common/fetch-dev-pack.mjs', 'packages/libnode-common/bridge/**', 'packages/runtime/src/scr_node_bridge.h') }}
|
||||
- name: Assemble the libnode pack
|
||||
if: steps.libnode.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: node packages/libnode-common/fetch-dev-pack.mjs --out "$RUNNER_TEMP/libnode-dev-pack"
|
||||
- name: Export SCRIPTC_LIBNODE
|
||||
shell: bash
|
||||
run: |
|
||||
case "$RUNNER_OS" in macOS) bridge=libscr_node_bridge.dylib ;; *) bridge=libscr_node_bridge.so ;; esac
|
||||
test -f "$RUNNER_TEMP/libnode-dev-pack/manifest.json"
|
||||
test -f "$RUNNER_TEMP/libnode-dev-pack/$bridge"
|
||||
echo "SCRIPTC_LIBNODE=$RUNNER_TEMP/libnode-dev-pack" >> "$GITHUB_ENV"
|
||||
@@ -0,0 +1,64 @@
|
||||
name: libnode pack (Linux x64, macOS)
|
||||
description: >-
|
||||
Provide the runner's libnode pack for the Node island tests and export
|
||||
SCRIPTC_LIBNODE. Restores the libnode build that the libnode-packs
|
||||
workflow cached and assembles @scriptc/libnode-<platform> from it (the
|
||||
bridge compiles in seconds; Node is never built here). Until that cache
|
||||
exists for this ref, it falls back to the pinned, checksum-verified
|
||||
development pack (packages/libnode-common/dev-pack.json). Fails the job if
|
||||
no pack results: the island tests must never skip in CI.
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Name the runner's pack
|
||||
id: target
|
||||
shell: bash
|
||||
run: |
|
||||
case "$RUNNER_OS-$RUNNER_ARCH" in
|
||||
Linux-X64) platform=linux-x64-gnu toolchain=manylinux_2_28-gcc-toolset-14 bridge=libscr_node_bridge.so ;;
|
||||
macOS-ARM64) platform=darwin-arm64 toolchain=macos-15-xcode-clang bridge=libscr_node_bridge.dylib ;;
|
||||
macOS-X64) platform=darwin-x64 toolchain=macos-15-xcode-clang bridge=libscr_node_bridge.dylib ;;
|
||||
*) echo "::error::no libnode pack for $RUNNER_OS $RUNNER_ARCH"; exit 1 ;;
|
||||
esac
|
||||
printf 'platform=%s\ntoolchain=%s\nbridge=%s\n' "$platform" "$toolchain" "$bridge" >> "$GITHUB_OUTPUT"
|
||||
- name: Compute the libnode cache key
|
||||
id: key
|
||||
shell: bash
|
||||
env:
|
||||
SCRIPTC_LIBNODE_TOOLCHAIN: ${{ steps.target.outputs.toolchain }}
|
||||
run: node packages/libnode-common/scripts/cache-key.mjs --platform ${{ steps.target.outputs.platform }} >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache/restore@v4
|
||||
id: libnode
|
||||
with:
|
||||
path: .libnode-cache/${{ steps.key.outputs.path }}
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
- name: Assemble @scriptc/libnode-${{ steps.target.outputs.platform }}
|
||||
if: steps.libnode.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SCRIPTC_LIBNODE_TOOLCHAIN: ${{ steps.target.outputs.toolchain }}
|
||||
SCRIPTC_LIBNODE_CACHE: ${{ github.workspace }}/.libnode-cache
|
||||
run: |
|
||||
node packages/libnode-${{ steps.target.outputs.platform }}/scripts/build.mjs --from-cache
|
||||
node packages/libnode-common/scripts/verify.mjs packages/libnode-${{ steps.target.outputs.platform }}
|
||||
echo "SCRIPTC_LIBNODE=$GITHUB_WORKSPACE/packages/libnode-${{ steps.target.outputs.platform }}" >> "$GITHUB_ENV"
|
||||
- uses: actions/cache@v4
|
||||
if: steps.libnode.outputs.cache-hit != 'true'
|
||||
id: devpack
|
||||
with:
|
||||
path: ${{ runner.temp }}/libnode-dev-pack
|
||||
key: libnode-dev-pack-${{ steps.target.outputs.platform }}-${{ hashFiles('packages/libnode-common/dev-pack.json', 'packages/libnode-common/build-pack.mjs', 'packages/libnode-common/fetch-dev-pack.mjs', 'packages/libnode-common/bridge/**', 'packages/runtime/src/scr_node_bridge.h') }}
|
||||
- name: Assemble the development pack
|
||||
if: steps.libnode.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::notice::no cached libnode-packs build for ${{ steps.key.outputs.key }}; using the development pack"
|
||||
if [ "${{ steps.devpack.outputs.cache-hit }}" != true ]; then
|
||||
node packages/libnode-common/fetch-dev-pack.mjs --out "$RUNNER_TEMP/libnode-dev-pack"
|
||||
fi
|
||||
echo "SCRIPTC_LIBNODE=$RUNNER_TEMP/libnode-dev-pack" >> "$GITHUB_ENV"
|
||||
- name: Check the pack
|
||||
shell: bash
|
||||
run: |
|
||||
test -f "$SCRIPTC_LIBNODE/manifest.json"
|
||||
test -f "$SCRIPTC_LIBNODE/${{ steps.target.outputs.bridge }}"
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
# Node island tests (`// @requires-libnode` corpus programs and
|
||||
# node-island.test.ts) load Node from a libnode pack, and fail rather
|
||||
# than skip in this lane when none is configured.
|
||||
- uses: ./.github/actions/libnode-dev-pack
|
||||
- uses: ./.github/actions/libnode
|
||||
# Ubuntu pipes core dumps to apport, and RLIMIT_CORE does not limit a
|
||||
# piped dump. The kernel then streams every page of the runtime's
|
||||
# reserved allocator address space through the pipe, so each expected
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
- run: pnpm build
|
||||
# Node island tests load Node from a libnode pack, and fail rather
|
||||
# than skip on macOS CI when none is configured.
|
||||
- uses: ./.github/actions/libnode-dev-pack
|
||||
- uses: ./.github/actions/libnode
|
||||
- name: Tests (${{ (github.event_name != 'schedule' && inputs.macos_suite != 'full') && 'darwin suite' || 'corpus and integration suites' }})
|
||||
run: node scripts/ci-test.mjs
|
||||
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
- run: pnpm build
|
||||
# Node island tests load Node from a libnode pack, and fail rather
|
||||
# than skip on macOS CI when none is configured.
|
||||
- uses: ./.github/actions/libnode-dev-pack
|
||||
- uses: ./.github/actions/libnode
|
||||
- name: Node island harness
|
||||
if: matrix.shard == 1
|
||||
run: pnpm test tests/harness/node-island packages/libnode-common
|
||||
@@ -788,7 +788,7 @@ jobs:
|
||||
- run: pnpm build
|
||||
# Node island tests load Node from a libnode pack, and fail rather
|
||||
# than skip on macOS CI when none is configured.
|
||||
- uses: ./.github/actions/libnode-dev-pack
|
||||
- uses: ./.github/actions/libnode
|
||||
- name: Node islands (Intel)
|
||||
run: >-
|
||||
pnpm test tests/harness/node-island packages/libnode-common
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: libnode packs
|
||||
|
||||
# Builds the @scriptc/libnode-<platform> packs: the pinned Node.js release
|
||||
# as a shared library plus the scriptc bridge (packages/libnode-common).
|
||||
# A Node build takes about half an hour on 8 cores, so this workflow is
|
||||
# never part of the ordinary build or test path. It runs only when the pin,
|
||||
# the bridge or the pack packages change, or by hand, and it caches each
|
||||
# platform's libnode build by (Node release, configure flags, platform,
|
||||
# toolchain): a bridge-only change restores libnode from the cache and
|
||||
# rebuilds just the bridge in seconds.
|
||||
#
|
||||
# Linux packs build in the manylinux_2_28 images (AlmaLinux 8, glibc 2.28,
|
||||
# gcc-toolset-14), the glibc floor of Node's official Linux builds, on
|
||||
# native runners: Node disables its startup snapshot when cross-compiling.
|
||||
# Every pack is smoke-tested by a C program that loads it the way the
|
||||
# scriptc runtime does (packages/libnode-common/smoke/smoke.c).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "packages/libnode-*/**"
|
||||
- "packages/runtime/src/scr_node_bridge.h"
|
||||
- ".github/workflows/libnode-packs.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/libnode-*/**"
|
||||
- "packages/runtime/src/scr_node_bridge.h"
|
||||
- ".github/workflows/libnode-packs.yml"
|
||||
|
||||
concurrency:
|
||||
# A fixed name: under workflow_call, github.workflow is the caller's, and
|
||||
# sharing the caller's group would cancel the run as a deadlock.
|
||||
group: libnode-packs-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build libnode pack (${{ matrix.platform }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux-x64-gnu
|
||||
runner: ubuntu-24.04
|
||||
image: quay.io/pypa/manylinux_2_28_x86_64
|
||||
toolchain: manylinux_2_28-gcc-toolset-14
|
||||
- platform: linux-arm64-gnu
|
||||
runner: ubuntu-24.04-arm
|
||||
image: quay.io/pypa/manylinux_2_28_aarch64
|
||||
toolchain: manylinux_2_28-gcc-toolset-14
|
||||
- platform: darwin-arm64
|
||||
runner: macos-15
|
||||
toolchain: macos-15-xcode-clang
|
||||
- platform: darwin-x64
|
||||
runner: macos-15-intel
|
||||
toolchain: macos-15-xcode-clang
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# A cold Linux build is ~30 minutes on 8 cores; the 3- and 4-core macOS
|
||||
# runners are expected to take well over an hour. A cache hit is minutes.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
SCRIPTC_LIBNODE_TOOLCHAIN: ${{ matrix.toolchain }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Compute the libnode cache key
|
||||
id: key
|
||||
run: node packages/libnode-common/scripts/cache-key.mjs --platform ${{ matrix.platform }} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore the libnode build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .libnode-cache/${{ steps.key.outputs.path }}
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
|
||||
- name: Build the pack (Linux, manylinux_2_28)
|
||||
if: startsWith(matrix.platform, 'linux')
|
||||
run: |
|
||||
NODE_ROOT=$(dirname "$(dirname "$(command -v node)")")
|
||||
docker run --rm --volume "$GITHUB_WORKSPACE:/work" --volume "$NODE_ROOT:/node:ro" \
|
||||
--env SCRIPTC_LIBNODE_TOOLCHAIN --env SCRIPTC_LIBNODE_CACHE=/work/.libnode-cache \
|
||||
--env CC=gcc --env CXX=g++ \
|
||||
--env PATH=/opt/rh/gcc-toolset-14/root/usr/bin:/opt/python/cp312-cp312/bin:/node/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
--workdir /work ${{ matrix.image }} sh -c '
|
||||
set -eu
|
||||
dnf install --assumeyes --quiet zstd
|
||||
pip install --quiet ninja
|
||||
node packages/libnode-${{ matrix.platform }}/scripts/build.mjs --compress
|
||||
node packages/libnode-common/scripts/verify.mjs packages/libnode-${{ matrix.platform }}
|
||||
# The glibc 2.28 floor: load the pack where it was built.
|
||||
node packages/libnode-common/scripts/smoke.mjs --platform ${{ matrix.platform }}
|
||||
chown -R "$(stat -c %u:%g /work)" /work/packages /work/.libnode-cache
|
||||
'
|
||||
|
||||
- name: Build the pack (macOS)
|
||||
if: startsWith(matrix.platform, 'darwin')
|
||||
env:
|
||||
CC: clang
|
||||
CXX: clang++
|
||||
SCRIPTC_LIBNODE_CACHE: ${{ github.workspace }}/.libnode-cache
|
||||
run: |
|
||||
brew install ninja zstd
|
||||
node packages/libnode-${{ matrix.platform }}/scripts/build.mjs --compress
|
||||
node packages/libnode-common/scripts/verify.mjs packages/libnode-${{ matrix.platform }}
|
||||
|
||||
- name: Smoke-test the pack on the runner
|
||||
run: node packages/libnode-common/scripts/smoke.mjs --platform ${{ matrix.platform }}
|
||||
|
||||
- name: Report sizes
|
||||
run: |
|
||||
{
|
||||
echo "### @scriptc/libnode-${{ matrix.platform }} (Node ${{ steps.key.outputs.node }})"
|
||||
echo
|
||||
echo '```'
|
||||
ls -l packages/libnode-${{ matrix.platform }} .libnode-cache/compressed/${{ matrix.platform }}
|
||||
cat .libnode-cache/${{ steps.key.outputs.path }}/build.json
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload the pack
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: libnode-${{ matrix.platform }}
|
||||
path: |
|
||||
packages/libnode-${{ matrix.platform }}
|
||||
!packages/libnode-${{ matrix.platform }}/scripts
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload compressed libraries
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: libnode-${{ matrix.platform }}-zst
|
||||
path: .libnode-cache/compressed/${{ matrix.platform }}
|
||||
retention-days: 30
|
||||
@@ -33,6 +33,12 @@ output/
|
||||
/packages/runtime-*/artifacts/
|
||||
/packages/runtime-*/runtime-pack.json
|
||||
/packages/runtime-*/.runtime-pack-*
|
||||
# libnode packs: built by build:native, never committed
|
||||
/packages/libnode-*/libnode.so.*
|
||||
/packages/libnode-*/libnode.*.dylib
|
||||
/packages/libnode-*/libscr_node_bridge.*
|
||||
/packages/libnode-*/manifest.json
|
||||
/packages/libnode-*/LICENSE
|
||||
!tests/fixtures/npm/node_modules/*/dist/
|
||||
!tests/fixtures/npm/node_modules/@*/*/dist/
|
||||
!tests/fixtures/npm/workspace/*/dist/
|
||||
|
||||
@@ -25,3 +25,19 @@ Each published package must have a GitHub Actions trusted publisher for
|
||||
`release.yml` and the `Release` environment. A missing configuration fails
|
||||
before upload. Re-runs skip package versions already present on npm, so a
|
||||
partially published release can be resumed safely.
|
||||
|
||||
## libnode packs
|
||||
|
||||
The `@scriptc/libnode-<platform>` packs (linux-x64-gnu, linux-arm64-gnu, darwin-arm64, darwin-x64) carry the Node.js shared library that programs with Node islands load in process, plus the scriptc bridge compiled against it. They version in lockstep with the other platform packages (`sync-versions.mjs` stamps them); the Node release they contain is pinned separately in `packages/libnode-common/libnode.json`, which must match `.node-version` and `internal/compatibility/node-v24.json`. Each pack's `manifest.json` records the Node version, `NODE_MODULE_VERSION`, the bridge ABI and the size and SHA-256 of both libraries.
|
||||
|
||||
A Node `--shared` build takes about half an hour on 8 cores, so packs never build in `pnpm -r build`, the test suites or the release job. `.github/workflows/libnode-packs.yml` builds them on matching hosts when the pin, the bridge (`packages/libnode-common/bridge/`, `packages/runtime/src/scr_node_bridge.h`) or the pack packages change, or on manual dispatch. Linux packs build in the `manylinux_2_28` images (glibc 2.28, gcc-toolset-14) on native x64 and arm64 runners; Node disables its startup snapshot when cross-compiling, so arm64 is never cross-built. Each platform's libnode build is cached by Node release, configure flags, platform and toolchain; a bridge-only change restores libnode from the cache and rebuilds only the bridge. Every pack is smoke-tested by `packages/libnode-common/smoke/smoke.c`, which loads it the way the runtime does, and uploaded as the `libnode-<platform>` artifact, with zstd copies of both libraries as `libnode-<platform>-zst`. The Linux x64 and macOS test lanes of `ci.yml` restore the cached build for their platform through `.github/actions/libnode`, assemble the pack (`build.mjs --from-cache`, which never builds Node) and set `SCRIPTC_LIBNODE`, so the Node island tests run; without a cached build they fall back to the pinned development pack (`packages/libnode-common/dev-pack.json`). `packages/libnode-common/build-pack.mjs` assembles a pack from any matching libnode and headers, the bridge-only rebuild path.
|
||||
|
||||
To build one pack by hand on its own platform (it needs Python 3.9+, Ninja, a C++20 compiler and zstd; on Linux, build in a manylinux_2_28 container for a distributable pack):
|
||||
|
||||
```bash
|
||||
pnpm --filter @scriptc/libnode-linux-x64-gnu build:native
|
||||
node packages/libnode-common/scripts/verify.mjs packages/libnode-linux-x64-gnu
|
||||
node packages/libnode-common/scripts/smoke.mjs
|
||||
```
|
||||
|
||||
The packs are `private` until the in-process Node island runtime ships. Publishing them then means removing `private`, configuring their npm trusted publishers, and having the release job download the `libnode-<platform>` artifacts of the workflow run for the release commit (or call the workflow, which restores every platform from its cache) and publish them with the other platform packages.
|
||||
|
||||
@@ -70,6 +70,9 @@ async function tryFastPath(): Promise<number | null> {
|
||||
values["profile-generate"] !== undefined ||
|
||||
values["profile-use"] !== undefined ||
|
||||
values.sparse ||
|
||||
// A --libnode=beside build places the pack and the island modules next
|
||||
// to the executable, which a routed hit does not do.
|
||||
values.libnode === "beside" ||
|
||||
// The full compiler resolves `native` against the build host.
|
||||
values["target-cpu"] !== undefined ||
|
||||
(values["external-types"] ?? []).length > 0 ||
|
||||
@@ -167,7 +170,7 @@ async function tryFastPath(): Promise<number | null> {
|
||||
: startup.executableNativeEnvironmentFingerprint(),
|
||||
nodeVersion: process.version,
|
||||
// An entry of a program that calls into Node.js serves only the same pack.
|
||||
libnodeIdentity: () => startup.libnodeCacheKey(values.libnode),
|
||||
libnodeIdentity: () => startup.libnodeCacheKey(values.libnode, process.env, dirname(input)),
|
||||
});
|
||||
if (hit === null) return null;
|
||||
if (!values["keep-llvm"]) await rm(hit.llvmPath, { force: true });
|
||||
|
||||
@@ -123,6 +123,61 @@ test("bootstrap exact builds use the routed cache and source edits fall through"
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test.skipIf(!persistentExecutable)(
|
||||
"bootstrap leaves --libnode=beside builds to the full compiler",
|
||||
async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-bootstrap-beside-"));
|
||||
const entry = join(dir, "main.ts");
|
||||
const outPath = join(dir, "program");
|
||||
const preload = join(dir, "reject-full-compiler.mjs");
|
||||
const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") };
|
||||
delete env.SCRIPTC_NO_CACHE;
|
||||
const build = (args: string[], rejectFullCompiler = false) =>
|
||||
execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
...(rejectFullCompiler ? ["--import", preload] : []),
|
||||
bootstrap,
|
||||
"build",
|
||||
entry,
|
||||
"-o",
|
||||
outPath,
|
||||
...args,
|
||||
],
|
||||
{ env, maxBuffer: 4 * 1024 * 1024 },
|
||||
);
|
||||
try {
|
||||
await Promise.all([
|
||||
writeFile(entry, 'console.log("beside");\n'),
|
||||
writeFile(
|
||||
preload,
|
||||
[
|
||||
"import { registerHooks } from 'node:module';",
|
||||
"registerHooks({ load(url, context, nextLoad) {",
|
||||
" if (url.includes('/packages/compiler/dist/index.js')) throw new Error('compiler graph loaded');",
|
||||
" return nextLoad(url, context);",
|
||||
"}});",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
]);
|
||||
for (const args of [[], ["--libnode=beside"]]) {
|
||||
await build(args);
|
||||
await build(args);
|
||||
}
|
||||
await expect(build([], true)).resolves.toBeDefined();
|
||||
// A routed hit would skip placing the pack and the island modules.
|
||||
await expect(build(["--libnode=beside"], true)).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining("compiler graph loaded"),
|
||||
});
|
||||
expect((await execFileAsync(outPath)).stdout).toBe("beside\n");
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
|
||||
test.skipIf(!runtimePackHost)(
|
||||
"bootstrap cache hits reuse LLVM executables",
|
||||
async () => {
|
||||
|
||||
@@ -903,7 +903,7 @@ async function prepareExecutableInput(
|
||||
if (!moduleRuntimeFeatures(mod).nodeIsland) {
|
||||
delete mod.nodeIsland;
|
||||
} else if (outputKind === "exe") {
|
||||
const recorded = libnodeRecord(opts.libnode, opts.outPath);
|
||||
const recorded = libnodeRecord(opts.libnode, opts.outPath, process.env, dirname(entryPath));
|
||||
if (typeof recorded === "string")
|
||||
return { ok: false, diagnostics: [nodeIslandNodeDiag(recorded, entryPath)], sourceTexts };
|
||||
mod.nodeIsland = { ...mod.nodeIsland, ...recorded };
|
||||
@@ -1701,7 +1701,7 @@ async function compileTracked(
|
||||
...(opts.detachedExit === true ? { detachedExit: true as const } : {}),
|
||||
// Calls into Node record the libnode pack; their entries record its
|
||||
// identity (node-islands/libnode.ts) and a hit requires it unchanged.
|
||||
libnodeIdentity: () => libnodeCacheKey(opts.libnode),
|
||||
libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)),
|
||||
...targetCpuField(opts.targetCpu),
|
||||
...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}),
|
||||
...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }),
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { compilerReleaseVersion } from "../library/sidecar.js";
|
||||
import { NODE_BRIDGE_ABI, libnodeRecord } from "./libnode.js";
|
||||
import {
|
||||
LIBNODE_PACK_SCHEMA,
|
||||
LibnodePackError,
|
||||
findInstalledLibnodePack,
|
||||
hostLibnodeTarget,
|
||||
libnodePackPackage,
|
||||
parseLibnodeSetting,
|
||||
readLibnodePack,
|
||||
writeLibnodeNotices,
|
||||
} from "./libnode-pack.js";
|
||||
|
||||
const repo = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function temp(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "scriptc-libnode-pack-"));
|
||||
dirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function entry(bytes: string) {
|
||||
return {
|
||||
size: Buffer.byteLength(bytes),
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
/** A published-style pack with stand-in libraries; `edit` adjusts the
|
||||
* manifest build-pack.mjs would write. */
|
||||
function fakePack(
|
||||
dir: string,
|
||||
edit: (manifest: Record<string, unknown>) => void = () => {},
|
||||
name = "@scriptc/libnode-linux-x64-gnu",
|
||||
): string {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const libnode = "stand-in libnode";
|
||||
const bridge = "stand-in bridge";
|
||||
writeFileSync(join(dir, "libnode.so.137"), libnode);
|
||||
writeFileSync(join(dir, "libscr_node_bridge.so"), bridge);
|
||||
writeFileSync(join(dir, "LICENSE"), "Node.js is licensed for use as follows:\n\nMIT\n");
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name, version: compilerReleaseVersion() }),
|
||||
);
|
||||
const manifest: Record<string, unknown> = {
|
||||
schema: LIBNODE_PACK_SCHEMA,
|
||||
nodeVersion: "24.22.0",
|
||||
moduleVersion: 137,
|
||||
bridgeAbi: NODE_BRIDGE_ABI,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
libnode: "libnode.so.137",
|
||||
bridge: "libscr_node_bridge.so",
|
||||
package: name,
|
||||
version: compilerReleaseVersion(),
|
||||
target: "linux-x64-gnu",
|
||||
license: "LICENSE",
|
||||
files: { "libnode.so.137": entry(libnode), "libscr_node_bridge.so": entry(bridge) },
|
||||
};
|
||||
edit(manifest);
|
||||
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function thrown(run: () => unknown): LibnodePackError {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(LibnodePackError);
|
||||
return error as LibnodePackError;
|
||||
}
|
||||
throw new Error("expected a LibnodePackError");
|
||||
}
|
||||
|
||||
describe("the pack packages", () => {
|
||||
test("every libnode platform package is the pack of one native target", () => {
|
||||
const packages = readdirSync(join(repo, "packages"))
|
||||
.filter((name) => name.startsWith("libnode-") && name !== "libnode-common")
|
||||
.map(
|
||||
(name) =>
|
||||
JSON.parse(readFileSync(join(repo, "packages", name, "package.json"), "utf8")).name,
|
||||
)
|
||||
.sort();
|
||||
const targets = ["linux-x64-gnu", "linux-arm64-gnu", "macos-arm64", "macos-x64"];
|
||||
expect(targets.map((target) => libnodePackPackage(target)).sort()).toEqual(packages);
|
||||
expect(libnodePackPackage("linux-x64-musl")).toBeNull();
|
||||
expect(libnodePackPackage("windows-x64-msvc")).toBeNull();
|
||||
expect(libnodePackPackage("wasm32-wasi")).toBeNull();
|
||||
});
|
||||
|
||||
test("host targets map to native target names", () => {
|
||||
expect(hostLibnodeTarget("linux", "x64")).toBe("linux-x64-gnu");
|
||||
expect(hostLibnodeTarget("linux", "arm64")).toBe("linux-arm64-gnu");
|
||||
expect(hostLibnodeTarget("darwin", "arm64")).toBe("macos-arm64");
|
||||
expect(hostLibnodeTarget("darwin", "x64")).toBe("macos-x64");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLibnodeSetting", () => {
|
||||
test("accepts embedded, beside and absolute directories", () => {
|
||||
expect(parseLibnodeSetting("embedded")).toEqual({ kind: "embedded" });
|
||||
expect(parseLibnodeSetting("beside")).toEqual({ kind: "beside" });
|
||||
expect(parseLibnodeSetting("/opt/scriptc/libnode-24.22.0")).toEqual({
|
||||
kind: "path",
|
||||
path: "/opt/scriptc/libnode-24.22.0",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects relative paths and other values", () => {
|
||||
expect(parseLibnodeSetting("libnode")).toBe(
|
||||
'must be "embedded", "beside" or an absolute directory, not the relative path "libnode"',
|
||||
);
|
||||
expect(parseLibnodeSetting(true)).toBe('must be "embedded", "beside" or an absolute directory');
|
||||
});
|
||||
});
|
||||
|
||||
describe("finding the installed pack", () => {
|
||||
test("in the nearest node_modules above the project", () => {
|
||||
const root = temp();
|
||||
const pack = fakePack(join(root, "node_modules/@scriptc/libnode-linux-x64-gnu"));
|
||||
const project = join(root, "apps/web/src");
|
||||
mkdirSync(project, { recursive: true });
|
||||
expect(
|
||||
findInstalledLibnodePack({
|
||||
targetName: "linux-x64-gnu",
|
||||
projectDir: project,
|
||||
resolveInstalled: () => null,
|
||||
}),
|
||||
).toBe(pack);
|
||||
});
|
||||
|
||||
test("next to the compiler when the project has none", () => {
|
||||
const pack = fakePack(join(temp(), "libnode-darwin-arm64"));
|
||||
expect(
|
||||
findInstalledLibnodePack({
|
||||
targetName: "macos-arm64",
|
||||
projectDir: temp(),
|
||||
resolveInstalled: (name) => (name === "@scriptc/libnode-darwin-arm64" ? pack : null),
|
||||
}),
|
||||
).toBe(pack);
|
||||
expect(
|
||||
findInstalledLibnodePack({
|
||||
targetName: "macos-arm64",
|
||||
projectDir: temp(),
|
||||
resolveInstalled: () => null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("targets without packs have none", () => {
|
||||
expect(findInstalledLibnodePack({ targetName: "linux-x64-musl", projectDir: temp() })).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("libnodeRecord uses the installed pack when nothing names one", () => {
|
||||
const root = temp();
|
||||
const name = libnodePackPackage(hostLibnodeTarget());
|
||||
if (name === null) return;
|
||||
const pack = fakePack(join(root, "node_modules", name), () => {}, name);
|
||||
expect(libnodeRecord(undefined, join(root, "app"), {}, root)).toEqual({
|
||||
libnode: pack,
|
||||
nodeVersion: "24.22.0",
|
||||
});
|
||||
// SCRIPTC_LIBNODE still wins.
|
||||
const other = fakePack(join(temp(), "other"), (m) => {
|
||||
m["nodeVersion"] = "24.13.1";
|
||||
});
|
||||
expect(libnodeRecord(undefined, join(root, "app"), { SCRIPTC_LIBNODE: other }, root)).toEqual({
|
||||
libnode: other,
|
||||
nodeVersion: "24.13.1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("an installed pack of another scriptc version is refused", () => {
|
||||
const root = temp();
|
||||
const name = libnodePackPackage(hostLibnodeTarget());
|
||||
if (name === null) return;
|
||||
const pack = fakePack(
|
||||
join(root, "node_modules", name),
|
||||
(m) => {
|
||||
m["version"] = "0.0.1";
|
||||
},
|
||||
name,
|
||||
);
|
||||
expect(libnodeRecord(undefined, join(root, "app"), {}, root)).toBe(
|
||||
`the installed libnode pack ${name} at ${pack} is version 0.0.1; this scriptc is ${compilerReleaseVersion()}: install ${name}@${compilerReleaseVersion()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("an installed pack whose files do not match their digests is refused", () => {
|
||||
const root = temp();
|
||||
const name = libnodePackPackage(hostLibnodeTarget());
|
||||
if (name === null) return;
|
||||
const pack = fakePack(join(root, "node_modules", name), () => {}, name);
|
||||
// Same size, other bytes.
|
||||
writeFileSync(join(pack, "libnode.so.137"), "stand-in libnodX");
|
||||
expect(libnodeRecord(undefined, join(root, "app"), {}, root)).toBe(
|
||||
`libnode.so.137 in the libnode pack at ${pack} does not match its manifest`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("readLibnodePack", () => {
|
||||
test("reads a pack in build-pack.mjs's format and checks its package", () => {
|
||||
const pack = fakePack(join(temp(), "pack"));
|
||||
expect(
|
||||
readLibnodePack(pack, { packageName: "@scriptc/libnode-linux-x64-gnu", verifyHashes: true })
|
||||
.manifest.nodeVersion,
|
||||
).toBe("24.22.0");
|
||||
expect(
|
||||
thrown(() => readLibnodePack(pack, { packageName: "@scriptc/libnode-linux-arm64-gnu" }))
|
||||
.message,
|
||||
).toBe(
|
||||
`the libnode pack at ${pack} is @scriptc/libnode-linux-x64-gnu; this target needs @scriptc/libnode-linux-arm64-gnu`,
|
||||
);
|
||||
});
|
||||
|
||||
test("refuses other manifest schemas and file names outside the pack", () => {
|
||||
const schema = fakePack(join(temp(), "pack"), (m) => {
|
||||
m["schema"] = "scriptc.libnode-pack.v0";
|
||||
});
|
||||
expect(thrown(() => readLibnodePack(schema)).kind).toBe("invalid");
|
||||
for (const name of [
|
||||
"lib/libnode.so.137",
|
||||
"../libnode.so.137",
|
||||
"..",
|
||||
"/etc/libnode.so",
|
||||
"a\\b",
|
||||
"a\0b",
|
||||
]) {
|
||||
const pack = fakePack(join(temp(), "pack"), (m) => {
|
||||
m["libnode"] = name;
|
||||
m["files"] = { ...(m["files"] as object), [name]: entry("stand-in libnode") };
|
||||
});
|
||||
expect(thrown(() => readLibnodePack(pack)).message).toBe(
|
||||
`the libnode pack at ${pack} names a file outside the pack (${JSON.stringify(name)})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("checks sizes always and hashes on request", () => {
|
||||
const pack = fakePack(join(temp(), "pack"));
|
||||
writeFileSync(join(pack, "libnode.so.137"), "stand-in libnodE");
|
||||
expect(readLibnodePack(pack).root).toBe(pack);
|
||||
expect(thrown(() => readLibnodePack(pack, { verifyHashes: true })).message).toBe(
|
||||
`libnode.so.137 in the libnode pack at ${pack} does not match its manifest`,
|
||||
);
|
||||
writeFileSync(join(pack, "libnode.so.137"), "short");
|
||||
expect(thrown(() => readLibnodePack(pack)).kind).toBe("invalid");
|
||||
});
|
||||
|
||||
test("a directory without a manifest is missing", () => {
|
||||
const dir = temp();
|
||||
expect(thrown(() => readLibnodePack(dir)).message).toBe(
|
||||
`no libnode pack at ${dir} (manifest.json is missing or unreadable)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("the license notices carry Node's LICENSE", () => {
|
||||
const pack = readLibnodePack(fakePack(join(temp(), "pack")));
|
||||
const out = join(temp(), "server");
|
||||
const path = writeLibnodeNotices(pack, out);
|
||||
expect(path).toBe(`${out}.licenses.txt`);
|
||||
expect(readFileSync(path, "utf8")).toBe(
|
||||
[
|
||||
`server runs part of its code in Node.js 24.22.0, loaded from libnode.so.137 (@scriptc/libnode-linux-x64-gnu@${compilerReleaseVersion()}).`,
|
||||
"Node.js and the components it bundles are licensed as follows. The scriptc bridge,",
|
||||
"libscr_node_bridge.so, is licensed under Apache-2.0.",
|
||||
"",
|
||||
"Node.js is licensed for use as follows:",
|
||||
"",
|
||||
"MIT",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("beside refuses an installed pack whose manifest names a file outside it", () => {
|
||||
const root = temp();
|
||||
const name = libnodePackPackage(hostLibnodeTarget());
|
||||
if (name === null) return;
|
||||
const pack = fakePack(
|
||||
join(root, "node_modules", name),
|
||||
(m) => {
|
||||
m["files"] = { ...(m["files"] as object), "../escaped": entry("payload") };
|
||||
},
|
||||
name,
|
||||
);
|
||||
// The pack's "../escaped" and the file it would replace next to the output.
|
||||
writeFileSync(join(pack, "..", "escaped"), "payload");
|
||||
writeFileSync(join(root, "escaped"), "victim");
|
||||
expect(libnodeRecord("beside", join(root, "app"), {}, root)).toBe(
|
||||
`the libnode pack at ${pack} names a file outside the pack ("../escaped")`,
|
||||
);
|
||||
expect(readFileSync(join(root, "escaped"), "utf8")).toBe("victim");
|
||||
expect(existsSync(join(root, "app.libnode"))).toBe(false);
|
||||
});
|
||||
|
||||
test("readLibnodePack refuses any listed file or license outside the pack", () => {
|
||||
for (const edit of [
|
||||
(m: Record<string, unknown>) => {
|
||||
m["files"] = { ...(m["files"] as object), "../escaped": entry("payload") };
|
||||
},
|
||||
(m: Record<string, unknown>) => {
|
||||
m["files"] = { ...(m["files"] as object), "/etc/passwd": entry("payload") };
|
||||
},
|
||||
(m: Record<string, unknown>) => {
|
||||
m["license"] = "../../secret";
|
||||
},
|
||||
(m: Record<string, unknown>) => {
|
||||
m["license"] = 7;
|
||||
},
|
||||
]) {
|
||||
const pack = fakePack(join(temp(), "pack"), edit);
|
||||
expect(thrown(() => readLibnodePack(pack)).kind).toBe("invalid");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/* The installed libnode packs (scope-B design §2.11, §2.13, §2.16.3):
|
||||
* `@scriptc/libnode-<platform>`, built only by the libnode-packs workflow
|
||||
* from the pinned Node release (packages/libnode-common) and installed like
|
||||
* the runtime packs. A scriptc build never compiles Node.
|
||||
*
|
||||
* libnode.ts decides which pack a build records: --libnode, else
|
||||
* SCRIPTC_LIBNODE, else the installed pack this module finds: the nearest
|
||||
* `node_modules/@scriptc/libnode-<platform>` walking up from the project
|
||||
* directory, else the one next to the compiler. The manifest format is the
|
||||
* one packages/libnode-common/build-pack.mjs writes (schema, nodeVersion,
|
||||
* moduleVersion, bridgeAbi, platform, arch, libnode, bridge, files); the
|
||||
* published packs add package, version, target, license and build inputs.
|
||||
*
|
||||
* Also here: the `nodeIslands.libnode` setting ("embedded" | "beside" |
|
||||
* <absolute directory>) for the project configuration, and the license
|
||||
* notices a program that redistributes Node.js carries. */
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
export const LIBNODE_PACK_SCHEMA = "scriptc.libnode-pack.v1";
|
||||
|
||||
/** The pack package for each native target that can host Node islands. */
|
||||
const PACK_PACKAGES: Readonly<Record<string, string>> = {
|
||||
"linux-x64-gnu": "@scriptc/libnode-linux-x64-gnu",
|
||||
"linux-arm64-gnu": "@scriptc/libnode-linux-arm64-gnu",
|
||||
"macos-arm64": "@scriptc/libnode-darwin-arm64",
|
||||
"macos-x64": "@scriptc/libnode-darwin-x64",
|
||||
};
|
||||
|
||||
export type LibnodePlacement =
|
||||
| { kind: "embedded" }
|
||||
| { kind: "beside" }
|
||||
| { kind: "path"; path: string };
|
||||
|
||||
export interface LibnodeFile {
|
||||
size: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface LibnodePackManifest {
|
||||
schema: string;
|
||||
nodeVersion: string;
|
||||
moduleVersion: number;
|
||||
bridgeAbi: number;
|
||||
/** process.platform and process.arch of the pack. */
|
||||
platform: string;
|
||||
arch: string;
|
||||
/** File names inside the pack directory. */
|
||||
libnode: string;
|
||||
bridge: string;
|
||||
files: Record<string, LibnodeFile>;
|
||||
/** Published packs only. */
|
||||
package?: string;
|
||||
version?: string;
|
||||
target?: string;
|
||||
license?: string;
|
||||
}
|
||||
|
||||
export interface LibnodePack {
|
||||
root: string;
|
||||
manifest: LibnodePackManifest;
|
||||
}
|
||||
|
||||
export class LibnodePackError extends Error {
|
||||
readonly kind: "missing" | "invalid" | "unsupported";
|
||||
|
||||
constructor(message: string, kind: "missing" | "invalid" | "unsupported") {
|
||||
super(message);
|
||||
this.name = "LibnodePackError";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** The libnode pack package for a native target, or null when Node islands
|
||||
* have no pack there (musl, Windows, WASI and mobile targets). */
|
||||
export function libnodePackPackage(targetName: string): string | null {
|
||||
return PACK_PACKAGES[targetName] ?? null;
|
||||
}
|
||||
|
||||
/** The native target name of the host, as libnodePackPackage takes it. */
|
||||
export function hostLibnodeTarget(
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
return platform === "darwin" ? `macos-${arch}` : `${platform}-${arch}-gnu`;
|
||||
}
|
||||
|
||||
/** Validates a `nodeIslands.libnode` value; a string result is the reason
|
||||
* it is invalid. */
|
||||
export function parseLibnodeSetting(value: unknown): LibnodePlacement | string {
|
||||
if (value === "embedded") return { kind: "embedded" };
|
||||
if (value === "beside") return { kind: "beside" };
|
||||
if (typeof value === "string" && isAbsolute(value)) return { kind: "path", path: value };
|
||||
if (typeof value === "string") {
|
||||
return `must be "embedded", "beside" or an absolute directory, not the relative path "${value}"`;
|
||||
}
|
||||
return `must be "embedded", "beside" or an absolute directory`;
|
||||
}
|
||||
|
||||
/** Whether `name` names a file directly in the pack directory. */
|
||||
function packFileName(name: string): boolean {
|
||||
return name !== "" && name !== "." && name !== ".." && !/[/\\\0]/.test(name) && !isAbsolute(name);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFileEntry(value: unknown): value is LibnodeFile {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value["size"] === "number" &&
|
||||
typeof value["sha256"] === "string" &&
|
||||
/^[0-9a-f]{64}$/.test(value["sha256"])
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses a pack's manifest.json; throws LibnodePackError when it is not a
|
||||
* pack manifest. Bridge ABI and Node version policy stay in libnode.ts. */
|
||||
export function parseLibnodePackManifest(json: unknown, where: string): LibnodePackManifest {
|
||||
const invalid = (detail: string): never => {
|
||||
throw new LibnodePackError(`the libnode pack at ${where} ${detail}`, "invalid");
|
||||
};
|
||||
if (!isRecord(json)) return invalid("has a malformed manifest.json");
|
||||
if (json["schema"] !== LIBNODE_PACK_SCHEMA) {
|
||||
return invalid(
|
||||
`has manifest schema ${String(json["schema"])}, expected ${LIBNODE_PACK_SCHEMA}`,
|
||||
);
|
||||
}
|
||||
for (const field of ["nodeVersion", "platform", "arch", "libnode", "bridge"]) {
|
||||
if (typeof json[field] !== "string" || json[field] === "") {
|
||||
return invalid(`has no ${field} in manifest.json`);
|
||||
}
|
||||
}
|
||||
if (typeof json["moduleVersion"] !== "number" || typeof json["bridgeAbi"] !== "number") {
|
||||
return invalid("has no moduleVersion or bridgeAbi in manifest.json");
|
||||
}
|
||||
const files = json["files"];
|
||||
if (!isRecord(files)) return invalid("lists no files in manifest.json");
|
||||
// Every name the pack lists is a file directly in the pack directory.
|
||||
const license = json["license"];
|
||||
if (license !== undefined && typeof license !== "string") {
|
||||
return invalid("has a malformed license in manifest.json");
|
||||
}
|
||||
for (const name of [...Object.keys(files), json["license"] ?? "LICENSE"] as string[]) {
|
||||
if (!packFileName(name)) {
|
||||
return invalid(`names a file outside the pack (${JSON.stringify(name)})`);
|
||||
}
|
||||
}
|
||||
for (const name of [json["libnode"], json["bridge"]] as string[]) {
|
||||
if (!packFileName(name)) {
|
||||
return invalid(`names a file outside the pack (${JSON.stringify(name)})`);
|
||||
}
|
||||
if (!isFileEntry(files[name])) return invalid(`lists no size and SHA-256 for ${name}`);
|
||||
}
|
||||
return json as unknown as LibnodePackManifest;
|
||||
}
|
||||
|
||||
/** Reads and checks the pack in `root`: its manifest, its package when
|
||||
* `packageName` is given, and that each library exists with the recorded
|
||||
* size (and SHA-256 when `verifyHashes`). */
|
||||
export function readLibnodePack(
|
||||
root: string,
|
||||
options: { packageName?: string | undefined; verifyHashes?: boolean | undefined } = {},
|
||||
): LibnodePack {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8"));
|
||||
} catch {
|
||||
throw new LibnodePackError(
|
||||
`no libnode pack at ${root} (manifest.json is missing or unreadable)`,
|
||||
"missing",
|
||||
);
|
||||
}
|
||||
const manifest = parseLibnodePackManifest(json, root);
|
||||
if (options.packageName !== undefined && manifest.package !== options.packageName) {
|
||||
throw new LibnodePackError(
|
||||
`the libnode pack at ${root} is ${manifest.package ?? "unnamed"}; this target needs ${options.packageName}`,
|
||||
"invalid",
|
||||
);
|
||||
}
|
||||
for (const name of [manifest.libnode, manifest.bridge]) {
|
||||
const path = join(root, name);
|
||||
const entry = manifest.files[name]!;
|
||||
let size: number;
|
||||
try {
|
||||
size = statSync(path).size;
|
||||
} catch {
|
||||
throw new LibnodePackError(`the libnode pack at ${root} is missing ${name}`, "invalid");
|
||||
}
|
||||
const hashMismatch =
|
||||
options.verifyHashes === true &&
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex") !== entry.sha256;
|
||||
if (size !== entry.size || hashMismatch) {
|
||||
throw new LibnodePackError(
|
||||
`${name} in the libnode pack at ${root} does not match its manifest`,
|
||||
"invalid",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { root, manifest };
|
||||
}
|
||||
|
||||
/** The nearest `node_modules/<name>` directory with a built pack (a
|
||||
* manifest.json) walking up from `from`. */
|
||||
function findInstalled(name: string, from: string): string | null {
|
||||
let dir = resolve(from);
|
||||
for (;;) {
|
||||
const candidate = join(dir, "node_modules", name);
|
||||
if (existsSync(join(candidate, "manifest.json"))) return candidate;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveNextToCompiler(name: string): string | null {
|
||||
try {
|
||||
const dir = dirname(createRequire(import.meta.url).resolve(`${name}/package.json`));
|
||||
// An unbuilt workspace package is not a pack.
|
||||
return existsSync(join(dir, "manifest.json")) ? dir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The installed pack directory for `targetName`, or null when none is
|
||||
* installed or the target has no packs. */
|
||||
export function findInstalledLibnodePack(options: {
|
||||
targetName: string;
|
||||
projectDir: string;
|
||||
/** Test hook for the compiler-relative lookup. */
|
||||
resolveInstalled?: ((name: string) => string | null) | undefined;
|
||||
}): string | null {
|
||||
const packageName = libnodePackPackage(options.targetName);
|
||||
if (packageName === null) return null;
|
||||
return (
|
||||
findInstalled(packageName, options.projectDir) ??
|
||||
(options.resolveInstalled ?? resolveNextToCompiler)(packageName)
|
||||
);
|
||||
}
|
||||
|
||||
/** Writes the license notices a program that redistributes Node.js must
|
||||
* carry, `<output>.licenses.txt` (design §2.13), and returns its path. */
|
||||
export function writeLibnodeNotices(pack: LibnodePack, outPath: string): string {
|
||||
const { manifest } = pack;
|
||||
const nodeLicense = readFileSync(join(pack.root, manifest.license ?? "LICENSE"), "utf8");
|
||||
const origin =
|
||||
manifest.package !== undefined && manifest.version !== undefined
|
||||
? ` (${manifest.package}@${manifest.version})`
|
||||
: "";
|
||||
const path = `${outPath}.licenses.txt`;
|
||||
writeFileSync(
|
||||
path,
|
||||
[
|
||||
`${basename(outPath)} runs part of its code in Node.js ${manifest.nodeVersion}, loaded from ${manifest.libnode}${origin}.`,
|
||||
`Node.js and the components it bundles are licensed as follows. The scriptc bridge,`,
|
||||
`${manifest.bridge}, is licensed under Apache-2.0.`,
|
||||
"",
|
||||
nodeLicense.trimEnd(),
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return path;
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { USES_TIMERS_LIB_FNS } from "../backend/llvm/lib-shared.js";
|
||||
import { libnodeCacheKey, libnodeRecord, NODE_BRIDGE_ABI } from "./libnode.js";
|
||||
import { libnodeCacheKey, libnodeRecord, libnodeTarget, NODE_BRIDGE_ABI } from "./libnode.js";
|
||||
import { libnodePackPackage } from "./libnode-pack.js";
|
||||
import { findInstalledLibnodePack, hostLibnodeTarget } from "./libnode-pack.js";
|
||||
|
||||
function pack(nodeVersion: string, bridgeAbi = NODE_BRIDGE_ABI): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "scriptc-libnode-"));
|
||||
@@ -39,7 +41,21 @@ describe("the recorded libnode pack", () => {
|
||||
});
|
||||
|
||||
test("refuses a missing, too old or incompatible pack", () => {
|
||||
expect(libnodeRecord(undefined, "/out/app", {})).toMatch(/no libnode pack is installed/);
|
||||
// Unless a workspace build of the host's @scriptc/libnode-<platform>
|
||||
// resolves next to the compiler, which libnodeRecord then uses.
|
||||
const installed = findInstalledLibnodePack({
|
||||
targetName: hostLibnodeTarget(),
|
||||
projectDir: "/nonexistent",
|
||||
});
|
||||
if (installed === null) {
|
||||
expect(libnodeRecord(undefined, "/out/app", {}, "/nonexistent")).toMatch(
|
||||
/no libnode pack is installed/,
|
||||
);
|
||||
} else {
|
||||
expect(libnodeRecord(undefined, "/out/app", {}, "/nonexistent")).toMatchObject({
|
||||
libnode: installed,
|
||||
});
|
||||
}
|
||||
expect(libnodeRecord("/nonexistent", "/out/app", {})).toBe(
|
||||
"no libnode pack at /nonexistent (manifest.json is missing or unreadable)",
|
||||
);
|
||||
@@ -143,9 +159,36 @@ describe("the recorded libnode pack", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the libnode pack in an executable's cache key", () => {
|
||||
test("is absent without --libnode or SCRIPTC_LIBNODE", () => {
|
||||
expect(libnodeCacheKey(undefined, {})).toBeUndefined();
|
||||
describe("the libnode pack identity a cached build keeps", () => {
|
||||
// A workspace build of the target's @scriptc/libnode-<platform> that
|
||||
// resolves next to the compiler (CI assembles one) is installed for every
|
||||
// project, so without a pack in the project the identity names it.
|
||||
const nextToCompiler = findInstalledLibnodePack({
|
||||
targetName: libnodeTarget({}),
|
||||
projectDir: "/nonexistent",
|
||||
});
|
||||
const withoutProjectPack = (key: string | undefined): void => {
|
||||
if (nextToCompiler === null) expect(key).toBeUndefined();
|
||||
else expect(key).toContain(`|${nextToCompiler}|`);
|
||||
};
|
||||
|
||||
test("is absent when no pack is named or installed", () => {
|
||||
withoutProjectPack(libnodeCacheKey(undefined, {}, "/nonexistent"));
|
||||
});
|
||||
|
||||
test("names the installed pack, and changes with it", () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "scriptc-libnode-project-"));
|
||||
const name = libnodePackPackage(libnodeTarget({}));
|
||||
if (name === null) return;
|
||||
const installed = join(project, "node_modules", name);
|
||||
mkdirSync(installed, { recursive: true });
|
||||
writeFileSync(join(installed, "manifest.json"), JSON.stringify({ nodeVersion: "24.22.0" }));
|
||||
const before = libnodeCacheKey(undefined, {}, project);
|
||||
expect(before).toContain(installed);
|
||||
writeFileSync(join(installed, "manifest.json"), JSON.stringify({ nodeVersion: "24.23.0" }));
|
||||
expect(libnodeCacheKey(undefined, {}, project)).not.toBe(before);
|
||||
rmSync(installed, { recursive: true, force: true });
|
||||
withoutProjectPack(libnodeCacheKey(undefined, {}, project));
|
||||
});
|
||||
|
||||
test("changes when the pack in the same directory is upgraded", () => {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* SCRIPTC_LIBNODE overrides the recorded directory at run time.
|
||||
*
|
||||
* Where the build finds the pack: `--libnode=<pack directory>`, else
|
||||
* SCRIPTC_LIBNODE in the build's environment (until NI-13 publishes
|
||||
* @scriptc/libnode-<platform> packages that an installation carries).
|
||||
* SCRIPTC_LIBNODE in the build's environment, else the installed
|
||||
* @scriptc/libnode-<platform> pack (libnode-pack.ts).
|
||||
* `--libnode=beside` takes that pack and copies it next to the executable
|
||||
* as `<output>.libnode/`, recorded as `$ORIGIN/<output>.libnode`, so the
|
||||
* executable and its Node travel together; a recorded `$ORIGIN/` path is
|
||||
@@ -24,6 +24,16 @@ import {
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { compilerReleaseVersion } from "../library/sidecar.js";
|
||||
import { nativeCodegenTarget } from "../backend/targets.js";
|
||||
import {
|
||||
findInstalledLibnodePack,
|
||||
hostLibnodeTarget,
|
||||
LibnodePackError,
|
||||
libnodePackPackage,
|
||||
readLibnodePack,
|
||||
type LibnodePack,
|
||||
} from "./libnode-pack.js";
|
||||
|
||||
/** The oldest Node.js release Node islands support: synchronous
|
||||
* module.registerHooks, which shared modules need, is a release candidate
|
||||
@@ -131,18 +141,56 @@ function realpathOr(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The native target whose libnode pack a build records: the codegen
|
||||
* target (SCRIPTC_TARGET, and the host's libc), else the host. */
|
||||
export function libnodeTarget(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return nativeCodegenTarget(env)?.name ?? hostLibnodeTarget();
|
||||
}
|
||||
|
||||
/** Why the installed pack in `dir` cannot serve this compiler, or null: it
|
||||
* must be the target's package at the compiler's own version. */
|
||||
function installedPackProblem(dir: string, packageName: string): string | null {
|
||||
let pack: LibnodePack;
|
||||
try {
|
||||
// An installed pack is found, not named: check its files' digests too.
|
||||
pack = readLibnodePack(dir, { packageName, verifyHashes: true });
|
||||
} catch (error) {
|
||||
return error instanceof LibnodePackError ? error.message : String(error);
|
||||
}
|
||||
const version = compilerReleaseVersion();
|
||||
if (pack.manifest.version !== version)
|
||||
return `the installed libnode pack ${packageName} at ${dir} is version ${pack.manifest.version ?? "unknown"}; this scriptc is ${version}: install ${packageName}@${version}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** What to record for a program that calls into Node, or the reason the
|
||||
* build cannot (an SC8005 message). `option` is --libnode. */
|
||||
export function libnodeRecord(
|
||||
option: string | undefined,
|
||||
outPath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
projectDir: string = process.cwd(),
|
||||
): LibnodeRecord | string {
|
||||
const fromEnv = env["SCRIPTC_LIBNODE"];
|
||||
const beside = option === "beside";
|
||||
const source = option !== undefined && !beside ? option : fromEnv;
|
||||
if (source === undefined || source === "")
|
||||
return "this program calls into Node.js (@scriptc/node), but no libnode pack is installed: set SCRIPTC_LIBNODE or pass --libnode=<pack directory>";
|
||||
const explicit = option !== undefined && !beside ? option : fromEnv;
|
||||
const target = libnodeTarget(env);
|
||||
// Without an explicit pack, the installed @scriptc/libnode-<target>.
|
||||
const installed =
|
||||
explicit !== undefined && explicit !== ""
|
||||
? null
|
||||
: findInstalledLibnodePack({ targetName: target, projectDir });
|
||||
const source = installed ?? explicit;
|
||||
if (source === undefined || source === null || source === "") {
|
||||
const pack = libnodePackPackage(target);
|
||||
return pack === null
|
||||
? `this program calls into Node.js (@scriptc/node), but there is no libnode pack for ${target}: set SCRIPTC_LIBNODE or pass --libnode=<pack directory>`
|
||||
: `this program calls into Node.js (@scriptc/node), but no libnode pack is installed: install ${pack}, set SCRIPTC_LIBNODE or pass --libnode=<pack directory>`;
|
||||
}
|
||||
if (installed !== null) {
|
||||
const problem = installedPackProblem(installed, libnodePackPackage(target)!);
|
||||
if (problem !== null) return problem;
|
||||
}
|
||||
if (source.startsWith(ORIGIN)) {
|
||||
// A pack the deployment places next to the executable: nothing to read.
|
||||
return { libnode: source, nodeVersion: "" };
|
||||
@@ -159,18 +207,23 @@ export function libnodeRecord(
|
||||
|
||||
/** The identity of the libnode pack a build that calls into Node.js
|
||||
* records, which the program's executable cache entry keeps
|
||||
* (EarlyExecutableCacheOptions.libnodeIdentity), or undefined when neither
|
||||
* --libnode nor SCRIPTC_LIBNODE is set. It holds the option, the pack
|
||||
* directory and a digest of the pack's manifest.json, so that a pack
|
||||
* upgraded in place (another Node version, bridge ABI or file list)
|
||||
* invalidates the entry instead of restoring its old record. */
|
||||
* (EarlyExecutableCacheOptions.libnodeIdentity): the option, the pack
|
||||
* directory (named by --libnode or SCRIPTC_LIBNODE, or the installed pack
|
||||
* found from `projectDir`), and a digest of the pack's manifest.json. A pack
|
||||
* upgraded in place, removed, or replaced by another installed pack changes
|
||||
* it. Undefined when no pack is named or installed. */
|
||||
export function libnodeCacheKey(
|
||||
option: string | undefined,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
projectDir: string = process.cwd(),
|
||||
): string | undefined {
|
||||
const fromEnv = env["SCRIPTC_LIBNODE"];
|
||||
if (option === undefined && fromEnv === undefined) return undefined;
|
||||
const source = option !== undefined && option !== "beside" ? option : (fromEnv ?? "");
|
||||
const explicit = option !== undefined && option !== "beside" ? option : fromEnv;
|
||||
const source =
|
||||
explicit !== undefined && explicit !== ""
|
||||
? explicit
|
||||
: findInstalledLibnodePack({ targetName: libnodeTarget(env), projectDir });
|
||||
if (source === undefined || source === null) return option === undefined ? undefined : option;
|
||||
let manifest = "none";
|
||||
if (isAbsolute(source)) {
|
||||
try {
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6822-node-island-gc/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6823-node-island-nested-stack/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
/* Assembles a libnode pack: the directory a scriptc program with Node
|
||||
* islands loads Node from (scope-B design §2.16.3). Until the pack CI
|
||||
* matrix (NI-13) publishes @scriptc/libnode-<platform> packages, packs are
|
||||
* assembled by hand from a Node `--shared` build and the matching headers:
|
||||
* islands loads Node from (scope-B design §2.16.3), from a Node `--shared`
|
||||
* build and the headers of the same release:
|
||||
*
|
||||
* node packages/libnode-common/build-pack.mjs \
|
||||
* --libnode <path to libnode.so.<ABI> or libnode.<ABI>.dylib> \
|
||||
@@ -10,21 +9,32 @@
|
||||
* --out <pack directory> [--license <Node's LICENSE>] [--cxx <compiler>]
|
||||
*
|
||||
* The pack holds libnode, the bridge (bridge/scr_node_bridge.cc) compiled
|
||||
* against those headers, Node's license and manifest.json, which the build
|
||||
* of a program with Node islands reads (packages/compiler/src/node-islands/
|
||||
* libnode.ts). Programs find a pack through SCRIPTC_LIBNODE or --libnode. */
|
||||
* against those headers and linked to libnode through $ORIGIN /
|
||||
* @loader_path (so the directory can move), Node's license and
|
||||
* manifest.json, which the build of a program with Node islands reads
|
||||
* (packages/compiler/src/node-islands/libnode.ts). Programs find a pack
|
||||
* through --libnode, SCRIPTC_LIBNODE, or the installed
|
||||
* @scriptc/libnode-<platform> package.
|
||||
*
|
||||
* The @scriptc/libnode-<platform> packs call buildPack() from
|
||||
* scripts/build.mjs after building libnode from the pinned release; run by
|
||||
* hand, this is the bridge-only rebuild path (seconds) and the way a
|
||||
* self-builder pairs the bridge with a compatible Node build of their own. */
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const here = fileURLToPath(new URL(".", import.meta.url));
|
||||
const runtimeSrc = join(here, "../runtime/src");
|
||||
|
||||
/** The manifest schema; the compiler's pack reader accepts exactly this. */
|
||||
export const LIBNODE_PACK_SCHEMA = "scriptc.libnode-pack.v1";
|
||||
|
||||
/** The bridge ABI the runtime expects (scr_node_bridge.h). */
|
||||
function bridgeAbi() {
|
||||
export function bridgeAbi() {
|
||||
const header = readFileSync(join(runtimeSrc, "scr_node_bridge.h"), "utf8");
|
||||
return Number(/#define SCR_NODE_BRIDGE_ABI (\d+)u/.exec(header)[1]);
|
||||
}
|
||||
@@ -46,38 +56,41 @@ function fileEntry(path) {
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
libnode: { type: "string" },
|
||||
headers: { type: "string" },
|
||||
out: { type: "string" },
|
||||
license: { type: "string" },
|
||||
cxx: { type: "string", default: process.platform === "darwin" ? "clang++" : "g++" },
|
||||
},
|
||||
});
|
||||
if (!values.libnode || !values.headers || !values.out) {
|
||||
console.error("usage: build-pack.mjs --libnode <lib> --headers <include/node> --out <dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
const out = resolve(values.out);
|
||||
const headers = resolve(values.headers);
|
||||
mkdirSync(out, { recursive: true });
|
||||
const libnodeName = basename(values.libnode);
|
||||
copyFileSync(values.libnode, join(out, libnodeName));
|
||||
const bridgeName =
|
||||
process.platform === "darwin" ? "libscr_node_bridge.dylib" : "libscr_node_bridge.so";
|
||||
const link =
|
||||
process.platform === "darwin"
|
||||
? ["-dynamiclib", join(out, libnodeName), "-Wl,-rpath,@loader_path"]
|
||||
: ["-shared", `-L${out}`, `-l:${libnodeName}`, "-Wl,-rpath,$ORIGIN"];
|
||||
/** The bridge library name on a platform. */
|
||||
export function bridgeName(os = process.platform) {
|
||||
return os === "darwin" ? "libscr_node_bridge.dylib" : "libscr_node_bridge.so";
|
||||
}
|
||||
|
||||
/** Compiles the bridge against `headers` (an include/node directory) and
|
||||
* links it to `libnode`, which must already sit in the bridge's directory. */
|
||||
export function buildBridge({ headers, libnode, out, cxx }) {
|
||||
const darwin = process.platform === "darwin";
|
||||
const compiler = cxx ?? process.env.CXX ?? (darwin ? "clang++" : "g++");
|
||||
const link = darwin
|
||||
? [
|
||||
"-dynamiclib",
|
||||
libnode,
|
||||
"-Wl,-rpath,@loader_path",
|
||||
`-Wl,-install_name,@rpath/${basename(out)}`,
|
||||
]
|
||||
: [
|
||||
"-shared",
|
||||
`-L${dirname(libnode)}`,
|
||||
`-l:${basename(libnode)}`,
|
||||
"-Wl,-rpath,$ORIGIN",
|
||||
"-Wl,-z,defs",
|
||||
"-Wl,--as-needed",
|
||||
"-ldl",
|
||||
"-pthread",
|
||||
];
|
||||
execFileSync(
|
||||
values.cxx,
|
||||
compiler,
|
||||
[
|
||||
"-std=c++20",
|
||||
"-O2",
|
||||
"-fPIC",
|
||||
"-fvisibility=hidden",
|
||||
"-fno-rtti",
|
||||
"-I",
|
||||
runtimeSrc,
|
||||
"-I",
|
||||
@@ -85,24 +98,79 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
join(here, "bridge/scr_node_bridge.cc"),
|
||||
...link,
|
||||
"-o",
|
||||
join(out, bridgeName),
|
||||
out,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (values.license) copyFileSync(values.license, join(out, "LICENSE"));
|
||||
if (darwin) {
|
||||
execFileSync("strip", ["-x", out], { stdio: "inherit" });
|
||||
// Stripping invalidated the linker's ad-hoc signature, which arm64
|
||||
// macOS requires to map the library.
|
||||
execFileSync("codesign", ["--force", "--sign", "-", out], { stdio: "inherit" });
|
||||
} else {
|
||||
execFileSync("strip", ["--strip-unneeded", out], { stdio: "inherit" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the pack in `out` and returns its manifest. `extra` adds fields
|
||||
* to manifest.json (the published packs record their package, target and
|
||||
* build inputs); schema, nodeVersion, moduleVersion, bridgeAbi, platform,
|
||||
* arch, libnode, bridge and files are always present.
|
||||
*
|
||||
* @param {{ libnode: string, headers: string, out: string, license?: string,
|
||||
* cxx?: string, extra?: Record<string, unknown> }} options
|
||||
*/
|
||||
export function buildPack(options) {
|
||||
const out = resolve(options.out);
|
||||
const headers = resolve(options.headers);
|
||||
mkdirSync(out, { recursive: true });
|
||||
const libnodeName = basename(options.libnode);
|
||||
const libnode = join(out, libnodeName);
|
||||
if (resolve(options.libnode) !== libnode) copyFileSync(options.libnode, libnode);
|
||||
const bridge = join(out, bridgeName());
|
||||
buildBridge({ headers, libnode, out: bridge, cxx: options.cxx });
|
||||
if (options.license) copyFileSync(options.license, join(out, "LICENSE"));
|
||||
const { nodeVersion, moduleVersion } = nodeVersionOf(headers);
|
||||
const manifest = {
|
||||
schema: "scriptc.libnode-pack.v1",
|
||||
schema: LIBNODE_PACK_SCHEMA,
|
||||
nodeVersion,
|
||||
moduleVersion,
|
||||
bridgeAbi: bridgeAbi(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
libnode: libnodeName,
|
||||
bridge: bridgeName(),
|
||||
...options.extra,
|
||||
files: {
|
||||
[libnodeName]: fileEntry(join(out, libnodeName)),
|
||||
[bridgeName]: fileEntry(join(out, bridgeName)),
|
||||
[libnodeName]: fileEntry(libnode),
|
||||
[bridgeName()]: fileEntry(bridge),
|
||||
},
|
||||
};
|
||||
writeFileSync(join(out, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`libnode pack for Node ${nodeVersion} in ${out}`);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
libnode: { type: "string" },
|
||||
headers: { type: "string" },
|
||||
out: { type: "string" },
|
||||
license: { type: "string" },
|
||||
cxx: { type: "string" },
|
||||
},
|
||||
});
|
||||
if (!values.libnode || !values.headers || !values.out) {
|
||||
console.error("usage: build-pack.mjs --libnode <lib> --headers <include/node> --out <dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
const manifest = buildPack({
|
||||
libnode: values.libnode,
|
||||
headers: values.headers,
|
||||
out: values.out,
|
||||
license: values.license,
|
||||
cxx: values.cxx,
|
||||
});
|
||||
console.log(`libnode pack for Node ${manifest.nodeVersion} in ${resolve(values.out)}`);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
/* Assembles a development libnode pack from the pinned, checksum-verified
|
||||
* inputs in dev-pack.json: a prebuilt libnode of the repository's Node
|
||||
* release and Node's official headers, with the bridge built against them
|
||||
* by build-pack.mjs. CI's Linux test lanes use it (.github/actions/
|
||||
* libnode-dev-pack) until the NI-13 packs are published.
|
||||
* by build-pack.mjs. CI's Linux x64 and macOS test lanes fall back to it
|
||||
* (.github/actions/libnode) until the libnode-packs workflow has cached a
|
||||
* build of @scriptc/libnode-<platform> for their ref.
|
||||
*
|
||||
* node packages/libnode-common/fetch-dev-pack.mjs --out <pack directory> */
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"nodeVersion": "24.22.0",
|
||||
"tarballSha256": "41f97cff2c666f1f8293dc5f85c65e8dd605c16b43fd9febd49ede32ee1e0329",
|
||||
"moduleVersion": 137,
|
||||
"configureFlags": ["--shared", "--ninja"],
|
||||
"icu": "full"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/libnode-common",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds one libnode pack into its package directory:
|
||||
//
|
||||
// 1. libnode: the pinned Node.js release (libnode.json) from its official
|
||||
// source tarball, SHA-256 verified, configured `--shared` with full ICU,
|
||||
// only the `libnode` target built, stripped, staged with its headers
|
||||
// and LICENSE in a cache directory keyed by libnodeCacheKey(). A cache
|
||||
// hit skips the Node build entirely (about 26 minutes on 8 cores).
|
||||
// 2. the bridge, compiled against those headers (seconds, every run);
|
||||
// 3. the pack: libnode, the bridge, Node's LICENSE and manifest.json in
|
||||
// packages/libnode-<platform>/, plus `--compress` copies (.zst) of both
|
||||
// libraries in <cache>/compressed/ for size reports and embedding.
|
||||
//
|
||||
// This never runs in `pnpm -r build` or the test suites: each platform
|
||||
// package runs it through its explicit `build:native` script, and the
|
||||
// libnode-packs workflow runs that on matching hosts.
|
||||
//
|
||||
// Environment:
|
||||
// SCRIPTC_LIBNODE_CACHE cache root (default node_modules/.cache/scriptc-libnode)
|
||||
// SCRIPTC_LIBNODE_SOURCE an extracted source tree of the pinned release to
|
||||
// build in (seeds the cache from an earlier build tree)
|
||||
// SCRIPTC_LIBNODE_TOOLCHAIN a name for the toolchain, part of the cache key
|
||||
// (CI: "manylinux_2_28-gcc14", "macos-15-clang")
|
||||
// SCRIPTC_LIBNODE_JOBS ninja parallelism (default: available cores)
|
||||
// CC, CXX the compilers for Node and the bridge
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { availableParallelism } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { buildPack } from "../build-pack.mjs";
|
||||
import {
|
||||
bridgeAbi,
|
||||
bridgeHash,
|
||||
libnodeCacheKey,
|
||||
packFileNames,
|
||||
platformInfo,
|
||||
readPin,
|
||||
repoRoot,
|
||||
} from "./pin.mjs";
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`libnode: ${message}\n`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
log(`$ ${command} ${args.join(" ")}`);
|
||||
execFileSync(command, args, { stdio: "inherit", ...options });
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
/** Downloads and verifies the pinned source tarball, then extracts it. */
|
||||
async function fetchSource(cacheRoot, pin) {
|
||||
const name = `node-v${pin.nodeVersion}`;
|
||||
const tree = join(cacheRoot, "src", name);
|
||||
if (existsSync(join(tree, "configure.py"))) return tree;
|
||||
const tarball = join(cacheRoot, "src", `${name}.tar.xz`);
|
||||
mkdirSync(join(cacheRoot, "src"), { recursive: true });
|
||||
if (!existsSync(tarball) || (await sha256File(tarball)) !== pin.tarballSha256) {
|
||||
const url = `https://nodejs.org/dist/v${pin.nodeVersion}/${name}.tar.xz`;
|
||||
run("curl", ["-fsSL", "--retry", "3", "-o", `${tarball}.part`, url]);
|
||||
renameSync(`${tarball}.part`, tarball);
|
||||
}
|
||||
const actual = await sha256File(tarball);
|
||||
if (actual !== pin.tarballSha256) {
|
||||
rmSync(tarball, { force: true });
|
||||
throw new Error(`${name}.tar.xz has SHA-256 ${actual}, expected ${pin.tarballSha256}`);
|
||||
}
|
||||
rmSync(tree, { recursive: true, force: true });
|
||||
run("tar", ["-xJf", tarball, "-C", join(cacheRoot, "src")]);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/** The version a source tree's node_version.h declares. */
|
||||
function sourceVersion(tree) {
|
||||
const text = readFileSync(join(tree, "src", "node_version.h"), "utf8");
|
||||
const define = (name) => new RegExp(`#define ${name} (\\d+)`).exec(text)?.[1];
|
||||
return `${define("NODE_MAJOR_VERSION")}.${define("NODE_MINOR_VERSION")}.${define("NODE_PATCH_VERSION")}`;
|
||||
}
|
||||
|
||||
function moduleVersionOf(headers) {
|
||||
const text = readFileSync(join(headers, "include", "node", "node_version.h"), "utf8");
|
||||
return Number(/#define NODE_MODULE_VERSION (\d+)/.exec(text)?.[1]);
|
||||
}
|
||||
|
||||
/** The highest GLIBC_x.y symbol version a Linux library needs. */
|
||||
function minimumGlibc(library) {
|
||||
const dynamic = execFileSync("objdump", ["-T", library], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
const versions = [...new Set(dynamic.match(/GLIBC_\d+(\.\d+)+/g) ?? [])].map((v) =>
|
||||
v.slice("GLIBC_".length),
|
||||
);
|
||||
versions.sort((a, b) => {
|
||||
const x = a.split(".").map(Number);
|
||||
const y = b.split(".").map(Number);
|
||||
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
||||
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return versions.at(-1) ?? null;
|
||||
}
|
||||
|
||||
/** Configures and builds libnode in `tree`, then stages it into `stage`. */
|
||||
function buildLibnode(tree, stage, platform, pin) {
|
||||
const { os } = platformInfo(platform);
|
||||
if (sourceVersion(tree) !== pin.nodeVersion) {
|
||||
throw new Error(`${tree} is Node ${sourceVersion(tree)}, the pin is ${pin.nodeVersion}`);
|
||||
}
|
||||
const python = process.env.PYTHON ?? "python3";
|
||||
// An earlier tree configured with exactly these flags is reused as is, so
|
||||
// a seeded build tree is a ninja no-op instead of a reconfigure.
|
||||
const status = join(tree, "config.status");
|
||||
const configured =
|
||||
existsSync(status) &&
|
||||
existsSync(join(tree, "out", "Release", "build.ninja")) &&
|
||||
readFileSync(status, "utf8")
|
||||
.trimEnd()
|
||||
.endsWith(`configure ${pin.configureFlags.join(" ")}`);
|
||||
const started = Date.now();
|
||||
if (!configured) run("./configure", pin.configureFlags, { cwd: tree });
|
||||
const gypi = readFileSync(join(tree, "config.gypi"), "utf8");
|
||||
for (const [setting, expected] of [
|
||||
["node_shared", "true"],
|
||||
["node_use_node_snapshot", "true"],
|
||||
["icu_small", pin.icu === "small" ? "true" : "false"],
|
||||
]) {
|
||||
if (!gypi.includes(`"${setting}": "${expected}"`)) {
|
||||
throw new Error(`config.gypi does not have ${setting}: ${expected}`);
|
||||
}
|
||||
}
|
||||
const jobs = process.env.SCRIPTC_LIBNODE_JOBS ?? String(availableParallelism());
|
||||
run("ninja", ["-C", join(tree, "out", "Release"), "-j", jobs, "libnode"]);
|
||||
const buildSeconds = Math.round((Date.now() - started) / 1000);
|
||||
|
||||
const names = packFileNames(platform, pin.moduleVersion);
|
||||
const built = [
|
||||
join(tree, "out", "Release", "lib", names.libnode),
|
||||
join(tree, "out", "Release", names.libnode),
|
||||
].find((path) => existsSync(path));
|
||||
if (built === undefined) throw new Error(`the Node build did not produce ${names.libnode}`);
|
||||
const partial = `${stage}.partial`;
|
||||
rmSync(partial, { recursive: true, force: true });
|
||||
mkdirSync(join(partial, "lib"), { recursive: true });
|
||||
const library = join(partial, "lib", names.libnode);
|
||||
if (os === "darwin") {
|
||||
copyFileSync(built, library);
|
||||
run("install_name_tool", ["-id", `@rpath/${names.libnode}`, library]);
|
||||
// -x keeps the global symbols addons and the bridge bind to.
|
||||
run("strip", ["-x", library]);
|
||||
// Editing the library invalidated the linker's ad-hoc signature, which
|
||||
// arm64 macOS requires to map it.
|
||||
run("codesign", ["--force", "--sign", "-", library]);
|
||||
} else {
|
||||
run("strip", ["--strip-unneeded", "-o", library, built]);
|
||||
}
|
||||
run(
|
||||
python,
|
||||
[
|
||||
join(tree, "tools", "install.py"),
|
||||
"install",
|
||||
"--headers-only",
|
||||
"--silent",
|
||||
"--root-dir",
|
||||
tree,
|
||||
"--dest-dir",
|
||||
join(partial, "headers"),
|
||||
"--prefix",
|
||||
"/",
|
||||
],
|
||||
{ cwd: tree },
|
||||
);
|
||||
const moduleVersion = moduleVersionOf(join(partial, "headers"));
|
||||
if (moduleVersion !== pin.moduleVersion) {
|
||||
throw new Error(
|
||||
`Node ${pin.nodeVersion} has NODE_MODULE_VERSION ${moduleVersion}, the pin says ${pin.moduleVersion}`,
|
||||
);
|
||||
}
|
||||
copyFileSync(join(tree, "LICENSE"), join(partial, "LICENSE"));
|
||||
writeFileSync(
|
||||
join(partial, "build.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
nodeVersion: pin.nodeVersion,
|
||||
moduleVersion,
|
||||
configureFlags: pin.configureFlags,
|
||||
icu: pin.icu,
|
||||
toolchain: process.env.SCRIPTC_LIBNODE_TOOLCHAIN ?? "default",
|
||||
buildSeconds,
|
||||
builtBytes: statSync(built).size,
|
||||
minimumGlibc: os === "linux" ? minimumGlibc(library) : null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
renameSync(partial, stage);
|
||||
}
|
||||
|
||||
/** zstd copies of the pack's libraries, for size reports and embedding. */
|
||||
function compress(pack, names, out) {
|
||||
mkdirSync(out, { recursive: true });
|
||||
const sizes = {};
|
||||
for (const name of [names.libnode, names.bridge]) {
|
||||
const target = join(out, `${name}.zst`);
|
||||
const started = Date.now();
|
||||
run("zstd", ["-19", "-T0", "-q", "-f", join(pack, name), "-o", target]);
|
||||
sizes[name] = { zstdBytes: statSync(target).size, seconds: (Date.now() - started) / 1000 };
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ platform: string, packageRoot: string, compress?: boolean, skipLibnode?: boolean }} options
|
||||
*/
|
||||
export async function buildLibnodePack(options) {
|
||||
const { platform, packageRoot } = options;
|
||||
const { os, arch } = platformInfo(platform);
|
||||
const pin = readPin();
|
||||
const cacheRoot = resolve(
|
||||
process.env.SCRIPTC_LIBNODE_CACHE ??
|
||||
join(repoRoot, "node_modules", ".cache", "scriptc-libnode"),
|
||||
);
|
||||
const key = libnodeCacheKey(platform);
|
||||
const stage = join(cacheRoot, `${platform}-${pin.nodeVersion}-${key.slice(0, 16)}`);
|
||||
const names = packFileNames(platform, pin.moduleVersion);
|
||||
if (existsSync(join(stage, "build.json"))) {
|
||||
log(`cached libnode ${pin.nodeVersion} for ${platform} in ${stage}`);
|
||||
} else {
|
||||
if (options.skipLibnode) throw new Error(`no cached libnode build in ${stage}`);
|
||||
const tree = process.env.SCRIPTC_LIBNODE_SOURCE
|
||||
? resolve(process.env.SCRIPTC_LIBNODE_SOURCE)
|
||||
: await fetchSource(cacheRoot, pin);
|
||||
buildLibnode(tree, stage, platform, pin);
|
||||
}
|
||||
const build = JSON.parse(readFileSync(join(stage, "build.json"), "utf8"));
|
||||
|
||||
for (const name of [names.libnode, names.bridge, "LICENSE", "manifest.json"]) {
|
||||
rmSync(join(packageRoot, name), { force: true });
|
||||
}
|
||||
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
||||
const bridgeStarted = Date.now();
|
||||
const manifest = buildPack({
|
||||
libnode: join(stage, "lib", names.libnode),
|
||||
headers: join(stage, "headers", "include", "node"),
|
||||
out: packageRoot,
|
||||
license: join(stage, "LICENSE"),
|
||||
extra: {
|
||||
package: packageJson.name,
|
||||
version: packageJson.version,
|
||||
target: platform,
|
||||
license: "LICENSE",
|
||||
build: {
|
||||
libnodeKey: key,
|
||||
bridgeHash: bridgeHash(),
|
||||
configureFlags: build.configureFlags,
|
||||
icu: build.icu,
|
||||
toolchain: build.toolchain,
|
||||
minimumGlibc: build.minimumGlibc,
|
||||
},
|
||||
},
|
||||
});
|
||||
const bridgeSeconds = (Date.now() - bridgeStarted) / 1000;
|
||||
if (
|
||||
manifest.nodeVersion !== pin.nodeVersion ||
|
||||
manifest.moduleVersion !== pin.moduleVersion ||
|
||||
manifest.bridgeAbi !== bridgeAbi() ||
|
||||
manifest.platform !== os ||
|
||||
manifest.arch !== arch ||
|
||||
manifest.libnode !== names.libnode ||
|
||||
manifest.bridge !== names.bridge
|
||||
) {
|
||||
throw new Error(`the assembled pack does not match ${platform}: ${JSON.stringify(manifest)}`);
|
||||
}
|
||||
const report = {
|
||||
platform,
|
||||
nodeVersion: pin.nodeVersion,
|
||||
libnodeBuildSeconds: build.buildSeconds,
|
||||
libnodeBuiltBytes: build.builtBytes,
|
||||
libnodeStrippedBytes: manifest.files[names.libnode].size,
|
||||
bridgeBytes: manifest.files[names.bridge].size,
|
||||
bridgeSeconds,
|
||||
minimumGlibc: manifest.build.minimumGlibc,
|
||||
};
|
||||
if (options.compress) {
|
||||
report.compressed = compress(packageRoot, names, join(cacheRoot, "compressed", platform));
|
||||
}
|
||||
log(`pack ${packageJson.name} for Node ${pin.nodeVersion}: ${JSON.stringify(report)}`);
|
||||
return report;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
platform: { type: "string" },
|
||||
"package-root": { type: "string" },
|
||||
compress: { type: "boolean", default: false },
|
||||
"skip-libnode": { type: "boolean", default: false },
|
||||
report: { type: "string" },
|
||||
},
|
||||
});
|
||||
if (!values.platform) {
|
||||
console.error("usage: build.mjs --platform <platform> [--package-root <dir>] [--compress]");
|
||||
process.exit(2);
|
||||
}
|
||||
const packageRoot = resolve(
|
||||
values["package-root"] ?? join(repoRoot, "packages", `libnode-${values.platform}`),
|
||||
);
|
||||
const report = await buildLibnodePack({
|
||||
platform: values.platform,
|
||||
packageRoot,
|
||||
compress: values.compress,
|
||||
skipLibnode: values["skip-libnode"],
|
||||
});
|
||||
if (values.report) writeFileSync(values.report, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Prints the CI cache identity of one platform's libnode build as
|
||||
// GitHub Actions outputs: `key` (Node release, configure flags, platform,
|
||||
// toolchain), `path` (the staged build directory under the cache root,
|
||||
// relative to it) and `bridge` (the bridge source hash, which is not part
|
||||
// of the key: a bridge change reuses the cached libnode).
|
||||
//
|
||||
// node packages/libnode-common/scripts/cache-key.mjs --platform <p> [--toolchain <t>]
|
||||
import { parseArgs } from "node:util";
|
||||
import { bridgeHash, libnodeCacheKey, readPin } from "./pin.mjs";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: { platform: { type: "string" }, toolchain: { type: "string" } },
|
||||
});
|
||||
if (!values.platform) {
|
||||
console.error("usage: cache-key.mjs --platform <platform> [--toolchain <name>]");
|
||||
process.exit(2);
|
||||
}
|
||||
const pin = readPin();
|
||||
const key = libnodeCacheKey(values.platform, values.toolchain);
|
||||
process.stdout.write(
|
||||
[
|
||||
`key=libnode-${values.platform}-${pin.nodeVersion}-${key}`,
|
||||
`path=${values.platform}-${pin.nodeVersion}-${key.slice(0, 16)}`,
|
||||
`bridge=${bridgeHash()}`,
|
||||
`node=${pin.nodeVersion}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
// The libnode pin and the platform table shared by the libnode pack build,
|
||||
// its CI cache keys, the pack verifier, and the smoke test.
|
||||
//
|
||||
// A libnode pack (`@scriptc/libnode-<platform>`) holds a stripped Node.js
|
||||
// shared library built from the pinned release, the C++ bridge compiled
|
||||
// against that release's headers, Node's license, and manifest.json. The
|
||||
// scriptc runtime dlopens the bridge on its first call into Node and talks
|
||||
// to it only through the C ABI in packages/runtime/src/scr_node_bridge.h.
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const commonRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export const repoRoot = dirname(dirname(commonRoot));
|
||||
export const bridgeSource = join(commonRoot, "bridge", "scr_node_bridge.cc");
|
||||
export const bridgeHeader = join(repoRoot, "packages", "runtime", "src", "scr_node_bridge.h");
|
||||
|
||||
export { LIBNODE_PACK_SCHEMA, bridgeAbi } from "../build-pack.mjs";
|
||||
|
||||
/** Bump to invalidate every cached libnode build (a change to how the
|
||||
* library is built or staged that the pin does not capture). */
|
||||
const BUILD_RECIPE = 1;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* nodeVersion: string,
|
||||
* tarballSha256: string,
|
||||
* moduleVersion: number,
|
||||
* configureFlags: string[],
|
||||
* icu: "full" | "small",
|
||||
* }} LibnodePin
|
||||
*/
|
||||
|
||||
/** @returns {LibnodePin} */
|
||||
export function readPin() {
|
||||
return JSON.parse(readFileSync(join(commonRoot, "libnode.json"), "utf8"));
|
||||
}
|
||||
|
||||
/** Pack platforms: the npm package suffix, the Node `os`/`arch`, and the
|
||||
* file names inside the pack. The darwin packs build only on macOS hosts. */
|
||||
export const LIBNODE_PLATFORMS = {
|
||||
"linux-x64-gnu": { os: "linux", arch: "x64", target: "linux-x64-gnu" },
|
||||
"linux-arm64-gnu": { os: "linux", arch: "arm64", target: "linux-arm64-gnu" },
|
||||
"darwin-arm64": { os: "darwin", arch: "arm64", target: "macos-arm64" },
|
||||
"darwin-x64": { os: "darwin", arch: "x64", target: "macos-x64" },
|
||||
};
|
||||
|
||||
/** @param {string} platform */
|
||||
export function platformInfo(platform) {
|
||||
const info = LIBNODE_PLATFORMS[/** @type {keyof typeof LIBNODE_PLATFORMS} */ (platform)];
|
||||
if (info === undefined) throw new Error(`unknown libnode pack platform ${platform}`);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** The library names inside a pack for one platform and module version. */
|
||||
export function packFileNames(platform, moduleVersion) {
|
||||
const { os } = platformInfo(platform);
|
||||
return os === "darwin"
|
||||
? { libnode: `libnode.${moduleVersion}.dylib`, bridge: "libscr_node_bridge.dylib" }
|
||||
: { libnode: `libnode.so.${moduleVersion}`, bridge: "libscr_node_bridge.so" };
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
/** The cache key of the libnode build itself: the Node release, how it is
|
||||
* configured, the platform and toolchain. The bridge is not part of it; a
|
||||
* bridge change rebuilds only the bridge against the cached headers. */
|
||||
export function libnodeCacheKey(platform, toolchain = process.env.SCRIPTC_LIBNODE_TOOLCHAIN) {
|
||||
const pin = readPin();
|
||||
platformInfo(platform);
|
||||
return sha256(
|
||||
JSON.stringify({
|
||||
recipe: BUILD_RECIPE,
|
||||
nodeVersion: pin.nodeVersion,
|
||||
tarballSha256: pin.tarballSha256,
|
||||
configureFlags: pin.configureFlags,
|
||||
icu: pin.icu,
|
||||
platform,
|
||||
toolchain: toolchain ?? "default",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The hash of everything the bridge is compiled from. */
|
||||
export function bridgeHash() {
|
||||
return sha256(readFileSync(bridgeSource, "utf8") + "\0" + readFileSync(bridgeHeader, "utf8"));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs the libnode pack smoke test (smoke/smoke.c): compiles the C program
|
||||
// with the host's C compiler, runs it against a pack directory and compares
|
||||
// its stdout with the expected transcript, which pins the Node version,
|
||||
// the built-in startup snapshot, full ICU, and synchronous, asynchronous
|
||||
// and throwing calls through the bridge's C ABI.
|
||||
//
|
||||
// node packages/libnode-common/scripts/smoke.mjs [--pack <dir>] [--platform <p>]
|
||||
//
|
||||
// The pack defaults to SCRIPTC_LIBNODE, else packages/libnode-<platform>.
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs } from "node:util";
|
||||
import { bridgeAbi, bridgeHeader, commonRoot, readPin, repoRoot } from "./pin.mjs";
|
||||
|
||||
/** The host's pack platform name. */
|
||||
export function hostPlatform() {
|
||||
if (process.platform === "darwin") return `darwin-${process.arch}`;
|
||||
return `${process.platform}-${process.arch}-gnu`;
|
||||
}
|
||||
|
||||
/** The transcript smoke.c prints for a correct pack of the pinned Node. */
|
||||
export function expectedTranscript(pin = readPin(), abi = bridgeAbi()) {
|
||||
const icuSmall = pin.icu === "small";
|
||||
return [
|
||||
`bridge ABI ${abi}, Node ${pin.nodeVersion}, NODE_MODULE_VERSION ${pin.moduleVersion}`,
|
||||
"libnode mapped before dlopen: no",
|
||||
"1 + 2 => R3",
|
||||
`process.versions.node => R"${pin.nodeVersion}"`,
|
||||
"process.config.variables.node_use_node_snapshot => Rtrue",
|
||||
`process.config.variables.icu_small => R${icuSmall}`,
|
||||
`new Intl.NumberFormat('de-DE').format(1234567.891) => R"${icuSmall ? "1,234,567.891" : "1.234.567,891"}"`,
|
||||
`new Intl.DateTimeFormat('ja-JP', { timeZone: 'UTC', dateStyle: 'full' }).format(0) => R"${icuSmall ? "Thursday, January 1, 1970" : "1970年1月1日木曜日"}"`,
|
||||
`require('node:crypto').createHash('sha256').update('scriptc').digest('hex') => R"${sha256Hex("scriptc")}"`,
|
||||
`require('node:zlib').gunzipSync(require('node:zlib').gzipSync('round trip')).toString() => R"round trip"`,
|
||||
`[typeof require('node:fs').readFileSync, require('node:path').join('a', 'b')] => R["function","a/b"]`,
|
||||
`null.property => T{"error":{"name":"TypeError","message":"Cannot read properties of null (reading 'property')"}}`,
|
||||
`await new Promise((resolve) => setTimeout(() => resolve('timer fired'), 5)) => R"timer fired"`,
|
||||
"await require('node:fs/promises').readFile(process.execPath).then((b) => b.length > 0) => Rtrue",
|
||||
`await Promise.reject(new RangeError('rejected')) => T{"error":{"name":"RangeError","message":"rejected"}}`,
|
||||
"ok",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function sha256Hex(text) {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
/** Compiles and runs the smoke test; returns its stdout and stderr. */
|
||||
export function runSmoke(pack, cc = process.env.CC ?? "cc") {
|
||||
const work = mkdtempSync(join(tmpdir(), "scriptc-libnode-smoke-"));
|
||||
try {
|
||||
const binary = join(work, "smoke");
|
||||
execFileSync(
|
||||
cc,
|
||||
[
|
||||
"-std=c11",
|
||||
...(process.platform === "linux" ? ["-D_POSIX_C_SOURCE=200809L", "-D_DEFAULT_SOURCE"] : []),
|
||||
"-O2",
|
||||
"-Wall",
|
||||
"-Werror",
|
||||
"-I",
|
||||
dirname(bridgeHeader),
|
||||
join(commonRoot, "smoke", "smoke.c"),
|
||||
...(process.platform === "linux" ? ["-ldl"] : []),
|
||||
"-o",
|
||||
binary,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const result = spawnSync(binary, [pack], { encoding: "utf8", env: { ...process.env } });
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const { values } = parseArgs({
|
||||
options: { pack: { type: "string" }, platform: { type: "string" } },
|
||||
});
|
||||
// An explicit --pack or --platform names the pack; SCRIPTC_LIBNODE only
|
||||
// replaces the host's default.
|
||||
const pack = resolve(
|
||||
values.pack ??
|
||||
(values.platform !== undefined
|
||||
? join(repoRoot, "packages", `libnode-${values.platform}`)
|
||||
: (process.env.SCRIPTC_LIBNODE ?? join(repoRoot, "packages", `libnode-${hostPlatform()}`))),
|
||||
);
|
||||
const manifest = JSON.parse(readFileSync(join(pack, "manifest.json"), "utf8"));
|
||||
const { status, stdout, stderr } = runSmoke(pack);
|
||||
process.stderr.write(stderr);
|
||||
const expected = expectedTranscript();
|
||||
if (status !== 0 || stdout !== expected) {
|
||||
process.stderr.write(
|
||||
`smoke test failed (exit ${status}) for ${pack}\n--- expected\n${expected}--- actual\n${stdout}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
`libnode smoke test passed: ${manifest.package} (Node ${manifest.nodeVersion})\n`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
// Verifies a built libnode pack before it is packed or published: the
|
||||
// manifest matches the package, the pin and the bridge ABI; every library
|
||||
// it lists exists with the recorded size and SHA-256; the license files are
|
||||
// present.
|
||||
//
|
||||
// node packages/libnode-common/scripts/verify.mjs <package directory>
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { LIBNODE_PACK_SCHEMA, bridgeAbi, packFileNames, platformInfo, readPin } from "./pin.mjs";
|
||||
|
||||
/** The problems with the pack in `root`; empty when it is complete. */
|
||||
export function verifyPack(root) {
|
||||
const problems = [];
|
||||
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
||||
const manifestPath = join(root, "manifest.json");
|
||||
if (!existsSync(manifestPath)) return [`${manifestPath} is missing; run build:native first`];
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
const pin = readPin();
|
||||
const platform = String(packageJson.name).replace(/^@scriptc\/libnode-/, "");
|
||||
const expect = (field, actual, expected) => {
|
||||
if (actual !== expected) problems.push(`manifest ${field} is ${actual}, expected ${expected}`);
|
||||
};
|
||||
expect("schema", manifest.schema, LIBNODE_PACK_SCHEMA);
|
||||
expect("package", manifest.package, packageJson.name);
|
||||
expect("version", manifest.version, packageJson.version);
|
||||
expect("target", manifest.target, platform);
|
||||
expect("platform", manifest.platform, platformInfo(platform).os);
|
||||
expect("arch", manifest.arch, platformInfo(platform).arch);
|
||||
expect("nodeVersion", manifest.nodeVersion, pin.nodeVersion);
|
||||
expect("moduleVersion", manifest.moduleVersion, pin.moduleVersion);
|
||||
expect("bridgeAbi", manifest.bridgeAbi, bridgeAbi());
|
||||
const names = packFileNames(platform, pin.moduleVersion);
|
||||
expect("libnode", manifest.libnode, names.libnode);
|
||||
expect("bridge", manifest.bridge, names.bridge);
|
||||
for (const name of [names.libnode, names.bridge]) {
|
||||
const entry = manifest.files?.[name];
|
||||
const path = join(root, name);
|
||||
if (entry === undefined) problems.push(`manifest lists no ${name}`);
|
||||
else if (!existsSync(path)) problems.push(`${name} is missing`);
|
||||
else if (statSync(path).size !== entry.size) problems.push(`${name} has the wrong size`);
|
||||
else if (createHash("sha256").update(readFileSync(path)).digest("hex") !== entry.sha256) {
|
||||
problems.push(`${name} has the wrong SHA-256`);
|
||||
}
|
||||
if (!packageJson.files.includes(name)) problems.push(`package.json files omits ${name}`);
|
||||
}
|
||||
for (const name of ["LICENSE", "SCRIPTC_LICENSE", "THIRD_PARTY_NOTICES"]) {
|
||||
if (!existsSync(join(root, name))) problems.push(`${name} is missing`);
|
||||
}
|
||||
if (existsSync(join(root, "LICENSE"))) {
|
||||
const license = readFileSync(join(root, "LICENSE"), "utf8");
|
||||
if (!license.startsWith("Node.js is licensed for use as follows:")) {
|
||||
problems.push("LICENSE is not the Node.js LICENSE file");
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const root = resolve(process.argv[2] ?? ".");
|
||||
const problems = verifyPack(root);
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(`libnode pack ${root}: ${problem}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`libnode pack ${root} verified`);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/* Smoke test for a libnode pack, written the way the scriptc runtime loads
|
||||
* one: a plain C program that never includes a Node or V8 header, dlopens
|
||||
* the pack's bridge, checks its ABI and Node version, starts Node (one
|
||||
* environment from the built-in startup snapshot), evaluates expressions
|
||||
* synchronously and asynchronously through the bridge's C ABI, runs Node's
|
||||
* event loop until the asynchronous ones settle, and tears Node down.
|
||||
*
|
||||
* smoke <pack directory> (or SCRIPTC_LIBNODE=<pack directory> smoke)
|
||||
*
|
||||
* stdout is deterministic and compared by scripts/smoke.mjs; timings and
|
||||
* memory go to stderr. */
|
||||
#include "scr_node_bridge.h"
|
||||
|
||||
#include <dlfcn.h>
|
||||
#include <poll.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/resource.h>
|
||||
#include <time.h>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#define BRIDGE_NAME "libscr_node_bridge.dylib"
|
||||
#else
|
||||
#define BRIDGE_NAME "libscr_node_bridge.so"
|
||||
#endif
|
||||
|
||||
/* The bootstrap runs as the environment's main script. It hands the bridge
|
||||
* a dispatcher that evaluates `name` as an expression and answers the
|
||||
* bridge's outcome strings: "R<json>" when it returns, "T<json>" when it
|
||||
* throws; an asynchronous call settles through the host. The smoke test
|
||||
* schedules no runtime jobs and listens to no process events, so the rest
|
||||
* of the dispatcher the bridge requires does nothing. */
|
||||
static const char kBootstrap[] =
|
||||
"const host = globalThis.__scriptcNodeIslandHost;\n"
|
||||
"globalThis.require = require;\n"
|
||||
"const outcome = (ok, value) => (ok ? 'R' : 'T') + JSON.stringify(\n"
|
||||
" ok ? value : { error: { name: value?.name, message: String(value?.message ?? value) } });\n"
|
||||
"globalThis.__scriptcNodeIsland = {\n"
|
||||
" call(id, sync, file, specifier, name, args) {\n"
|
||||
" let value;\n"
|
||||
" try { value = (0, eval)(name); } catch (error) { return outcome(false, error); }\n"
|
||||
" if (sync) return outcome(true, value);\n"
|
||||
" Promise.resolve(value).then((v) => host.settle(id, outcome(true, v)),\n"
|
||||
" (e) => host.settle(id, outcome(false, e)));\n"
|
||||
" },\n"
|
||||
" setTimer() { return 0; }, clearTimer() {}, refTimer() {}, refreshTimer() {},\n"
|
||||
" setImmediate() { return 0; }, clearImmediate() {}, refImmediate() {},\n"
|
||||
" nextTick() {}, emit() {},\n"
|
||||
"};\n";
|
||||
|
||||
static double now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return ts.tv_sec * 1e3 + ts.tv_nsec / 1e6;
|
||||
}
|
||||
|
||||
static long max_rss_kb(void) {
|
||||
struct rusage usage;
|
||||
getrusage(RUSAGE_SELF, &usage);
|
||||
#if defined(__APPLE__)
|
||||
return usage.ru_maxrss / 1024;
|
||||
#else
|
||||
return usage.ru_maxrss;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Whether a library whose path contains `needle` is mapped (Linux only). */
|
||||
static int mapped(const char *needle) {
|
||||
FILE *maps = fopen("/proc/self/maps", "r");
|
||||
if (maps == NULL) return -1;
|
||||
char line[4096];
|
||||
int found = 0;
|
||||
while (!found && fgets(line, sizeof line, maps) != NULL) found = strstr(line, needle) != NULL;
|
||||
fclose(maps);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* The asynchronous expressions, by call id, and their outcomes. */
|
||||
static const char *const kAsync[] = {
|
||||
"new Promise((resolve) => setTimeout(() => resolve('timer fired'), 5))",
|
||||
"require('node:fs/promises').readFile(process.execPath).then((b) => b.length > 0)",
|
||||
"Promise.reject(new RangeError('rejected'))",
|
||||
};
|
||||
#define ASYNC_COUNT (sizeof kAsync / sizeof kAsync[0])
|
||||
static char *settled[ASYNC_COUNT];
|
||||
|
||||
static void host_settle(uint32_t id, const char *outcome, size_t len) {
|
||||
if (id >= ASYNC_COUNT || settled[id] != NULL) return;
|
||||
settled[id] = malloc(len + 1);
|
||||
memcpy(settled[id], outcome, len);
|
||||
settled[id][len] = '\0';
|
||||
}
|
||||
|
||||
static void host_exit(int code) {
|
||||
fflush(stdout);
|
||||
exit(code);
|
||||
}
|
||||
|
||||
/* The smoke test never calls host_loop, so Node's loop runs alone: no
|
||||
* runtime jobs, I/O or listeners, and V8 keeps the thread's stack limit. */
|
||||
static void host_run(uint32_t kind, uint32_t token) { (void)kind, (void)token; }
|
||||
static void host_io_state(ScrNodeIoState *state) {
|
||||
state->nfds = 0;
|
||||
state->alive = 0;
|
||||
state->ready = 0;
|
||||
state->cap_ms = -1;
|
||||
}
|
||||
static void host_io_dispatch(void) {}
|
||||
static void host_microtasks_done(void) {}
|
||||
static void host_listen(uint32_t event, const char *name, uint32_t listener, int add, int once) {
|
||||
(void)event, (void)name, (void)listener, (void)add, (void)once;
|
||||
}
|
||||
static uintptr_t host_stack_limit(void) { return 0; }
|
||||
/* The smoke test runs on its own thread's stack. */
|
||||
static uintptr_t host_stack_start(void) { return 0; }
|
||||
|
||||
static const ScrNodeBridgeHost kHost = {
|
||||
host_settle, host_exit, host_run, host_io_state,
|
||||
host_io_dispatch, host_microtasks_done, host_listen, host_stack_limit,
|
||||
host_stack_start,
|
||||
};
|
||||
|
||||
static const ScrNodeBridge *bridge;
|
||||
|
||||
static void eval_sync(const char *expression) {
|
||||
size_t len = 0;
|
||||
char *outcome = bridge->call(0, 1, "", "", expression, "[]", 2, &len);
|
||||
printf("%s => %s\n", expression, outcome);
|
||||
bridge->release(outcome);
|
||||
}
|
||||
|
||||
/* Starts every asynchronous expression, then runs Node's event loop until
|
||||
* nothing keeps it alive, as the runtime does at the end of a program. */
|
||||
static void eval_async_all(void) {
|
||||
for (uint32_t id = 0; id < ASYNC_COUNT; id++) {
|
||||
size_t len = 0;
|
||||
char *immediate = bridge->call(id, 0, "", "", kAsync[id], "[]", 2, &len);
|
||||
if (immediate != NULL) bridge->release(immediate);
|
||||
bridge->checkpoint(0);
|
||||
}
|
||||
bridge->run_loop(0);
|
||||
for (uint32_t id = 0; id < ASYNC_COUNT; id++) {
|
||||
printf("await %s => %s\n", kAsync[id], settled[id] != NULL ? settled[id] : "(never settled)");
|
||||
free(settled[id]);
|
||||
settled[id] = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
const char *dir = argc > 1 ? argv[1] : getenv("SCRIPTC_LIBNODE");
|
||||
if (dir == NULL || dir[0] == '\0') {
|
||||
fprintf(stderr, "usage: smoke <pack directory> (or set SCRIPTC_LIBNODE)\n");
|
||||
return 2;
|
||||
}
|
||||
char path[4096];
|
||||
snprintf(path, sizeof path, "%s/%s", dir, BRIDGE_NAME);
|
||||
int before = mapped("/libnode.");
|
||||
double t0 = now_ms();
|
||||
void *library = dlopen(path, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (library == NULL) {
|
||||
fprintf(stderr, "smoke: %s\n", dlerror());
|
||||
return 1;
|
||||
}
|
||||
double t1 = now_ms();
|
||||
const ScrNodeBridge *(*entry)(void) =
|
||||
(const ScrNodeBridge *(*)(void))dlsym(library, SCR_NODE_BRIDGE_ENTRY);
|
||||
if (entry == NULL) {
|
||||
fprintf(stderr, "smoke: %s has no %s\n", path, SCR_NODE_BRIDGE_ENTRY);
|
||||
return 1;
|
||||
}
|
||||
bridge = entry();
|
||||
if (bridge->abi != SCR_NODE_BRIDGE_ABI) {
|
||||
fprintf(stderr, "smoke: bridge ABI %u, expected %u\n", bridge->abi, SCR_NODE_BRIDGE_ABI);
|
||||
return 1;
|
||||
}
|
||||
printf("bridge ABI %u, Node %s, NODE_MODULE_VERSION %u\n", bridge->abi, bridge->node_version,
|
||||
bridge->module_version);
|
||||
printf("libnode mapped before dlopen: %s\n", before == 1 ? "yes" : "no");
|
||||
char *error = bridge->start(&kHost, kBootstrap, argv[0], 0);
|
||||
if (error != NULL) {
|
||||
fprintf(stderr, "smoke: %s\n", error);
|
||||
return 1;
|
||||
}
|
||||
double t2 = now_ms();
|
||||
eval_sync("1 + 2");
|
||||
double t3 = now_ms();
|
||||
eval_sync("process.versions.node");
|
||||
eval_sync("process.config.variables.node_use_node_snapshot");
|
||||
eval_sync("process.config.variables.icu_small");
|
||||
eval_sync("new Intl.NumberFormat('de-DE').format(1234567.891)");
|
||||
eval_sync("new Intl.DateTimeFormat('ja-JP', { timeZone: 'UTC', dateStyle: 'full' }).format(0)");
|
||||
eval_sync("require('node:crypto').createHash('sha256').update('scriptc').digest('hex')");
|
||||
eval_sync("require('node:zlib').gunzipSync(require('node:zlib').gzipSync('round trip')).toString()");
|
||||
eval_sync("[typeof require('node:fs').readFileSync, require('node:path').join('a', 'b')]");
|
||||
eval_sync("null.property");
|
||||
eval_async_all();
|
||||
fprintf(stderr,
|
||||
"smoke: dlopen %.1f ms, start %.1f ms, first result %.2f ms, dlopen to first result "
|
||||
"%.1f ms, max RSS %ld KB\n",
|
||||
t1 - t0, t2 - t1, t3 - t2, t3 - t0, max_rss_kb());
|
||||
bridge->stop();
|
||||
printf("ok\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -51,9 +51,10 @@ static void listen(uint32_t event, const char *name, uint32_t listener, int add,
|
||||
}
|
||||
|
||||
static uintptr_t stack_limit(void) { return 0; }
|
||||
static uintptr_t stack_start(void) { return 0; }
|
||||
|
||||
static const ScrNodeBridgeHost host = {settle, on_exit_code, run, io_state, io_dispatch,
|
||||
microtasks_done, listen, stack_limit};
|
||||
microtasks_done, listen, stack_limit, stack_start};
|
||||
|
||||
static void call(const ScrNodeBridge *b, uint32_t id, int sync, const char *module,
|
||||
const char *name, const char *args) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* calls through the bridge with the runtime's embedded bootstrap. Needs a
|
||||
* pack in SCRIPTC_LIBNODE; required in the Linux x64 and macOS CI lanes
|
||||
* (tests/harness/libnode.ts). */
|
||||
import { bridgeAbi } from "../build-pack.mjs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -45,7 +46,7 @@ exports.quit = (code) => process.exit(code);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(result.stdout).toBe(
|
||||
[
|
||||
"abi 4 ok 1",
|
||||
`abi ${bridgeAbi()} ok 1`,
|
||||
"sync add -> R5",
|
||||
'sync fails -> T{"error":{"kind":2,"message":"out of range","code":"ERR_X"}}',
|
||||
`sync missing -> T{"error":{"kind":1,"message":"'missing' exported by './smoke.cjs' is not a function"}}`,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
LIBNODE_PLATFORMS,
|
||||
bridgeAbi,
|
||||
bridgeHash,
|
||||
libnodeCacheKey,
|
||||
packFileNames,
|
||||
readPin,
|
||||
repoRoot,
|
||||
} from "../scripts/pin.mjs";
|
||||
import { expectedTranscript } from "../scripts/smoke.mjs";
|
||||
|
||||
const readJson = (path: string) => JSON.parse(readFileSync(join(repoRoot, path), "utf8"));
|
||||
|
||||
describe("the libnode pin", () => {
|
||||
test("is the repository's pinned Node release", () => {
|
||||
const pin = readPin();
|
||||
expect(pin.nodeVersion).toBe(readJson("internal/compatibility/node-v24.json").version);
|
||||
expect(pin.nodeVersion).toBe(readFileSync(join(repoRoot, ".node-version"), "utf8").trim());
|
||||
expect(pin.tarballSha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("builds the shared library with full ICU (design §8 question 2)", () => {
|
||||
const pin = readPin();
|
||||
expect(pin.configureFlags).toContain("--shared");
|
||||
expect(pin.configureFlags.some((flag: string) => flag.startsWith("--with-intl"))).toBe(false);
|
||||
expect(pin.icu).toBe("full");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the platform packages", () => {
|
||||
const dirs = readdirSync(join(repoRoot, "packages")).filter(
|
||||
(name) => name.startsWith("libnode-") && name !== "libnode-common",
|
||||
);
|
||||
|
||||
test("match the platform table", () => {
|
||||
expect(dirs.map((dir) => dir.slice("libnode-".length)).sort()).toEqual(
|
||||
Object.keys(LIBNODE_PLATFORMS).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(Object.entries(LIBNODE_PLATFORMS))("%s ships its libraries", (platform, info) => {
|
||||
const manifest = readJson(`packages/libnode-${platform}/package.json`);
|
||||
const names = packFileNames(platform, readPin().moduleVersion);
|
||||
expect(manifest.name).toBe(`@scriptc/libnode-${platform}`);
|
||||
expect(manifest.version).toBe(readJson("packages/cli/package.json").version);
|
||||
expect(manifest.os).toEqual([info.os]);
|
||||
expect(manifest.cpu).toEqual([info.arch]);
|
||||
expect(manifest.files).toEqual([
|
||||
names.libnode,
|
||||
names.bridge,
|
||||
"manifest.json",
|
||||
"LICENSE",
|
||||
"SCRIPTC_LICENSE",
|
||||
"THIRD_PARTY_NOTICES",
|
||||
]);
|
||||
// Never built by `pnpm -r build`.
|
||||
expect(manifest.scripts.build).toBe("true");
|
||||
expect(manifest.scripts["build:native"]).toBe("node scripts/build.mjs");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache identity", () => {
|
||||
test("the libnode key depends on platform and toolchain, not on the bridge", () => {
|
||||
const a = libnodeCacheKey("linux-x64-gnu", "manylinux_2_28-gcc-toolset-14");
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(libnodeCacheKey("linux-x64-gnu", "manylinux_2_28-gcc-toolset-14")).toBe(a);
|
||||
expect(libnodeCacheKey("linux-arm64-gnu", "manylinux_2_28-gcc-toolset-14")).not.toBe(a);
|
||||
expect(libnodeCacheKey("linux-x64-gnu", "other")).not.toBe(a);
|
||||
expect(bridgeHash()).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test("unknown platforms are refused", () => {
|
||||
expect(() => libnodeCacheKey("linux-x64-musl", "x")).toThrow(
|
||||
"unknown libnode pack platform linux-x64-musl",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("the smoke transcript pins the Node version, the snapshot and full ICU", () => {
|
||||
const lines = expectedTranscript().split("\n");
|
||||
expect(lines[0]).toBe(
|
||||
`bridge ABI ${bridgeAbi()}, Node ${readPin().nodeVersion}, NODE_MODULE_VERSION ${readPin().moduleVersion}`,
|
||||
);
|
||||
expect(lines).toContain("process.config.variables.node_use_node_snapshot => Rtrue");
|
||||
expect(lines).toContain(`new Intl.NumberFormat('de-DE').format(1234567.891) => R"1.234.567,891"`);
|
||||
expect(lines.at(-2)).toBe("ok");
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// Loads a real libnode pack from a C program (smoke/smoke.c) the way the
|
||||
// scriptc runtime does. Packs are not built by the ordinary build, so this
|
||||
// runs only when SCRIPTC_LIBNODE names a pack directory (a CI artifact of
|
||||
// the libnode-packs workflow, or a local build:native output). CI's
|
||||
// fallback development pack (dev-pack.json) is not a package: it has no
|
||||
// package.json and may carry a different Node patch release, so it is
|
||||
// exercised by the Node island tests instead.
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { verifyPack } from "../scripts/verify.mjs";
|
||||
import { expectedTranscript, runSmoke } from "../scripts/smoke.mjs";
|
||||
|
||||
const pack = process.env["SCRIPTC_LIBNODE"];
|
||||
const isPackage = pack !== undefined && pack !== "" && existsSync(join(pack, "package.json"));
|
||||
|
||||
describe.skipIf(!isPackage)("libnode pack smoke test", () => {
|
||||
test("the pack is complete", () => {
|
||||
expect(verifyPack(pack!)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a C program starts Node from the pack and evaluates expressions", () => {
|
||||
const { status, stdout } = runSmoke(pack!);
|
||||
expect(stdout).toBe(expectedTranscript());
|
||||
expect(status).toBe(0);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Copyright scriptc contributors. Licensed under Apache-2.0.
|
||||
@@ -0,0 +1,15 @@
|
||||
This package contains libnode.137.dylib, a stripped build of the Node.js shared
|
||||
library (https://nodejs.org) from the official source release named in
|
||||
manifest.json, built without modification. Node.js is licensed under the
|
||||
MIT license. It bundles third-party components, among them V8, ICU,
|
||||
OpenSSL, libuv, zlib, brotli, nghttp2, nghttp3, ngtcp2, llhttp, c-ares,
|
||||
simdutf, ada and uvwasi, under the BSD, Apache-2.0, Unicode, MIT and
|
||||
other permissive licenses. LICENSE is the LICENSE file of that Node.js
|
||||
release, which carries the complete license text of Node.js and of every
|
||||
bundled component.
|
||||
|
||||
libscr_node_bridge.dylib is the scriptc bridge between the scriptc runtime and Node.js,
|
||||
licensed under Apache-2.0 (SCRIPTC_LICENSE).
|
||||
|
||||
A program that `scriptc build` links against this package redistributes
|
||||
Node.js; scriptc writes these notices next to such a program.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@scriptc/libnode-darwin-arm64",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS arm64",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"homepage": "https://scriptc.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/scriptc.git",
|
||||
"directory": "packages/libnode-darwin-arm64"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"files": [
|
||||
"libnode.137.dylib",
|
||||
"libscr_node_bridge.dylib",
|
||||
"manifest.json",
|
||||
"LICENSE",
|
||||
"SCRIPTC_LICENSE",
|
||||
"THIRD_PARTY_NOTICES"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "true",
|
||||
"build:native": "node scripts/build.mjs",
|
||||
"prepack": "node ../libnode-common/scripts/verify.mjs ."
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds @scriptc/libnode-darwin-arm64: libnode for the pinned Node.js release
|
||||
// and the bridge (packages/libnode-common/scripts/build.mjs). The Node
|
||||
// build takes about half an hour on 8 cores and is cached by Node version,
|
||||
// configure flags and toolchain; only the libnode-packs workflow and an
|
||||
// explicit `pnpm --filter @scriptc/libnode-darwin-arm64 build:native` run it.
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildLibnodePack } from "../../libnode-common/scripts/build.mjs";
|
||||
|
||||
if (process.platform !== "darwin" || process.arch !== "arm64") {
|
||||
process.stdout.write("@scriptc/libnode-darwin-arm64: skipped on this host\n");
|
||||
process.exit(0);
|
||||
}
|
||||
await buildLibnodePack({
|
||||
platform: "darwin-arm64",
|
||||
packageRoot: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
compress: process.argv.includes("--compress"),
|
||||
// Assemble from a restored libnode build only (CI test lanes); never
|
||||
// start a Node build.
|
||||
skipLibnode: process.argv.includes("--from-cache"),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Copyright scriptc contributors. Licensed under Apache-2.0.
|
||||
@@ -0,0 +1,15 @@
|
||||
This package contains libnode.137.dylib, a stripped build of the Node.js shared
|
||||
library (https://nodejs.org) from the official source release named in
|
||||
manifest.json, built without modification. Node.js is licensed under the
|
||||
MIT license. It bundles third-party components, among them V8, ICU,
|
||||
OpenSSL, libuv, zlib, brotli, nghttp2, nghttp3, ngtcp2, llhttp, c-ares,
|
||||
simdutf, ada and uvwasi, under the BSD, Apache-2.0, Unicode, MIT and
|
||||
other permissive licenses. LICENSE is the LICENSE file of that Node.js
|
||||
release, which carries the complete license text of Node.js and of every
|
||||
bundled component.
|
||||
|
||||
libscr_node_bridge.dylib is the scriptc bridge between the scriptc runtime and Node.js,
|
||||
licensed under Apache-2.0 (SCRIPTC_LICENSE).
|
||||
|
||||
A program that `scriptc build` links against this package redistributes
|
||||
Node.js; scriptc writes these notices next to such a program.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@scriptc/libnode-darwin-x64",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS x64",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"homepage": "https://scriptc.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/scriptc.git",
|
||||
"directory": "packages/libnode-darwin-x64"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"files": [
|
||||
"libnode.137.dylib",
|
||||
"libscr_node_bridge.dylib",
|
||||
"manifest.json",
|
||||
"LICENSE",
|
||||
"SCRIPTC_LICENSE",
|
||||
"THIRD_PARTY_NOTICES"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "true",
|
||||
"build:native": "node scripts/build.mjs",
|
||||
"prepack": "node ../libnode-common/scripts/verify.mjs ."
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds @scriptc/libnode-darwin-x64: libnode for the pinned Node.js release
|
||||
// and the bridge (packages/libnode-common/scripts/build.mjs). The Node
|
||||
// build takes about half an hour on 8 cores and is cached by Node version,
|
||||
// configure flags and toolchain; only the libnode-packs workflow and an
|
||||
// explicit `pnpm --filter @scriptc/libnode-darwin-x64 build:native` run it.
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildLibnodePack } from "../../libnode-common/scripts/build.mjs";
|
||||
|
||||
if (process.platform !== "darwin" || process.arch !== "x64") {
|
||||
process.stdout.write("@scriptc/libnode-darwin-x64: skipped on this host\n");
|
||||
process.exit(0);
|
||||
}
|
||||
await buildLibnodePack({
|
||||
platform: "darwin-x64",
|
||||
packageRoot: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
compress: process.argv.includes("--compress"),
|
||||
// Assemble from a restored libnode build only (CI test lanes); never
|
||||
// start a Node build.
|
||||
skipLibnode: process.argv.includes("--from-cache"),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Copyright scriptc contributors. Licensed under Apache-2.0.
|
||||
@@ -0,0 +1,15 @@
|
||||
This package contains libnode.so.137, a stripped build of the Node.js shared
|
||||
library (https://nodejs.org) from the official source release named in
|
||||
manifest.json, built without modification. Node.js is licensed under the
|
||||
MIT license. It bundles third-party components, among them V8, ICU,
|
||||
OpenSSL, libuv, zlib, brotli, nghttp2, nghttp3, ngtcp2, llhttp, c-ares,
|
||||
simdutf, ada and uvwasi, under the BSD, Apache-2.0, Unicode, MIT and
|
||||
other permissive licenses. LICENSE is the LICENSE file of that Node.js
|
||||
release, which carries the complete license text of Node.js and of every
|
||||
bundled component.
|
||||
|
||||
libscr_node_bridge.so is the scriptc bridge between the scriptc runtime and Node.js,
|
||||
licensed under Apache-2.0 (SCRIPTC_LICENSE).
|
||||
|
||||
A program that `scriptc build` links against this package redistributes
|
||||
Node.js; scriptc writes these notices next to such a program.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@scriptc/libnode-linux-arm64-gnu",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux arm64 glibc",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"homepage": "https://scriptc.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/scriptc.git",
|
||||
"directory": "packages/libnode-linux-arm64-gnu"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"files": [
|
||||
"libnode.so.137",
|
||||
"libscr_node_bridge.so",
|
||||
"manifest.json",
|
||||
"LICENSE",
|
||||
"SCRIPTC_LICENSE",
|
||||
"THIRD_PARTY_NOTICES"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "true",
|
||||
"build:native": "node scripts/build.mjs",
|
||||
"prepack": "node ../libnode-common/scripts/verify.mjs ."
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds @scriptc/libnode-linux-arm64-gnu: libnode for the pinned Node.js release
|
||||
// and the bridge (packages/libnode-common/scripts/build.mjs). The Node
|
||||
// build takes about half an hour on 8 cores and is cached by Node version,
|
||||
// configure flags and toolchain; only the libnode-packs workflow and an
|
||||
// explicit `pnpm --filter @scriptc/libnode-linux-arm64-gnu build:native` run it.
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildLibnodePack } from "../../libnode-common/scripts/build.mjs";
|
||||
|
||||
if (process.platform !== "linux" || process.arch !== "arm64") {
|
||||
process.stdout.write("@scriptc/libnode-linux-arm64-gnu: skipped on this host\n");
|
||||
process.exit(0);
|
||||
}
|
||||
await buildLibnodePack({
|
||||
platform: "linux-arm64-gnu",
|
||||
packageRoot: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
compress: process.argv.includes("--compress"),
|
||||
// Assemble from a restored libnode build only (CI test lanes); never
|
||||
// start a Node build.
|
||||
skipLibnode: process.argv.includes("--from-cache"),
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Copyright scriptc contributors. Licensed under Apache-2.0.
|
||||
@@ -0,0 +1,15 @@
|
||||
This package contains libnode.so.137, a stripped build of the Node.js shared
|
||||
library (https://nodejs.org) from the official source release named in
|
||||
manifest.json, built without modification. Node.js is licensed under the
|
||||
MIT license. It bundles third-party components, among them V8, ICU,
|
||||
OpenSSL, libuv, zlib, brotli, nghttp2, nghttp3, ngtcp2, llhttp, c-ares,
|
||||
simdutf, ada and uvwasi, under the BSD, Apache-2.0, Unicode, MIT and
|
||||
other permissive licenses. LICENSE is the LICENSE file of that Node.js
|
||||
release, which carries the complete license text of Node.js and of every
|
||||
bundled component.
|
||||
|
||||
libscr_node_bridge.so is the scriptc bridge between the scriptc runtime and Node.js,
|
||||
licensed under Apache-2.0 (SCRIPTC_LICENSE).
|
||||
|
||||
A program that `scriptc build` links against this package redistributes
|
||||
Node.js; scriptc writes these notices next to such a program.
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@scriptc/libnode-linux-x64-gnu",
|
||||
"version": "0.4.5",
|
||||
"private": true,
|
||||
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux x64 glibc",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"homepage": "https://scriptc.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/scriptc.git",
|
||||
"directory": "packages/libnode-linux-x64-gnu"
|
||||
},
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"files": [
|
||||
"libnode.so.137",
|
||||
"libscr_node_bridge.so",
|
||||
"manifest.json",
|
||||
"LICENSE",
|
||||
"SCRIPTC_LICENSE",
|
||||
"THIRD_PARTY_NOTICES"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "true",
|
||||
"build:native": "node scripts/build.mjs",
|
||||
"prepack": "node ../libnode-common/scripts/verify.mjs ."
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds @scriptc/libnode-linux-x64-gnu: libnode for the pinned Node.js release
|
||||
// and the bridge (packages/libnode-common/scripts/build.mjs). The Node
|
||||
// build takes about half an hour on 8 cores and is cached by Node version,
|
||||
// configure flags and toolchain; only the libnode-packs workflow and an
|
||||
// explicit `pnpm --filter @scriptc/libnode-linux-x64-gnu build:native` run it.
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildLibnodePack } from "../../libnode-common/scripts/build.mjs";
|
||||
|
||||
if (process.platform !== "linux" || process.arch !== "x64") {
|
||||
process.stdout.write("@scriptc/libnode-linux-x64-gnu: skipped on this host\n");
|
||||
process.exit(0);
|
||||
}
|
||||
await buildLibnodePack({
|
||||
platform: "linux-x64-gnu",
|
||||
packageRoot: dirname(dirname(fileURLToPath(import.meta.url))),
|
||||
compress: process.argv.includes("--compress"),
|
||||
// Assemble from a restored libnode build only (CI test lanes); never
|
||||
// start a Node build.
|
||||
skipLibnode: process.argv.includes("--from-cache"),
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scriptc/node",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.5",
|
||||
"description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.",
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://scriptc.dev",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "scr_runtime.h"
|
||||
#ifdef SCR_NODE_ISLANDS
|
||||
#include "scr_node_bridge.h"
|
||||
#include "scr_node_island.h"
|
||||
#endif
|
||||
|
||||
#include <errno.h>
|
||||
@@ -1659,6 +1660,10 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) {
|
||||
uintptr_t guard = scr_stack_guard;
|
||||
if (to_fiber) SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)to_fiber->stack, SCR_FIBER_STACK));
|
||||
#endif
|
||||
#ifdef SCR_NODE_ISLANDS
|
||||
/* Leaving Node's stack keeps this frame and those above it live. */
|
||||
scr_node_island_leave_stack((uintptr_t)__builtin_frame_address(0));
|
||||
#endif
|
||||
#ifdef _WIN32
|
||||
/* SwitchToFiber snapshots the outgoing fiber's state inside its own
|
||||
* fiber object — `from` has nothing to record. */
|
||||
|
||||
@@ -92,6 +92,7 @@ void __sanitizer_start_switch_fiber(void **fake_stack_save, const void *bottom,
|
||||
void __sanitizer_finish_switch_fiber(void *fake_stack_save, const void **bottom_old,
|
||||
size_t *size_old);
|
||||
void __lsan_register_root_region(const void *p, size_t size) __attribute__((weak));
|
||||
void __asan_unpoison_memory_region(void const volatile *addr, size_t size);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -882,17 +883,103 @@ static void ni_trampoline(void) {
|
||||
}
|
||||
}
|
||||
|
||||
static uintptr_t ni_stack_start(void) {
|
||||
uintptr_t sp = (uintptr_t)__builtin_frame_address(0);
|
||||
return ni_stack != NULL && sp >= (uintptr_t)ni_stack && sp < (uintptr_t)ni_stack + NI_STACK
|
||||
? (uintptr_t)ni_stack + NI_STACK
|
||||
: 0;
|
||||
/* Whether `sp` lies on Node's stack. */
|
||||
static bool ni_stack_has(uintptr_t sp) {
|
||||
return ni_stack != NULL && sp > (uintptr_t)ni_stack && sp <= (uintptr_t)ni_stack + NI_STACK;
|
||||
}
|
||||
|
||||
/* The lowest address of Node's stack in use while runtime code Node's
|
||||
* frames called runs (a host callback below), 0 outside them: the
|
||||
* callback's frame, or lower, where that code switched to a fiber's stack
|
||||
* (scr_node_island_leave_stack) and left its own frames live. Runtime code
|
||||
* may resume an async fiber there, and the fiber may call into Node again:
|
||||
* that entry runs on Node's stack below this mark, so every frame of Node
|
||||
* stays on the one stack V8 scans (heap::base::Stack, bounded by
|
||||
* ni_stack_start), never on a fiber's stack beyond its bound. */
|
||||
static uintptr_t ni_mark = 0;
|
||||
|
||||
/* Host callbacks run runtime code from Node's frames: each records the
|
||||
* mark for its duration. */
|
||||
#define NI_MARK_BEGIN \
|
||||
uintptr_t ni_saved_mark = ni_mark; \
|
||||
do { \
|
||||
uintptr_t ni_here = (uintptr_t)__builtin_frame_address(0); \
|
||||
if (ni_stack_has(ni_here)) ni_mark = ni_here; \
|
||||
} while (0)
|
||||
#define NI_MARK_END (ni_mark = ni_saved_mark)
|
||||
|
||||
void scr_node_island_leave_stack(uintptr_t sp) {
|
||||
if (ni_mark != 0 && ni_stack_has(sp) && sp < ni_mark) ni_mark = sp;
|
||||
}
|
||||
|
||||
/* A nested entry: job(arg) on Node's stack below the mark, from a fiber. */
|
||||
static void (*ni_nested_job)(void *) = NULL;
|
||||
static void *ni_nested_arg = NULL;
|
||||
static ucontext_t *ni_nested_return = NULL;
|
||||
|
||||
#ifdef NI_ASAN
|
||||
static uintptr_t ni_nested_top = 0; /* the nested context's stack top */
|
||||
#endif
|
||||
|
||||
static void ni_nested_trampoline(void) {
|
||||
#ifdef NI_ASAN
|
||||
const void *caller_bottom;
|
||||
size_t caller_size;
|
||||
uintptr_t top = ni_nested_top;
|
||||
__sanitizer_finish_switch_fiber(NULL, &caller_bottom, &caller_size);
|
||||
#endif
|
||||
void (*job)(void *) = ni_nested_job;
|
||||
void *arg = ni_nested_arg;
|
||||
ucontext_t *back = ni_nested_return;
|
||||
job(arg);
|
||||
#ifdef NI_ASAN
|
||||
/* This context ends: no fake stack to keep. Its frame never returns, so
|
||||
* the redzones ASan poisoned around this frame's locals would stay in
|
||||
* the shadow of Node's stack, where Node's own frames (uninstrumented)
|
||||
* later trip over them. Linux's ASan clears a context's stack when it is
|
||||
* switched to (its swapcontext interceptor); macOS's has none, so this
|
||||
* frame's part of the stack is unpoisoned here. */
|
||||
uintptr_t low = ((uintptr_t)__builtin_frame_address(0) - 4096) & ~(uintptr_t)7;
|
||||
if (top > low) __asan_unpoison_memory_region((const void *)low, top - low);
|
||||
__sanitizer_start_switch_fiber(NULL, caller_bottom, caller_size);
|
||||
#endif
|
||||
setcontext(back);
|
||||
}
|
||||
|
||||
static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) {
|
||||
ucontext_t nested, caller;
|
||||
char *lo = ni_stack + NI_GUARD;
|
||||
/* Below the red zone and 16-byte aligned, as makecontext expects. */
|
||||
uintptr_t hi = (ni_mark - 512) & ~(uintptr_t)15;
|
||||
ni_make_context(&nested, lo, hi - (uintptr_t)lo, ni_nested_trampoline);
|
||||
#ifdef NI_ASAN
|
||||
ni_nested_top = hi;
|
||||
#endif
|
||||
ni_nested_job = job;
|
||||
ni_nested_arg = arg;
|
||||
ni_nested_return = &caller;
|
||||
uintptr_t guard = scr_stack_guard;
|
||||
SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)lo, hi - (uintptr_t)lo));
|
||||
#ifdef NI_ASAN
|
||||
void *caller_fake = NULL;
|
||||
__sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo);
|
||||
#endif
|
||||
swapcontext(&caller, &nested);
|
||||
#ifdef NI_ASAN
|
||||
const void *bottom;
|
||||
size_t size;
|
||||
__sanitizer_finish_switch_fiber(caller_fake, &bottom, &size);
|
||||
#endif
|
||||
SCR_STACK_GUARD_SET(guard);
|
||||
}
|
||||
|
||||
/* Runs job(arg) on Node's stack (directly when already there). */
|
||||
static void ni_on_node_stack(void (*job)(void *), void *arg) {
|
||||
if (ni_on_stack) {
|
||||
job(arg);
|
||||
if (ni_mark != 0 && !ni_stack_has((uintptr_t)__builtin_frame_address(0)))
|
||||
ni_on_node_stack_below_mark(job, arg);
|
||||
else
|
||||
job(arg);
|
||||
return;
|
||||
}
|
||||
if (ni_stack == NULL) {
|
||||
@@ -1008,8 +1095,7 @@ static uintptr_t ni_stack_limit(void) {
|
||||
/* The frame address, not a local's: under ASan's use-after-return
|
||||
* detection locals live on a fake stack elsewhere in memory. */
|
||||
uintptr_t sp = (uintptr_t)__builtin_frame_address(0);
|
||||
if (ni_stack != NULL && sp > (uintptr_t)ni_stack && sp <= (uintptr_t)ni_stack + NI_STACK)
|
||||
return (uintptr_t)(ni_stack + NI_GUARD) + NI_STACK / 8;
|
||||
if (ni_stack_has(sp)) return (uintptr_t)(ni_stack + NI_GUARD) + NI_STACK / 8;
|
||||
if (sp > ni_main_lo && sp <= ni_main_hi) {
|
||||
uintptr_t limit = ni_main_hi > 984 * 1024 ? ni_main_hi - 984 * 1024 : 0;
|
||||
return limit > ni_main_lo ? limit : ni_main_lo + 64 * 1024;
|
||||
@@ -1018,25 +1104,83 @@ static uintptr_t ni_stack_limit(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The start of Node's stack for callers on it (V8's stack-scan bound). */
|
||||
static uintptr_t ni_stack_start(void) {
|
||||
uintptr_t sp = (uintptr_t)__builtin_frame_address(0);
|
||||
return ni_stack_has(sp) ? (uintptr_t)ni_stack + NI_STACK : 0;
|
||||
}
|
||||
|
||||
/* The host callbacks that run runtime code, with the mark recorded. */
|
||||
static void ni_host_settle(uint32_t id, const char *outcome, size_t len) {
|
||||
NI_MARK_BEGIN;
|
||||
ni_settle(id, outcome, len);
|
||||
NI_MARK_END;
|
||||
}
|
||||
|
||||
static void ni_host_exit(int code) {
|
||||
NI_MARK_BEGIN; /* never returns */
|
||||
(void)ni_saved_mark;
|
||||
ni_exit(code);
|
||||
}
|
||||
|
||||
static void ni_host_run(uint32_t kind, uint32_t token) {
|
||||
NI_MARK_BEGIN;
|
||||
scr_loop_hosted_run(kind, token);
|
||||
NI_MARK_END;
|
||||
}
|
||||
|
||||
static void ni_host_io(void) {
|
||||
NI_MARK_BEGIN;
|
||||
scr_loop_hosted_io();
|
||||
NI_MARK_END;
|
||||
}
|
||||
|
||||
static void ni_host_microtasks_done(void) {
|
||||
NI_MARK_BEGIN;
|
||||
scr_loop_hosted_microtasks_done();
|
||||
NI_MARK_END;
|
||||
}
|
||||
|
||||
static void ni_host_listen(uint32_t event, const char *name, uint32_t listener, int add,
|
||||
int once) {
|
||||
NI_MARK_BEGIN;
|
||||
ni_listen(event, name, listener, add, once);
|
||||
NI_MARK_END;
|
||||
}
|
||||
|
||||
static const ScrNodeBridgeHost ni_host = {
|
||||
&ni_settle,
|
||||
&ni_exit,
|
||||
&scr_loop_hosted_run,
|
||||
&ni_host_settle,
|
||||
&ni_host_exit,
|
||||
&ni_host_run,
|
||||
&scr_loop_hosted_io_state,
|
||||
&scr_loop_hosted_io,
|
||||
&scr_loop_hosted_microtasks_done,
|
||||
&ni_listen,
|
||||
&ni_host_io,
|
||||
&ni_host_microtasks_done,
|
||||
&ni_host_listen,
|
||||
&ni_stack_limit,
|
||||
&ni_stack_start,
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
uint32_t event, listener;
|
||||
const char *name;
|
||||
int arg;
|
||||
} NiEmit;
|
||||
|
||||
static void ni_emit_job(void *p) {
|
||||
NiEmit *e = p;
|
||||
ni_bridge->emit(e->event, e->listener, e->name, e->arg);
|
||||
}
|
||||
|
||||
/* An island listener of a process event, on Node's stack (an exit listener
|
||||
* runs from the runtime's exit sequence, on whatever stack called it). */
|
||||
void scr_node_island_emit(uint32_t event, uint32_t listener, ScrStr *name, int arg) {
|
||||
if (ni_bridge == NULL) return;
|
||||
char *n = malloc(name->len + 1);
|
||||
if (n == NULL) ni_oom();
|
||||
memcpy(n, name->data, name->len);
|
||||
n[name->len] = '\0';
|
||||
ni_bridge->emit(event, listener, n, arg);
|
||||
NiEmit e = {event, listener, n, arg};
|
||||
ni_on_node_stack(ni_emit_job, &e);
|
||||
free(n);
|
||||
}
|
||||
|
||||
@@ -1069,7 +1213,75 @@ static int ni_run_loop(int continue_turn) {
|
||||
return a.code;
|
||||
}
|
||||
|
||||
/* The bridge as the hosted loop sees it: Node's loop runs on Node's stack. */
|
||||
/* The bridge as the hosted loop sees it. Every entry that runs JavaScript
|
||||
* (scheduling a tick, timer or immediate goes through the bootstrap's
|
||||
* helpers; a checkpoint drains queues; Node's loop) runs on Node's stack,
|
||||
* below the mark when it comes from an async fiber that a host callback
|
||||
* resumed, like every other entry: V8 scans only Node's stack, so no frame
|
||||
* of Node may live on a fiber's stack or the main thread's. */
|
||||
typedef struct {
|
||||
int op;
|
||||
uint32_t a, b;
|
||||
double d0, d1;
|
||||
int i0;
|
||||
uint32_t result;
|
||||
} NiHosted;
|
||||
|
||||
enum {
|
||||
NI_H_MICROTASK,
|
||||
NI_H_TICK,
|
||||
NI_H_TIMER,
|
||||
NI_H_TIMER_CLEAR,
|
||||
NI_H_TIMER_REF,
|
||||
NI_H_TIMER_REFRESH,
|
||||
NI_H_IMMEDIATE,
|
||||
NI_H_IMMEDIATE_CLEAR,
|
||||
NI_H_IMMEDIATE_REF,
|
||||
NI_H_CHECKPOINT,
|
||||
};
|
||||
|
||||
static void ni_hosted_job(void *p) {
|
||||
NiHosted *h = p;
|
||||
switch (h->op) {
|
||||
case NI_H_MICROTASK: ni_bridge->microtask(h->a); break;
|
||||
case NI_H_TICK: ni_bridge->tick(h->a); break;
|
||||
case NI_H_TIMER: h->result = ni_bridge->timer(h->d0, h->d1, h->i0, h->a); break;
|
||||
case NI_H_TIMER_CLEAR: ni_bridge->timer_clear(h->a); break;
|
||||
case NI_H_TIMER_REF: ni_bridge->timer_ref(h->a, h->i0); break;
|
||||
case NI_H_TIMER_REFRESH: ni_bridge->timer_refresh(h->a); break;
|
||||
case NI_H_IMMEDIATE: h->result = ni_bridge->immediate(h->i0, h->a); break;
|
||||
case NI_H_IMMEDIATE_CLEAR: ni_bridge->immediate_clear(h->a); break;
|
||||
case NI_H_IMMEDIATE_REF: ni_bridge->immediate_ref(h->a, h->i0); break;
|
||||
case NI_H_CHECKPOINT: ni_bridge->checkpoint(h->i0); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
static uint32_t ni_hosted_run(int op, uint32_t a, double d0, double d1, int i0) {
|
||||
NiHosted h = {op, a, 0, d0, d1, i0, 0};
|
||||
ni_on_node_stack(ni_hosted_job, &h);
|
||||
return h.result;
|
||||
}
|
||||
|
||||
static void ni_h_microtask(uint32_t token) { ni_hosted_run(NI_H_MICROTASK, token, 0, 0, 0); }
|
||||
static void ni_h_tick(uint32_t token) { ni_hosted_run(NI_H_TICK, token, 0, 0, 0); }
|
||||
static uint32_t ni_h_timer(double delay, double repeat, int ref, uint32_t token) {
|
||||
return ni_hosted_run(NI_H_TIMER, token, delay, repeat, ref);
|
||||
}
|
||||
static void ni_h_timer_clear(uint32_t h) { ni_hosted_run(NI_H_TIMER_CLEAR, h, 0, 0, 0); }
|
||||
static void ni_h_timer_ref(uint32_t h, int ref) { ni_hosted_run(NI_H_TIMER_REF, h, 0, 0, ref); }
|
||||
static void ni_h_timer_refresh(uint32_t h) { ni_hosted_run(NI_H_TIMER_REFRESH, h, 0, 0, 0); }
|
||||
static uint32_t ni_h_immediate(int ref, uint32_t token) {
|
||||
return ni_hosted_run(NI_H_IMMEDIATE, token, 0, 0, ref);
|
||||
}
|
||||
static void ni_h_immediate_clear(uint32_t h) { ni_hosted_run(NI_H_IMMEDIATE_CLEAR, h, 0, 0, 0); }
|
||||
static void ni_h_immediate_ref(uint32_t h, int ref) {
|
||||
ni_hosted_run(NI_H_IMMEDIATE_REF, h, 0, 0, ref);
|
||||
}
|
||||
static void ni_h_checkpoint(int microtasks_first) {
|
||||
ni_hosted_run(NI_H_CHECKPOINT, 0, 0, 0, microtasks_first);
|
||||
}
|
||||
|
||||
static ScrNodeBridge ni_hosted;
|
||||
|
||||
/* ── loading Node ─────────────────────────────────────────────────────── */
|
||||
@@ -1140,6 +1352,16 @@ static void ni_start(void) {
|
||||
scr_atexit(ni_stop_atexit);
|
||||
ni_hosted = *bridge;
|
||||
ni_hosted.run_loop = &ni_run_loop;
|
||||
ni_hosted.microtask = &ni_h_microtask;
|
||||
ni_hosted.tick = &ni_h_tick;
|
||||
ni_hosted.timer = &ni_h_timer;
|
||||
ni_hosted.timer_clear = &ni_h_timer_clear;
|
||||
ni_hosted.timer_ref = &ni_h_timer_ref;
|
||||
ni_hosted.timer_refresh = &ni_h_timer_refresh;
|
||||
ni_hosted.immediate = &ni_h_immediate;
|
||||
ni_hosted.immediate_clear = &ni_h_immediate_clear;
|
||||
ni_hosted.immediate_ref = &ni_h_immediate_ref;
|
||||
ni_hosted.checkpoint = &ni_h_checkpoint;
|
||||
scr_loop_host(&ni_hosted, since);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ void scr_loop_hosted_io_state(struct ScrNodeIoState *state);
|
||||
void scr_loop_hosted_io(void);
|
||||
void scr_loop_hosted_microtasks_done(void);
|
||||
|
||||
/* Runtime code switches stacks (scr_switch) at `sp`: when that is Node's
|
||||
* stack, the frames above stay live until it switches back, so a call
|
||||
* into Node.js from the other stack runs below them. */
|
||||
void scr_node_island_leave_stack(uintptr_t sp);
|
||||
|
||||
/* Process events island code listens to (SCR_NODE_EVENT_* of the bridge
|
||||
* ABI). Their listeners sit in the runtime's registries (the
|
||||
* SCR_NODE_ISLANDS build of scr_events.c) at their registration position,
|
||||
|
||||
Generated
+10
@@ -236,6 +236,16 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../threads
|
||||
|
||||
packages/libnode-common: {}
|
||||
|
||||
packages/libnode-darwin-arm64: {}
|
||||
|
||||
packages/libnode-darwin-x64: {}
|
||||
|
||||
packages/libnode-linux-arm64-gnu: {}
|
||||
|
||||
packages/libnode-linux-x64-gnu: {}
|
||||
|
||||
packages/llvm-darwin-arm64: {}
|
||||
|
||||
packages/llvm-darwin-x64: {}
|
||||
|
||||
@@ -48,6 +48,10 @@ for (const pkg of [
|
||||
"llvm-linux-x64-musl",
|
||||
"llvm-linux-arm64-musl",
|
||||
"llvm-win32-x64-msvc",
|
||||
"libnode-linux-x64-gnu",
|
||||
"libnode-linux-arm64-gnu",
|
||||
"libnode-darwin-arm64",
|
||||
"libnode-darwin-x64",
|
||||
]) {
|
||||
const path = manifest(pkg);
|
||||
const json = read(path);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Allocates short-lived objects in rounds and keeps one per round alive
|
||||
// across calls, so collections run inside the calls and must keep it.
|
||||
const keep = [];
|
||||
|
||||
exports.churn = function (rounds) {
|
||||
let count = 0;
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
const batch = [];
|
||||
for (let i = 0; i < 200000; i++) batch.push({ i, s: "x" + i });
|
||||
count += batch.length;
|
||||
keep.push(batch[r]);
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
exports.kept = function () {
|
||||
let sum = 0;
|
||||
for (const item of keep) sum += item.i;
|
||||
return keep.length * 1000000 + sum;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// @requires-libnode
|
||||
// Garbage collections inside calls into Node.js: calls that allocate enough
|
||||
// for V8 to collect while JavaScript runs, from the main program and from an
|
||||
// async function that a timer on Node's loop resumes.
|
||||
import { call, callSync } from "@scriptc/node";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("sync", callSync<number>("./alloc.cjs", "churn", 20));
|
||||
console.log("async", await call<number>("./alloc.cjs", "churn", 10));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await sleep(1);
|
||||
console.log("after timer", i, callSync<number>("./alloc.cjs", "churn", 20));
|
||||
}
|
||||
const a = call<number>("./alloc.cjs", "churn", 5);
|
||||
const b = call<number>("./alloc.cjs", "churn", 5);
|
||||
console.log("both", await a, await b);
|
||||
console.log("kept", callSync<number>("./alloc.cjs", "kept"));
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,8 @@
|
||||
// Returns its argument after using some of Node's stack.
|
||||
function down(n, depth) {
|
||||
return depth === 0 ? n : down(n, depth - 1);
|
||||
}
|
||||
|
||||
exports.echo = function (n) {
|
||||
return down(n, 500);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// @requires-libnode
|
||||
// A timer on Node's loop runs runtime code deep below the callback's own
|
||||
// frame, and an async function it starts calls into Node.js before its
|
||||
// first await: that call's frames go below the deepest live runtime frame
|
||||
// on Node's stack, so the frames above it return intact.
|
||||
import { callSync } from "@scriptc/node";
|
||||
|
||||
async function viaNode(n: number): Promise<number> {
|
||||
const echoed = callSync<number>("./echo.cjs", "echo", n);
|
||||
await null;
|
||||
return echoed;
|
||||
}
|
||||
|
||||
const started: Promise<number>[] = [];
|
||||
function deep(depth: number, n: number): number {
|
||||
const local = [depth, n, depth * n];
|
||||
if (depth > 0) return deep(depth - 1, n) + local[2]!;
|
||||
started.push(viaNode(n));
|
||||
return local[0]! + local[1]!;
|
||||
}
|
||||
|
||||
console.log("loaded", callSync<number>("./echo.cjs", "echo", 1));
|
||||
setTimeout(() => {
|
||||
console.log("deep", deep(200, 3));
|
||||
void Promise.all(started).then((values) => console.log("echoed", values.join(",")));
|
||||
}, 0);
|
||||
@@ -2,7 +2,9 @@
|
||||
// inside a scratch directory derived from THIS process's own identity: the
|
||||
// harness runs Node and the native binary CONCURRENTLY in one cwd, and
|
||||
// argv[1]'s trailing segment differs between the two (script name vs binary
|
||||
// name), so the sides never touch the same paths.
|
||||
// name), so the sides never touch the same paths. The pid keeps two Node
|
||||
// oracles apart too: the differential and LLVM differential suites can run
|
||||
// the same program at once in one shard.
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3300, "san": 23000 }
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 3000, "san": 20000 }
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Node island tests need a libnode pack (SCRIPTC_LIBNODE). The Linux x64 and
|
||||
* macOS CI test lanes provide one (.github/actions/libnode-dev-pack) and
|
||||
* must run these tests, so there a missing pack is a failure, never a skip.
|
||||
* Other platforms have no pack yet (NI-13), and a local run without a pack
|
||||
* macOS CI test lanes provide one (.github/actions/libnode) and must run
|
||||
* these tests, so there a missing pack is a failure, never a skip. Other
|
||||
* CI platforms do not provision a pack yet, and a local run without a pack
|
||||
* skips. */
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function libnodePack(): string | null {
|
||||
if (pack !== undefined && pack !== "") return pack;
|
||||
if (libnodeRequired)
|
||||
throw new Error(
|
||||
"Node island tests need a libnode pack in this CI lane, and SCRIPTC_LIBNODE is not set (.github/actions/libnode-dev-pack provides it)",
|
||||
"Node island tests need a libnode pack in this CI lane, and SCRIPTC_LIBNODE is not set (.github/actions/libnode provides it)",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ describe("surface manifest generation", () => {
|
||||
expect(readJson("packages/llvm-linux-x64-musl/package.json").version).toBe(releaseVersion);
|
||||
expect(readJson("packages/llvm-linux-arm64-musl/package.json").version).toBe(releaseVersion);
|
||||
expect(readJson("packages/llvm-win32-x64-msvc/package.json").version).toBe(releaseVersion);
|
||||
for (const platform of ["linux-x64-gnu", "linux-arm64-gnu", "darwin-arm64", "darwin-x64"]) {
|
||||
expect(readJson(`packages/libnode-${platform}/package.json`).version).toBe(releaseVersion);
|
||||
}
|
||||
});
|
||||
|
||||
test("schema: ids unique and sorted, enums valid, codes on every non-static entry", () => {
|
||||
|
||||
Reference in New Issue
Block a user