Exit cleanly with Node loaded and keep nested entries on Node's stack (NI-7c, NI-3b) (#1905)

This commit is contained in:
Chris Tate
2026-10-28 23:43:21 -05:00
committed by GitHub
15 changed files with 488 additions and 6 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6870-node-island-server-fibers/main.ts"
],
"diags": []
}
@@ -195,6 +195,26 @@ bool TakeFunction(Isolate *isolate, Local<Context> context, Local<Object> from,
return true;
}
// OpenSSL registers OPENSSL_cleanup with atexit on its first
// initialization. In a scriptc program that loaded Node, threads Node owns
// can still be inside OpenSSL when the process's exit handlers run: the
// threads node:tls starts to parse the root certificates (long with
// NODE_USE_SYSTEM_CA=1; RunLoop waits for them, but an exit that does not
// end the loop does not) are joined only by Node's per-process teardown,
// which cannot run here (see Stop). OPENSSL_cleanup
// freed OpenSSL's locks and method stores under them (SIGSEGV/SIGABRT at
// exit in up to half the runs, NI-7c). Initializing OpenSSL first with
// OPENSSL_INIT_NO_ATEXIT keeps that handler from being registered: OpenSSL's
// process-lifetime state is left to process exit, like the rest of Node's
// per-process state. Packs whose libnode does not export the symbol keep
// OpenSSL's default.
void KeepOpenSSLAtExit() {
using InitCrypto = int (*)(uint64_t, const void *);
constexpr uint64_t kNoAtExit = 0x00080000L; // OPENSSL_INIT_NO_ATEXIT (OpenSSL 3)
auto init = reinterpret_cast<InitCrypto>(dlsym(RTLD_DEFAULT, "OPENSSL_init_crypto"));
if (init != nullptr) init(kNoAtExit, nullptr);
}
uint32_t ExportIdOf(Isolate *isolate, Local<Context> context, Local<Value> v);
// isExport(value): whether a value is the representative of a native value
@@ -214,6 +234,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar
Dl_info info;
if (dladdr(reinterpret_cast<void *>(&napi_create_function), &info) && info.dli_fname)
dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD | RTLD_GLOBAL);
KeepOpenSSLAtExit();
std::vector<std::string> args = {argv0};
// The program is not a node process: NODE_OPTIONS from its environment
// does not apply, and Node leaves the process's resource limits and code
@@ -433,10 +454,18 @@ void OnCap(uv_timer_t *) {
SyncIo(SCR_NODE_IO_JOBS);
}
void OnPrepare(uv_prepare_t *) { SyncIo(SCR_NODE_IO_PREPARE); }
// Before each poll phase, while the runtime has live work: one pass over
// its dispatch stations, as the runtime's own loop makes one per turn.
// Some of their work no fd announces (a server's deferred 'listening' or
// 'close', a socket's queued writes), so waiting for an fd could block the
// loop forever with that work pending.
void OnPrepare(uv_prepare_t *handle) {
if (uv_has_ref(reinterpret_cast<uv_handle_t *>(handle))) Dispatch();
SyncIo(SCR_NODE_IO_PREPARE);
}
// The end of an iteration: the next one may dispatch again (from its idle
// phase when work is ready, else from its poll phase).
// or prepare phase, else from its poll phase).
void OnCheck(uv_check_t *) {
g_dispatched = false;
SyncIo(SCR_NODE_IO_CHECK);
@@ -616,6 +645,29 @@ void Checkpoint(int microtasks_first) {
node::CallbackScope scope(e.isolate, Object::New(e.isolate), node::async_context{0, 0});
}
// Loading node:tls starts threads that parse the root certificates into
// function-local statics (bundled, NODE_EXTRA_CA_CERTS, and the system
// store with NODE_USE_SYSTEM_CA=1, which takes long). Only Node's
// CleanupCachedRootCertificates joins them. Once exit() begins, the
// statics' destructors (registered when the threads initialize them, so
// they run before any handler the program registered earlier) free the
// vectors under a thread that is still parsing, and OpenSSL's state goes
// away under it: heap corruption and SIGSEGV/SIGABRT at exit (NI-7c). So
// when the loop is done, before the program returns, the bridge waits for
// them: building a root store reads the same statics under the same
// conditions, and initializing a static blocks until the thread that runs
// it is done. A store built earlier means the statics were initialized
// then. Programs that never loaded node:tls do nothing here.
void AwaitCertificateLoading(Enter &e) {
TryCatch try_catch(e.isolate);
static const char kSource[] =
"(() => { try { if (process.moduleLoadList.includes('NativeModule tls'))"
" process.getBuiltinModule('node:tls').createSecureContext(); } catch {} })()";
Local<v8::Script> script;
if (v8::Script::Compile(e.context, Utf8(e.isolate, kSource)).ToLocal(&script))
(void)script->Run(e.context);
}
int RunLoop(int continue_turn) {
Enter e;
// The rest of the turn the switch interrupted: libuv's UV_RUN_ONCE skips
@@ -624,7 +676,9 @@ int RunLoop(int continue_turn) {
uv_run(g_setup->event_loop(), UV_RUN_ONCE);
g_platform->DrainTasks(e.isolate);
}
return node::SpinEventLoop(g_setup->env()).FromMaybe(0);
int code = node::SpinEventLoop(g_setup->env()).FromMaybe(0);
AwaitCertificateLoading(e);
return code;
}
// An island listener of a process event: through MakeCallback, so a throw
@@ -653,9 +707,16 @@ void Stop() {
delete g_locker;
g_locker = nullptr;
g_setup.reset();
// As Node's own exit does (DefaultProcessExitHandlerInternal): join
// libuv's thread pool, whose work (pbkdf2, key generation, ...) may be
// inside OpenSSL, before the process's exit handlers clean OpenSSL up.
uv_library_shutdown();
v8::V8::Dispose();
v8::V8::DisposePlatform();
node::TearDownOncePerProcess();
// Node's per-process teardown is left to process exit: Stop runs from an
// exit handler registered when Node started, after the destructors of
// the certificate statics (registered later) already ran, so its
// certificate cleanup would free them a second time.
}
+68
View File
@@ -1701,9 +1701,26 @@ static void *scr_win_self(void) {
}
#endif
#ifdef SCR_NODE_ISLANDS
/* The lowest frame of Node's stack that is still live while a fiber runs:
* where runtime code on Node's stack (a job or host callback, however deep
* it went) last switched to a fiber. A call into Node from that fiber runs
* on Node's stack below it (scr_node_island.c), never over those frames.
* 0: none. scr_node_stack_has (set when Node starts) tells Node's stack. */
uintptr_t scr_node_stack_low = 0;
bool (*scr_node_stack_has)(uintptr_t sp) = NULL;
#endif
static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) {
#ifdef SCR_ASAN_FIBERS
ScrFiber *from_fiber = scr_current;
#endif
#ifdef SCR_NODE_ISLANDS
uintptr_t node_low = scr_node_stack_low;
if (to_fiber != NULL && scr_node_stack_has != NULL) {
uintptr_t here = (uintptr_t)__builtin_frame_address(0);
if (scr_node_stack_has(here)) scr_node_stack_low = here;
}
#endif
scr_current = to_fiber;
scr_exc_swap_cell(to_fiber ? &to_fiber->exc : NULL);
@@ -1756,8 +1773,59 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) {
#if !defined(_WIN32) && !defined(__wasi__)
SCR_STACK_GUARD_SET(guard);
#endif
#ifdef SCR_NODE_ISLANDS
scr_node_stack_low = node_low; /* back on the stack that switched away */
#endif
}
#ifdef SCR_NODE_ISLANDS
/* A nested entry into Node (scr_node_island.c) runs on Node's stack in a
* context of its own while the fiber that made the call stays suspended in
* it. Runtime code that Node calls back there runs as the loop's code does:
* no current fiber, the main exception cell and ALS slot. It may resume
* other fibers, which saves the resumer's context in scr_loop_ctx (and,
* under ASan, its fake stack in the main slot); those belong to whoever
* resumed the calling fiber, so they are saved here and put back when the
* nested entry returns, or the calling fiber's next park would return to a
* context that no longer exists. */
typedef struct {
ScrFiber *current;
ScrAlsCtx **als;
ScrCtx loop_ctx;
#ifdef SCR_ASAN_FIBERS
void *main_fake;
#endif
} ScrNestedSave;
void *scr_loop_nested_enter(void) {
ScrNestedSave *s = malloc(sizeof *s);
if (s == NULL) scr_oom();
s->current = scr_current;
s->als = scr_als_active;
s->loop_ctx = scr_loop_ctx;
#ifdef SCR_ASAN_FIBERS
s->main_fake = scr_main_fake_stack;
scr_main_fake_stack = NULL;
#endif
scr_current = NULL;
scr_exc_swap_cell(NULL);
scr_als_active = &scr_als_main_slot;
return s;
}
void scr_loop_nested_leave(void *save) {
ScrNestedSave *s = save;
scr_loop_ctx = s->loop_ctx;
#ifdef SCR_ASAN_FIBERS
scr_main_fake_stack = s->main_fake;
#endif
scr_current = s->current;
scr_exc_swap_cell(s->current ? &s->current->exc : NULL);
scr_als_active = s->als;
free(s);
}
#endif
static void scr_promise_settle_wake(ScrPromise *p);
/* Copies src's settlement into a still-pending dst (payload RETAINED —
+31 -2
View File
@@ -93,6 +93,25 @@ void __sanitizer_finish_switch_fiber(void *fake_stack_save, const void **bottom_
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);
/* LeakSanitizer's report at exit, with Node loaded: Node keeps its root
* certificate stores (the bundled roots, the system's with
* NODE_USE_SYSTEM_CA, the extra ones of NODE_EXTRA_CA_CERTS) for the
* process's life and never frees them, and OpenSSL keeps the compression
* methods it loads with its default library context. Only those loaders
* are suppressed; a leak anywhere else, in libnode or in the program,
* still reports. */
__attribute__((used)) const char *__lsan_default_suppressions(void) {
return "leak:node::crypto::LoadCACertificates\n"
"leak:node::crypto::GetBundledRootCertificates\n"
"leak:node::crypto::LoadSystemCACertificates\n"
"leak:node::crypto::GetExtraCACertificates\n"
"leak:ossl_load_builtin_compressions\n";
}
/* Without the table of suppressions used, which LeakSanitizer otherwise
* prints to stderr at every exit (LSAN_OPTIONS still overrides it). */
__attribute__((used)) const char *__lsan_default_options(void) { return "print_suppressions=0"; }
#endif
#endif
@@ -954,8 +973,15 @@ static void ni_nested_trampoline(void) {
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;
/* Below every live frame of Node's stack: the mark the host callback
* recorded, or lower where runtime code it ran went deeper before it
* switched to this fiber (an async call's eager start, a job: the
* spawner's frame and its saved context are still in use there). Below
* the red zone and the swap's own frame, 16-byte aligned, as makecontext
* expects. */
uintptr_t top = ni_mark;
if (scr_node_stack_low != 0 && scr_node_stack_low < top) top = scr_node_stack_low;
uintptr_t hi = (top - 1024) & ~(uintptr_t)15;
ni_make_context(&nested, lo, hi - (uintptr_t)lo, ni_nested_trampoline);
#ifdef NI_ASAN
ni_nested_top = hi;
@@ -965,6 +991,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *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));
void *loop_save = scr_loop_nested_enter();
#ifdef NI_ASAN
void *caller_fake = NULL;
__sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo);
@@ -975,6 +1002,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) {
size_t size;
__sanitizer_finish_switch_fiber(caller_fake, &bottom, &size);
#endif
scr_loop_nested_leave(loop_save);
SCR_STACK_GUARD_SET(guard);
}
@@ -994,6 +1022,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) {
ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
if (ni_stack == MAP_FAILED) ni_oom();
(void)mprotect(ni_stack, NI_GUARD, PROT_NONE);
scr_node_stack_has = &ni_stack_has;
#ifdef NI_ASAN
if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK);
#endif
+10
View File
@@ -43,6 +43,16 @@ ScrPromise *scr_node_island_call(ScrStr *file, ScrStr *specifier, ScrStr *name,
struct ScrNodeBridge;
struct ScrNodeIoState;
void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms);
/* The lowest live frame of Node's stack while a fiber runs (the SCR_NODE_ISLANDS
* build of scr_async.c records it at each switch from Node's stack to a
* fiber; 0: none), and the test for Node's stack it uses. */
extern uintptr_t scr_node_stack_low;
extern bool (*scr_node_stack_has)(uintptr_t sp);
/* Around a nested entry: the runtime's scheduling state of the suspended
* calling fiber, saved and put back (the SCR_NODE_ISLANDS build of
* scr_async.c). */
void *scr_loop_nested_enter(void);
void scr_loop_nested_leave(void *save);
bool scr_loop_is_hosted(void);
void scr_loop_hosted_run(uint32_t kind, uint32_t token);
void scr_loop_hosted_io_state(struct ScrNodeIoState *state);
@@ -1424,6 +1424,10 @@ static const ScrDynHandleOps nh_ops = {
* own objects. */
static void nh_cleanup(void) {
nh_drain_freed();
/* What the last conversion for the export unit kept alive. */
NhKeep last = nh_out_keep;
memset(&nh_out_keep, 0, sizeof nh_out_keep);
nh_keep_done(&last);
if (nh_inspect_fn != NULL) {
scr_dyn_release(nh_inspect_fn);
nh_inspect_fn = NULL;
@@ -0,0 +1,91 @@
// @requires-libnode
// An HTTP server whose async request handler awaits island promises, calls
// island functions, extends an island class and arms timers, while an
// island client drives it (a long-running service's shape): the handler's
// continuations run as runtime jobs that Node's loop
// dispatches, possibly on an async fiber's stack, and every entry into Node
// from there must run on Node's stack, where V8 scans it during the
// collections the island's allocations cause.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { churn, delay, depth, get, Handler } from "server-kit";
class CountingHandler extends Handler {
served = 0;
async handle(path: string): Promise<string> {
this.served++;
return `${await super.handle(path)}#${this.served}`;
}
}
const handler = new CountingHandler("kit");
const timers: string[] = [];
// Called from a continuation (a runtime job on Node's stack): its eager
// start runs on a fiber and calls into the island, deep, before its first
// await returns to the caller's frame on Node's stack.
async function listFiles(path: string, wait: (n: number) => Promise<number>): Promise<unknown> {
const out: string[] = [];
try {
for (let i = 0; i < 3; i++) {
out.push(`${path}:${depth(300 + i)}`);
}
const more = await wait(out.length);
out.push(String(more));
} finally {
out.push("done");
}
return out;
}
// Starts listFiles a few native frames deeper on the current stack.
function startDeep(levels: number, path: string): Promise<unknown> {
const parts = [path, String(levels), path.length.toString(16)];
if (levels > 0) return startDeep(levels - 1, parts[0]!);
return listFiles(parts.join("").slice(0, path.length), (n) => delay(1, n));
}
async function respond(path: string): Promise<string> {
const waited = await delay(1, path);
const files = (await startDeep(24, waited)) as string[];
let total = 0;
for (let i = 0; i < 3; i++) {
total += churn(i) > 0 ? 1 : 0;
await delay(0);
}
const handled = await handler.handle(waited);
await new Promise<void>((resolve) =>
setTimeout(() => {
timers.push(path);
resolve();
}, 1),
);
return `${handled} churn=${total} files=${files.length}`;
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const path = req.url ?? "/";
respond(path).then(
(text) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end(text);
},
(error: unknown) => {
res.writeHead(500);
res.end(String(error));
},
);
});
async function main(): Promise<void> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
const port = typeof address === "object" && address !== null ? address.port : 0;
for (let i = 0; i < 40; i++) {
const reply = await get(port, `/r${i}`);
if (i % 10 === 0 || reply.status !== 200) console.log(reply.status, reply.body);
}
console.log("timers", timers.length, "served", handler.served, "count", handler.count);
server.close(() => console.log("closed"));
}
void main();
@@ -0,0 +1,14 @@
export interface Reply {
status: number;
body: string;
}
export declare function get(port: number, path: string): Promise<Reply>;
export declare function delay<T>(ms: number, value?: T): Promise<T>;
export declare function churn(seed: number): number;
export declare function depth(n: number): number;
export declare class Handler {
name: string;
count: number;
constructor(name: string);
handle(path: string): Promise<string>;
}
@@ -0,0 +1,53 @@
"use strict";
// An island package shaped like a service's HTTP helpers: a
// client that requests over HTTP and answers promises, a base class that
// native code extends, and work that allocates enough to collect garbage.
const http = require("node:http");
function get(port, path) {
return new Promise((resolve, reject) => {
http
.get({ host: "127.0.0.1", port, path, agent: false }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (body += chunk));
res.on("end", () => resolve({ status: res.statusCode, body }));
})
.on("error", reject);
});
}
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
// Allocates and drops a few megabytes, so collections run while native
// callers are on the stack.
function churn(seed) {
let keep = 0;
for (let i = 0; i < 2000; i++) {
const a = new Array(64).fill(seed + i);
keep += a.length + JSON.stringify({ i, a: a.slice(0, 4) }).length;
}
return keep;
}
// Recursion deep enough to use tens of kilobytes of the stack it runs on.
function depth(n) {
const pad = [n, n + 1, n + 2, n + 3];
return n <= 0 ? pad.length : depth(n - 1) + (pad[n % 4] > 0 ? 1 : 0);
}
class Handler {
constructor(name) {
this.name = name;
this.count = 0;
}
async handle(path) {
this.count++;
await delay(1);
return `${this.name}:${path}`;
}
}
module.exports = { get, delay, churn, depth, Handler };
@@ -0,0 +1 @@
{ "name": "server-kit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "island-server-fibers", "private": true, "type": "module", "scriptc": { "packages": { "server-kit": "node" } } }
+16
View File
@@ -0,0 +1,16 @@
// Island work that keeps OpenSSL busy while the program exits
// (exit-tls.mjs): loading node:tls starts Node's off-thread loading of the
// root certificates (system ones with NODE_USE_SYSTEM_CA=1, extra ones with
// NODE_EXTRA_CA_CERTS); pbkdf2 and key generation run on libuv's pool.
"use strict";
const crypto = require("node:crypto");
const tls = require("node:tls");
exports.warm = function warm(mode) {
if (mode === "load") return tls.DEFAULT_MIN_VERSION.length > 0;
tls.createSecureContext();
const roots = tls.rootCertificates.length > 0;
for (let i = 0; i < 8; i++) crypto.pbkdf2("password", "salt", 20000, 64, "sha512", () => {});
if (mode === "keys") crypto.generateKeyPair("ec", { namedCurve: "P-256" }, () => {});
return roots;
};
+20
View File
@@ -0,0 +1,20 @@
// Exit while Node has OpenSSL work in flight (node-island-exit.test.ts):
// Node must stop its threads before the process's exit handlers tear
// OpenSSL down. argv[2]: "early" (exit right after Node starts, while it
// loads the system root certificates on a thread of its own with
// NODE_USE_SYSTEM_CA=1), "load" (node:tls loaded, extra certificates
// loading off-thread), "exit" (process.exit with pool work in flight),
// "keys", or "return" (fall off the end). A "-return" suffix ends the
// program by returning instead of process.exit (C exit and its handlers).
import { requireModule } from "@scriptc/node";
const arg = process.argv[2] ?? "exit";
const returns = arg.endsWith("-return") || arg === "return";
const mode = arg.replace(/-return$/, "");
if (mode === "early") {
console.log("roots", requireModule("./helper.cjs") !== null);
} else {
const lib = requireModule("./exit-tls.cjs");
console.log("roots", lib.warm(mode));
}
if (!returns) process.exit(0);
+21
View File
@@ -2,3 +2,24 @@
# (scripts/ci-test-plan.mjs). One `leak:<function>` line per intentional
# leak, under a comment that gives the reason. Empty: no corpus program
# leaks.
# Node islands (scope-B NI-7c): OpenSSL inside libnode is initialized with
# OPENSSL_INIT_NO_ATEXIT (scr_node_bridge.cc, KeepOpenSSLAtExit), so its
# process-lifetime state is never freed: OPENSSL_cleanup at exit raced the
# threads Node owns that can still be inside OpenSSL.
leak:OPENSSL_init_crypto
# Node islands (scope-B NI-7c): Node's root certificates (bundled, system
# store, NODE_EXTRA_CA_CERTS) live in function-local statics that Node's
# per-process teardown frees; a scriptc program leaves that teardown to
# process exit (scr_node_bridge.cc, Stop), and the vectors' own destructors
# drop the last pointers at exit.
leak:node::crypto::LoadCACertificates
leak:node::crypto::LoadSystemCACertificates
leak:node::crypto::GetBundledRootCertificates
leak:node::crypto::GetSystemStoreCACertificates
leak:node::crypto::GetExtraCACertificates
leak:node::crypto::GetOpenSSLSystemCertificates
leak:node::crypto::LoadCertsFromBIO
# The certificates' lazily decoded keys, cached in them when a root store
# is built.
leak:node::crypto::NewRootCertStore
+87
View File
@@ -0,0 +1,87 @@
/* Exit with Node loaded (scope-B NI-7c): island code loads node:tls (Node
* then loads the root certificates on threads of its own) or leaves OpenSSL
* work on libuv's thread pool, and the program exits at once. The bridge's
* stop runs Node's own exit sequence (join the pool, dispose the platform,
* join the certificate threads) before the process's exit handlers run
* OpenSSL's cleanup; without it a certificate thread inside OpenSSL crashed
* the exit (SIGSEGV/SIGABRT in up to half the runs).
* Many runs per mode; SCRIPTC_NODE_ISLAND_EXIT_RUNS scales them. Needs a
* libnode pack in SCRIPTC_LIBNODE. */
import { execFile } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { beforeAll, describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const fixture = join(repoRoot, "tests/fixtures/node-island/exit-tls.mjs");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-exit");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const pack = libnodePack();
const runs = Number(process.env["SCRIPTC_NODE_ISLAND_EXIT_RUNS"] ?? (sanitize ? "10" : "40"));
let binary = "";
// Node loads the system and extra root certificates on their own threads
// when node:tls loads, with these set: the threads the exit must wait for.
const caFile = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"].find((f) =>
existsSync(f),
);
const env: NodeJS.ProcessEnv = {
...process.env,
NODE_USE_SYSTEM_CA: "1",
...(caFile === undefined ? {} : { NODE_EXTRA_CA_CERTS: caFile }),
};
async function runOnce(mode: string): Promise<{ code: number | string; stdout: string }> {
try {
const { stdout } = await execFileAsync(binary, [mode], { encoding: "utf8", env });
return { code: 0, stdout };
} catch (err) {
const e = err as { code?: number; signal?: string; stdout?: string };
return { code: e.signal ?? e.code ?? "?", stdout: e.stdout ?? "" };
}
}
describe.skipIf(pack === null)("Node islands: exit with OpenSSL work in flight", () => {
beforeAll(async () => {
mkdirSync(outDir, { recursive: true });
const result = await compile(fixture, {
outPath: join(outDir, "exit-tls"),
outDir,
sanitize,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
binary = result.binaryPath;
}, 300_000);
test.each([
"early",
"load",
"exit",
"keys",
"return",
"early-return",
"load-return",
"keys-return",
])(
"%s: every run exits 0",
async (mode) => {
const outcomes: (number | string)[] = [];
for (let i = 0; i < runs; i += 4) {
const batch = await Promise.all(
Array.from({ length: Math.min(4, runs - i) }, () => runOnce(mode)),
);
for (const r of batch) {
outcomes.push(r.code);
expect(r.stdout).toBe(`roots true\n`);
}
}
expect(outcomes.filter((c) => c !== 0)).toEqual([]);
},
600_000,
);
});