From 9621b895ce313d8585f1bca678195421f3c4104f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 14 Oct 2026 19:48:01 -0500 Subject: [PATCH] Define new runtime globals with initializers so library localization hides them scr_cyc_types, scr_str_scan and scr_wtf8_lone were tentative definitions. The macOS runtime build emits tentative definitions as COMMON symbols, and Mach-O `ld -r` writes COMMON symbols back out as commons, which -exported_symbols_list does not demote. Every runtime-localized library archive therefore defined them next to its declared API, failing the library-multi M1 and M7 symbol checks on macOS. Linux clang defaults to -fno-common, so the same declarations were ordinary definitions there and the checks passed. Initialize the three globals explicitly, as scr_sa and scr_stdio_write_hook already are, and add a runtime test that compiles every runtime source with -fcommon in the plain and worker configurations and expects no COMMON symbols, so a new tentative definition fails on Linux too. --- packages/runtime/src/scr_cycle.c | 6 +- packages/runtime/src/scr_string.c | 7 ++- packages/runtime/test/common-symbols.test.ts | 58 ++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/test/common-symbols.test.ts diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index c5ea9e26e..2aba7406b 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -266,8 +266,10 @@ SCR_TL size_t scr_cyc_live = 0; SCR_TL size_t scr_cyc_old_freed = 0; #endif -/* Descriptor copies by registry id. Id 0 stays empty (an unregistered type). */ -ScrCycType scr_cyc_types[SCR_CYC_MAX_TYPES]; +/* Descriptor copies by registry id. Id 0 stays empty (an unregistered type). + * Explicitly initialized so library localization can demote it: a tentative + * definition is a COMMON symbol on Mach-O, and COMMON symbols stay global. */ +ScrCycType scr_cyc_types[SCR_CYC_MAX_TYPES] = {0}; static uint32_t scr_cyc_ntypes = 1; /* Assign `type` its id on its first allocation; any thread may get there diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 71016cfdc..3010721f8 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -96,8 +96,10 @@ typedef struct ScrSidx { /* Keep short-string cursor traffic out of the sparse cache. A short-lived * one-byte receiver can be far more common than a large indexed one; sharing * the round-robin slots would otherwise rebuild a warm index every few calls. + * Explicitly initialized so library localization can demote it: a tentative + * definition is a COMMON symbol on Mach-O, and COMMON symbols stay global. */ -SCR_TL ScrStrScan scr_str_scan; +SCR_TL ScrStrScan scr_str_scan = {0}; static SCR_TL ScrSidx scr_sidx_sparse_tab[SCR_SIDX_N]; static SCR_TL ScrSidx scr_sidx_cursor_tab[SCR_SIDX_N]; static SCR_TL unsigned scr_sidx_sparse_clock; @@ -936,7 +938,8 @@ static inline uint32_t scr_utf8_decode(const char *p, size_t *adv) { /* ── WTF-8 lone surrogates (scr_runtime.h) ────────────────────────── */ -bool scr_wtf8_lone; +/* Explicitly initialized for library localization (see scr_str_scan). */ +bool scr_wtf8_lone = false; void scr_wtf8_note(void) { if (!scr_wtf8_any()) __atomic_store_n(&scr_wtf8_lone, true, __ATOMIC_RELAXED); diff --git a/packages/runtime/test/common-symbols.test.ts b/packages/runtime/test/common-symbols.test.ts new file mode 100644 index 000000000..1244f258d --- /dev/null +++ b/packages/runtime/test/common-symbols.test.ts @@ -0,0 +1,58 @@ +import { execFile } from "node:child_process"; +import { mkdir, readdir, readFile } from "node:fs/promises"; +import { availableParallelism } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const testDir = import.meta.dirname; +const srcDir = join(testDir, "../src"); +const vendorDir = join(testDir, "../vendor"); +const buildDir = join(testDir, "build", "common-symbols"); + +// Library localization (abi.localize_runtime) keeps COMMON symbols global, +// so a runtime global written as a tentative definition (`T x;` at file +// scope with no initializer) is exported from every localized library +// archive on toolchains that emit tentative definitions as COMMON symbols, +// such as the macOS runtime build. Linux clang defaults to -fno-common, where +// the same declaration is an ordinary definition, so the library-multi +// symbol checks only catch it on macOS. This test compiles every runtime +// source with -fcommon, in the plain and worker configurations, and expects +// no COMMON symbols: every external runtime global needs an initializer. +test("runtime globals are not tentative definitions", async () => { + await mkdir(buildDir, { recursive: true }); + const sources = (await readdir(srcDir)) + .filter((name) => name.endsWith(".c") && !name.endsWith(".test.c")) + .sort(); + const jobs = sources.flatMap((name) => + [[], ["-DSCR_WORKERS"]].map((defines) => ({ name, defines })), + ); + const common: string[] = []; + let next = 0; + const worker = async (): Promise => { + while (next < jobs.length) { + const { name, defines } = jobs[next++]!; + const output = join(buildDir, `${name}${defines.join("")}.ll`); + await execFileAsync("clang", [ + "-std=c11", + "-O0", + "-fcommon", + "-S", + "-emit-llvm", + ...(process.platform === "linux" ? ["-D_GNU_SOURCE"] : []), + ...defines, + `-I${join(vendorDir, "quickjs-ng")}`, + `-I${join(vendorDir, "mbedtls", "include")}`, + "-o", + output, + join(srcDir, name), + ]); + for (const match of (await readFile(output, "utf8")).matchAll(/^@(\S+) = common /gm)) { + common.push(`${name}${defines.length > 0 ? ` (${defines.join(" ")})` : ""}: ${match[1]}`); + } + } + }; + await Promise.all(Array.from({ length: Math.min(8, availableParallelism()) }, worker)); + expect(common.sort()).toEqual([]); +});