From df869f28b016998d96e3e2fe6e03cadc67d1e095 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 12:16:23 -0700 Subject: [PATCH] Run Node islands on macOS, and test them in the macOS CI lanes macOS binds libnode's own calls to v8::base::Stack inside libnode, so the bridge's definitions cannot give V8 Node's stack as they do on Linux; V8 took the main thread's stack start and its conservative scan faulted in the gap above Node's stack. While Node starts on its stack, the main thread's pthread record now names that stack, which V8 reads once and keeps. macOS's makecontext clears the whole stack it is given, so it gets only the top slice of Node's stack (no 64 MiB pass per context). CI: dev-pack.json pins mirrored darwin-arm64 and darwin-x64 libnode builds of the pinned Node 24.22.0 from the libnode-packs workflow, the dev-pack action serves either platform, and the macOS lanes provision it, so island tests fail rather than skip on macOS CI. The arm64 LLVM job runs the island harness; the Intel job runs it and the island corpus. bridge-smoke loads the .dylib bridge on macOS. Co-Authored-By: Claude Opus 5.5 --- .github/actions/libnode-dev-pack/action.yml | 17 ++-- .github/workflows/ci.yml | 21 ++++- .../libnode-common/bridge/scr_node_bridge.cc | 4 +- packages/libnode-common/dev-pack.json | 36 ++++++++- packages/libnode-common/test/bridge-smoke.c | 8 +- packages/libnode-common/test/bridge.test.ts | 2 +- packages/runtime/src/scr_node_island.c | 78 +++++++++++++++++-- tests/harness/libnode.ts | 12 +-- 8 files changed, 156 insertions(+), 22 deletions(-) diff --git a/.github/actions/libnode-dev-pack/action.yml b/.github/actions/libnode-dev-pack/action.yml index d6a1efbdb..0619b1c45 100644 --- a/.github/actions/libnode-dev-pack/action.yml +++ b/.github/actions/libnode-dev-pack/action.yml @@ -1,19 +1,23 @@ -name: libnode development pack (Linux x64) +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 - (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. + 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-linux-x64-${{ 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') }} + 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 @@ -21,6 +25,7 @@ runs: - 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/libscr_node_bridge.so" + test -f "$RUNNER_TEMP/libnode-dev-pack/$bridge" echo "SCRIPTC_LIBNODE=$RUNNER_TEMP/libnode-dev-pack" >> "$GITHUB_ENV" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eaaef8d5..1264a0797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,6 +284,9 @@ jobs: path: ${{ runner.temp }} - run: tar -xzf "$RUNNER_TEMP/native-darwin-arm64.tar.gz" - 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 - name: Tests (${{ (github.event_name != 'schedule' && inputs.macos_suite != 'full') && 'darwin suite' || 'corpus and integration suites' }}) run: node scripts/ci-test.mjs @@ -704,6 +707,12 @@ jobs: path: ${{ runner.temp }} - run: tar -xzf "$RUNNER_TEMP/native-darwin-arm64.tar.gz" - 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 + - name: Node island harness + if: matrix.shard == 1 + run: pnpm test tests/harness/node-island packages/libnode-common - name: No-clang assembly/object contract if: matrix.shard == 1 run: pnpm test packages/cli/test/native-output.test.ts @@ -761,7 +770,8 @@ jobs: if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.macos == 'true' }} name: test (macOS x64 LLVM artifacts, no clang) runs-on: macos-15-intel - timeout-minutes: 20 + # The Node island tests take most of it. + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -776,6 +786,15 @@ jobs: with: target: darwin-x64 - 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 + - name: Node islands (Intel) + run: >- + pnpm test tests/harness/node-island packages/libnode-common + && pnpm test tests/harness/differential.test.ts -t ' (68[0-9][0-9]|7661|7662)-' + env: + SCRIPTC_TEST_WORKERS: "3" - name: Intel Mach-O no-clang object contract run: | mkdir -p "$RUNNER_TEMP/traps" diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index ef60851d6..7365b08dc 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -323,7 +323,9 @@ const ScrNodeBridge kBridge = { // libnode's own answer (RTLD_NEXT). Without them a garbage collection that // scans the stack while JavaScript runs on the runtime's stack walks from // that stack up to the main thread's and faults in the unmapped gap. -// (macOS binds libnode's calls inside libnode; its packs need another way.) +// macOS binds libnode's calls inside libnode instead: there the runtime +// points the main thread's pthread stack record at its stack while Node +// starts (ni_pthread_stack_enter in scr_node_island.c). #define SCR_V8_STACK_START(fn, symbol) \ extern "C" __attribute__((visibility("default"))) void *fn() __asm__(symbol); \ extern "C" void *fn() { \ diff --git a/packages/libnode-common/dev-pack.json b/packages/libnode-common/dev-pack.json index 6c521cbb6..8cc43fb7d 100644 --- a/packages/libnode-common/dev-pack.json +++ b/packages/libnode-common/dev-pack.json @@ -1,5 +1,5 @@ { - "comment": "Temporary libnode source for CI until the NI-13 packs (@scriptc/libnode-) exist: the community dockfries/libnode build of Node 24.22.1 and Node's official headers for the bridge. The first URL is scriptc's CI-only mirror (prerelease libnode-dev-24.22.1), the others the original locations; fetch-dev-pack.mjs tries them in order and verifies the sha256.", + "comment": "Temporary libnode source for CI until the NI-13 packs (@scriptc/libnode-) are published. linux-x64: the community dockfries/libnode build of Node 24.22.1. darwin-arm64 and darwin-x64: libnode of the pinned Node 24.22.0 from the libnode-packs workflow (NI-13), mirrored. Each with Node's official headers of the same release for the bridge. The first URL is scriptc's CI-only mirror (prerelease libnode-dev-24.22.1), the others the original locations; fetch-dev-pack.mjs tries them in order and verifies the sha256.", "nodeVersion": "24.22.1", "linux-x64": { "libnode": { @@ -18,5 +18,39 @@ "sha256": "322e0a27db9088093ab8347bdc8c7e0aaa21dc9ed548390bf06b394494c09267", "dir": "node-v24.22.1/include/node" } + }, + "darwin-arm64": { + "libnode": { + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/libnode-darwin-arm64.tar.gz" + ], + "sha256": "3f06e5d0a0e1054f5fb4aab578a81908b3f94db4c2db64ceb9d3273c69d1ca3e", + "file": "libnode.137.dylib" + }, + "headers": { + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/node-v24.22.0-headers.tar.gz", + "https://nodejs.org/dist/v24.22.0/node-v24.22.0-headers.tar.gz" + ], + "sha256": "00e9f08b9057f9f5428387a5ffd496e53f274565b40b144b7ba876700755352a", + "dir": "node-v24.22.0/include/node" + } + }, + "darwin-x64": { + "libnode": { + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/libnode-darwin-x64.tar.gz" + ], + "sha256": "17b8ee5bc6610da3b839041a992a4c16dc740d6f256712214d65b696d4a48d39", + "file": "libnode.137.dylib" + }, + "headers": { + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/node-v24.22.0-headers.tar.gz", + "https://nodejs.org/dist/v24.22.0/node-v24.22.0-headers.tar.gz" + ], + "sha256": "00e9f08b9057f9f5428387a5ffd496e53f274565b40b144b7ba876700755352a", + "dir": "node-v24.22.0/include/node" + } } } diff --git a/packages/libnode-common/test/bridge-smoke.c b/packages/libnode-common/test/bridge-smoke.c index b4437333a..f05c96d91 100644 --- a/packages/libnode-common/test/bridge-smoke.c +++ b/packages/libnode-common/test/bridge-smoke.c @@ -13,6 +13,12 @@ #include "scr_node_bridge.h" #include "scr_node_island_host.h" +#ifdef __APPLE__ +#define BRIDGE_FILE "libscr_node_bridge.dylib" +#else +#define BRIDGE_FILE "libscr_node_bridge.so" +#endif + static int settled = 0; static void settle(uint32_t id, const char *outcome, size_t len) { @@ -44,7 +50,7 @@ static void call(const ScrNodeBridge *b, uint32_t id, int sync, const char *modu int main(int argc, char **argv) { if (argc != 3) return 2; char path[4096]; - snprintf(path, sizeof path, "%s/libscr_node_bridge.so", argv[1]); + snprintf(path, sizeof path, "%s/" BRIDGE_FILE, argv[1]); void *lib = dlopen(path, RTLD_NOW | RTLD_GLOBAL); if (lib == NULL) { fprintf(stderr, "%s\n", dlerror()); diff --git a/packages/libnode-common/test/bridge.test.ts b/packages/libnode-common/test/bridge.test.ts index 4c37b31a3..1fa2415c6 100644 --- a/packages/libnode-common/test/bridge.test.ts +++ b/packages/libnode-common/test/bridge.test.ts @@ -1,7 +1,7 @@ /* The libnode bridge against a real pack, without the compiler: the C host * in bridge-smoke.c loads the pack the way scr_node_island.c does and makes * calls through the bridge with the runtime's embedded bootstrap. Needs a - * pack in SCRIPTC_LIBNODE; required in the Linux x64 CI lanes + * pack in SCRIPTC_LIBNODE; required in the Linux x64 and macOS CI lanes * (tests/harness/libnode.ts). */ import { execFile } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; diff --git a/packages/runtime/src/scr_node_island.c b/packages/runtime/src/scr_node_island.c index e32f8da41..deefc303e 100644 --- a/packages/runtime/src/scr_node_island.c +++ b/packages/runtime/src/scr_node_island.c @@ -52,6 +52,7 @@ #include #include #include +#include #include #if defined(__linux__) #include @@ -821,6 +822,28 @@ static const void *ni_caller_bottom = NULL; static size_t ni_caller_size = 0; #endif +/* A context that runs fn on the stack [lo, lo + size), from its top. + * macOS's makecontext clears the whole stack it is given, which costs a + * pass over (and commits) every page of it on each call: there it is given + * only the top slice. The context starts at the same address either way, + * and its frames grow below the slice as they would anywhere on the + * stack; nothing reads uc_stack after makecontext, and the stack's bound + * is the runtime's stack guard (and the guard page), not uc_stack. */ +static void ni_make_context(ucontext_t *ctx, char *lo, size_t size, void (*fn)(void)) { + if (getcontext(ctx) != 0) scr_trap("scriptc: getcontext failed\n"); +#if defined(__APPLE__) + size_t slice = (size_t)64 * 1024; + if (size > slice) { + lo += size - slice; + size = slice; + } +#endif + ctx->uc_stack.ss_sp = lo; + ctx->uc_stack.ss_size = size; + ctx->uc_link = NULL; + makecontext(ctx, fn, 0); +} + static void ni_trampoline(void) { for (;;) { #ifdef NI_ASAN @@ -854,11 +877,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) { #ifdef NI_ASAN if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK); #endif - if (getcontext(&ni_node_ctx) != 0) scr_trap("scriptc: getcontext failed\n"); - ni_node_ctx.uc_stack.ss_sp = ni_stack + NI_GUARD; - ni_node_ctx.uc_stack.ss_size = NI_STACK - NI_GUARD; - ni_node_ctx.uc_link = NULL; - makecontext(&ni_node_ctx, ni_trampoline, 0); + ni_make_context(&ni_node_ctx, ni_stack + NI_GUARD, NI_STACK - NI_GUARD, ni_trampoline); } ni_job = job; ni_job_arg = arg; @@ -884,12 +903,61 @@ typedef struct { char *error; } NiStart; +#if defined(__APPLE__) +/* V8 takes each thread's stack start (v8::base::Stack, the bound of its + * conservative stack scan and of its stack limit) from + * pthread_get_stackaddr_np the first time it asks, and keeps it for the + * thread's life. On Linux the bridge's definitions of those functions + * answer for libnode with Node's stack; macOS binds libnode's own calls + * inside libnode, so they cannot. While Node starts on its stack (where + * V8 first asks, and where every frame of Node runs), the main thread's + * pthread record names Node's stack instead: its stack address and the + * bottom pthread_get_stacksize_np derives from it, found by value next + * to each other in the record and checked through the public accessors. + * The record is restored once Node has started. */ +static void **ni_pthread_stack = NULL; +static void *ni_pthread_saved[2]; + +static void ni_pthread_stack_enter(uintptr_t start, size_t size) { + pthread_t self = pthread_self(); + void *addr = pthread_get_stackaddr_np(self); + void *bottom = (char *)addr - pthread_get_stacksize_np(self); + void **record = (void **)(void *)self; + for (size_t i = 0; i + 1 < 64; i++) { + if (record[i] != addr || record[i + 1] != bottom) continue; + ni_pthread_saved[0] = record[i]; + ni_pthread_saved[1] = record[i + 1]; + record[i] = (void *)start; + record[i + 1] = (void *)(start - size); + if (pthread_get_stackaddr_np(self) == (void *)start) { + ni_pthread_stack = &record[i]; + return; + } + record[i] = ni_pthread_saved[0]; + record[i + 1] = ni_pthread_saved[1]; + } +} + +static void ni_pthread_stack_leave(void) { + if (ni_pthread_stack == NULL) return; + ni_pthread_stack[0] = ni_pthread_saved[0]; + ni_pthread_stack[1] = ni_pthread_saved[1]; + ni_pthread_stack = NULL; +} +#endif + static void ni_start_job(void *p) { NiStart *a = p; /* JavaScript may use Node's stack down to a reserve above its guard page * (native frames below V8's limit, the runtime's stack guard). */ uintptr_t limit = (uintptr_t)(ni_stack + NI_GUARD) + NI_STACK / 8; +#if defined(__APPLE__) + ni_pthread_stack_enter((uintptr_t)ni_stack + NI_STACK, NI_STACK - NI_GUARD); +#endif a->error = ni_bridge->start(&ni_host, a->bootstrap, a->argv0, limit); +#if defined(__APPLE__) + ni_pthread_stack_leave(); +#endif } typedef struct { diff --git a/tests/harness/libnode.ts b/tests/harness/libnode.ts index 2c2e4b460..d3941631e 100644 --- a/tests/harness/libnode.ts +++ b/tests/harness/libnode.ts @@ -1,7 +1,8 @@ -/* Node island tests need a libnode pack (SCRIPTC_LIBNODE). The Linux x64 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 skips. */ +/* 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 + * skips. */ import { readFileSync } from "node:fs"; /** `// @requires-libnode` in a corpus program's two-line directive head. */ @@ -16,8 +17,7 @@ export const libnodeRequired = process.env["CI"] !== undefined && process.env["CI"] !== "" && process.env["CI"] !== "false" && - process.platform === "linux" && - process.arch === "x64"; + ((process.platform === "linux" && process.arch === "x64") || process.platform === "darwin"); /** The configured pack, or null. Throws where CI requires one. */ export function libnodePack(): string | null {