Bound V8's stack scan by Node's own stack in Node islands (#1898)

This commit is contained in:
Chris Tate
2026-10-26 20:11:41 -05:00
committed by GitHub
60 changed files with 2759 additions and 121 deletions
@@ -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"
+64
View File
@@ -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 }}"
+4 -4
View File
@@ -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
+145
View File
@@ -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
+6
View File
@@ -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/
+16
View File
@@ -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.
+4 -1
View File
@@ -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 });
+55
View File
@@ -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 () => {
+2 -2
View File
@@ -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", () => {
+65 -12
View File
@@ -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 {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6822-node-island-gc/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6823-node-island-nested-stack/main.ts"
],
"diags": []
}
+108 -40
View File
@@ -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)}`);
}
+3 -2
View File
@@ -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";
+7
View File
@@ -0,0 +1,7 @@
{
"nodeVersion": "24.22.0",
"tarballSha256": "41f97cff2c666f1f8293dc5f85c65e8dd605c16b43fd9febd49ede32ee1e0329",
"moduleVersion": 137,
"configureFlags": ["--shared", "--ninja"],
"icu": "full"
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@scriptc/libnode-common",
"version": "0.0.1",
"private": true,
"type": "module"
}
+336
View File
@@ -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"),
);
+90
View File
@@ -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"));
}
+108
View File
@@ -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`);
}
+205
View File
@@ -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;
}
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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"}}`,
+90
View File
@@ -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.
+35
View File
@@ -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 -1
View File
@@ -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",
+5
View File
@@ -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. */
+238 -16
View File
@@ -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);
}
+5
View File
@@ -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,
+10
View File
@@ -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: {}
+4
View File
@@ -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;
};
+24
View File
@@ -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);
+3 -1
View File
@@ -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 }
+4 -4
View File
@@ -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;
}
+3
View File
@@ -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", () => {