diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index c5ea9e26e5..2aba7406bb 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 71016cfdc9..a9d6fefdb4 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); @@ -2603,6 +2606,25 @@ static int scr_digit_value(char c) { return 99; } +/* One fold of V8's generic-radix loop: v * multiplier + part. Node's V8 is + * compiled with the C++ compiler's default floating-point contraction, + * which fuses this multiply-add into one rounding on arm64 and keeps two + * roundings on x86-64, so Node answers long inputs differently on the two + * architectures (parseInt("9007199254740993", 36) is 1.9896986116031812e+24 + * on arm64 and 1.9896986116031807e+24 on x64). Each form is written out so + * the result matches the same architecture's Node under any contraction + * setting this file is compiled with. */ +static inline double scr_radix_fold(double v, uint32_t multiplier, uint32_t part) { +#if defined(__aarch64__) + return fma(v, (double)multiplier, (double)part); +#else +#ifdef __clang__ +#pragma clang fp contract(off) +#endif + return v * (double)multiplier + (double)part; +#endif +} + /* Digit string → double (digits already validated against the radix, * leading zeros stripped, count >= 1). Node (V8) is the oracle, and V8 is * only EXACT where the spec requires it (radix 2, 4, 8, 10, 16, 32); for @@ -2615,8 +2637,8 @@ static double scr_digits_to_double(const char *p, size_t n, int radix) { if (radix != 10 && (radix & (radix - 1)) != 0) { /* V8's generic-radix loop (numbers/conversions.cc): consume the * longest digit run whose multiplier stays below 0xFFFFFFFF/36, then - * fold with one double multiply-add per chunk — the rounding of each - * fold IS the observable Node behavior. */ + * fold with one double multiply-add per chunk (scr_radix_fold) — the + * rounding of each fold IS the observable Node behavior. */ const uint32_t kMaximumMultiplier = 0xFFFFFFFFu / 36u; double v = 0.0; size_t i = 0; @@ -2635,7 +2657,7 @@ static double scr_digits_to_double(const char *p, size_t n, int radix) { multiplier = m; i++; } - v = v * (double)multiplier + (double)part; + v = scr_radix_fold(v, multiplier, part); } while (!done); return v; } diff --git a/packages/runtime/test/common-symbols.test.ts b/packages/runtime/test/common-symbols.test.ts new file mode 100644 index 0000000000..1244f258de --- /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([]); +}); diff --git a/packages/runtime/test/gen-string-cases.mjs b/packages/runtime/test/gen-string-cases.mjs index 866f442915..ee3cc02967 100644 --- a/packages/runtime/test/gen-string-cases.mjs +++ b/packages/runtime/test/gen-string-cases.mjs @@ -309,8 +309,42 @@ const radices = [ -4294967280, 35, ]; +// parseInt cases whose Node answer depends on the architecture, keyed by +// input and radix argument. For radixes other than 2, 4, 8, 10, 16 and 32, V8 +// folds each 32-bit digit chunk with `value * multiplier + part`; Node's +// arm64 builds fuse that multiply-add into one rounding and its x64 builds +// round twice. Each case is written once per architecture with the listed +// answers, so the file is the same whichever machine generates it, and the +// answer for this machine's architecture is checked against Node here. +const ARCH_PARSEINT = new Map([ + ["9007199254740993\t36", { arm64: "1.9896986116031812e+24", x64: "1.9896986116031807e+24" }], + [ + "123456789012345678901234567890\t36", + { arm64: "1.436287679432363e+45", x64: "1.4362876794323633e+45" }, + ], +]); +const ownArch = process.arch === "arm64" ? "arm64" : "x64"; +const archCases = new Set(); for (const s of parseIntInputs) - for (const r of radices) - emit("parseInt", s, r === undefined ? "0" : num(r), hex(String(parseInt(s, r)))); + for (const r of radices) { + const args = r === undefined ? "0" : num(r); + const answer = String(parseInt(s, r)); + const arch = ARCH_PARSEINT.get(`${s}\t${args}`); + if (arch === undefined) { + emit("parseInt", s, args, hex(answer)); + continue; + } + if (answer !== arch[ownArch]) { + throw new Error( + `parseInt(${JSON.stringify(s)}, ${args}) is ${answer} on ${process.arch}; ARCH_PARSEINT lists ${arch[ownArch]}`, + ); + } + archCases.add(`${s}\t${args}`); + emit("parseInt.arm64", s, args, hex(arch.arm64)); + emit("parseInt.x64", s, args, hex(arch.x64)); + } +if (archCases.size !== ARCH_PARSEINT.size) { + throw new Error("ARCH_PARSEINT lists a case that parseIntInputs and radices do not generate"); +} stdout.write([...lines].join("\n") + "\n"); diff --git a/packages/runtime/test/string-cases.txt b/packages/runtime/test/string-cases.txt index 4334b0470b..17dc8ef6fc 100644 --- a/packages/runtime/test/string-cases.txt +++ b/packages/runtime/test/string-cases.txt @@ -34282,7 +34282,8 @@ parseInt 39303037313939323534373430393933 2 4e614e parseInt 39303037313939323534373430393933 8 4e614e parseInt 39303037313939323534373430393933 10 39303037313939323534373430393932 parseInt 39303037313939323534373430393933 16 3130333738323931393832353731343037303030 -parseInt 39303037313939323534373430393933 36 312e39383936393836313136303331383037652b3234 +parseInt.arm64 39303037313939323534373430393933 36 312e39383936393836313136303331383132652b3234 +parseInt.x64 39303037313939323534373430393933 36 312e39383936393836313136303331383037652b3234 parseInt 39303037313939323534373430393933 1 4e614e parseInt 39303037313939323534373430393933 37 4e614e parseInt 39303037313939323534373430393933 -1 4e614e @@ -34316,7 +34317,8 @@ parseInt 313233343536373839303132333435363738393031323334353637383930 2 31 parseInt 313233343536373839303132333435363738393031323334353637383930 8 333432333931 parseInt 313233343536373839303132333435363738393031323334353637383930 10 312e32333435363738393031323334353638652b3239 parseInt 313233343536373839303132333435363738393031323334353637383930 16 392e343532323837393638373336353437652b3334 -parseInt 313233343536373839303132333435363738393031323334353637383930 36 312e34333632383736373934333233363333652b3435 +parseInt.arm64 313233343536373839303132333435363738393031323334353637383930 36 312e343336323837363739343332333633652b3435 +parseInt.x64 313233343536373839303132333435363738393031323334353637383930 36 312e34333632383736373934333233363333652b3435 parseInt 313233343536373839303132333435363738393031323334353637383930 1 4e614e parseInt 313233343536373839303132333435363738393031323334353637383930 37 4e614e parseInt 313233343536373839303132333435363738393031323334353637383930 -1 4e614e diff --git a/packages/runtime/test/string.test.ts b/packages/runtime/test/string.test.ts index 14dfe090a9..3995a8de16 100644 --- a/packages/runtime/test/string.test.ts +++ b/packages/runtime/test/string.test.ts @@ -53,37 +53,17 @@ beforeAll(async () => { }); // Runs against the committed case file generated from Node via -// gen-string-cases.mjs. The test below also verifies the checked-in oracle -// against the generator, so an accidental stale result cannot be masked by -// the platform-specific native-runtime allowlist. Covers UTF-16 +// gen-string-cases.mjs. The first test below also verifies the checked-in +// oracle against the generator, so a stale result cannot pass. Covers UTF-16 // length/charCodeAt/indexOf/includes/startsWith/endsWith/slice/repeat/ // trim/trimStart/trimEnd/split/padStart/padEnd/charAt plus parseInt over // ASCII, Latin-1, CJK, astral, and combining-mark strings, including the // documented lone-surrogate → U+FFFD divergence. // -// On macOS every committed case matches exactly. On Linux (glibc, -// measured on the sandbox-suite lane's AL2023 clang 15) exactly two -// parseInt cases — giant base-36 inputs whose magnitude assembly rides -// libm — land one ulp away from V8's correctly-rounded conversion. -// scr_number.c is pinned and shared across every consumer, so the -// divergence is documented HERE as an allowlist keyed by exact input and -// exact divergent output: a third value, a new failing case, or a future -// conversion fix all make the test fail. -const LINUX_PARSEINT_ULP: ReadonlyMap = new Map([ - [ - // parseInt("9007199254740993", 36): Node 1.9896986116031812e+24, - // glibc lane 1.9896986116031807e+24. - "parseInt(39303037313939323534373430393933 ; 36)", - "312e39383936393836313136303331383037652b3234", - ], - [ - // parseInt("123456789012345678901234567890", 36): - // Node 1.436287679432363e+45, glibc lane 1.4362876794323633e+45. - "parseInt(313233343536373839303132333435363738393031323334353637383930 ; 36)", - "312e34333632383736373934333233363333652b3435", - ], -]); - +// parseInt cases whose Node answer differs between arm64 and x64 are +// recorded once per architecture ("parseInt.arm64" / "parseInt.x64", see +// ARCH_PARSEINT in gen-string-cases.mjs), so the generated file is the same +// on either machine and the C harness checks this architecture's answer. test("committed string oracle matches Node", async () => { const caseFile = join(testDir, "string-cases.txt"); const [{ stdout }, committed] = await Promise.all([ @@ -92,18 +72,10 @@ test("committed string oracle matches Node", async () => { }), readFile(caseFile, "utf8"), ]); - // The committed cases come from Node on arm64. Node on x64 answers the two - // allowlisted base-36 parseInt cases with exactly the values listed above; - // every other line, and any other value for those two, must still match. const generated = stdout.split("\n"); const expected = committed.split("\n"); expect(generated).toHaveLength(expected.length); - const differing = generated.filter((line, index) => line !== expected[index]); - if (process.arch === "arm64") expect(differing).toEqual([]); - for (const line of differing) { - const [operation, input, radix, output] = line.split("\t"); - expect(LINUX_PARSEINT_ULP.get(`${operation}(${input} ; ${radix})`), line).toBe(output); - } + expect(generated.filter((line, index) => line !== expected[index])).toEqual([]); }); test("string methods match Node on committed oracle cases", async () => { @@ -111,25 +83,7 @@ test("string methods match Node on committed oracle cases", async () => { (v) => ({ stderr: v.stderr }), (e: Error & { stderr?: string }) => ({ stderr: e.stderr ?? "" }), ); - const stderr = r.stderr.trim(); - if (process.platform !== "linux") { - expect(stderr).toMatch(/^(\d+)\/\1 cases passed$/); - return; - } - const lines = stderr.split("\n"); - const mismatches = lines.filter((l) => l.startsWith("MISMATCH ")); - for (const line of mismatches) { - const m = /^MISMATCH (.+) expected=[0-9a-f]* got=([0-9a-f]*)$/.exec(line); - expect(m, `unparseable mismatch line: ${line}`).not.toBeNull(); - expect(LINUX_PARSEINT_ULP.get(m![1]!), line).toBe(m![2]); - } - // The harness prints at most 20 mismatch lines; the pass-count tail is - // the ground truth that NOTHING beyond the printed (allowlisted) set - // failed. - const t = /^(\d+)\/(\d+) cases passed$/.exec(lines.at(-1) ?? ""); - expect(t, `missing pass-count line: ${lines.at(-1)}`).not.toBeNull(); - expect(Number(t![2]) - Number(t![1]), "failed-case count").toBe(mismatches.length); - expect(mismatches.length).toBeLessThanOrEqual(LINUX_PARSEINT_ULP.size); + expect(r.stderr.trim()).toMatch(/^(\d+)\/\1 cases passed$/); }); // repeat(count) with count < 0 or Infinity is Node's catchable RangeError diff --git a/packages/runtime/test/test_string.c b/packages/runtime/test/test_string.c index e7ce991005..55c49af7e7 100644 --- a/packages/runtime/test/test_string.c +++ b/packages/runtime/test/test_string.c @@ -27,6 +27,16 @@ long scr_str_live_count(void); /* provided by scr_string.c */ #endif +/* parseInt cases whose Node answer depends on the architecture carry it in + * the op name ("parseInt.arm64" / "parseInt.x64"); only this build's runs. + * scr_radix_fold uses the fused multiply-add on aarch64 and two roundings + * elsewhere, matching Node on arm64 and x64. */ +#if defined(__aarch64__) +#define SCR_TEST_PARSEINT_ARCH "parseInt.arm64" +#else +#define SCR_TEST_PARSEINT_ARCH "parseInt.x64" +#endif + #ifdef SCR_SIDX_TEST /* Test-only sparse-index observability, deliberately absent from the normal * runtime ABI. The walker count is code-point steps after a cache prime. */ @@ -977,9 +987,12 @@ int main(int argc, char **argv) { } else if (strcmp(op, "trimEnd") == 0) { check_str(op, args, input, scr_str_trim_end(input), expected_bytes, exp_len); - } else if (strcmp(op, "parseInt") == 0) { + } else if (strcmp(op, "parseInt") == 0 || + strcmp(op, SCR_TEST_PARSEINT_ARCH) == 0) { check_f64(op, args, input, scr_parse_int(input, strtod(args, NULL)), expected_bytes, exp_len); + } else if (strncmp(op, "parseInt.", 9) == 0) { + /* another architecture's answer (see SCR_TEST_PARSEINT_ARCH) */ } else if (strcmp(op, "split") == 0 || strcmp(op, "splitLimit") == 0) { /* args = separator hex; expected = ":" */ double limit = 4294967295.0;