From 9ede73761b90bbaec7dc913fc7317b2a45f830e5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 16 Oct 2026 20:13:00 -0500 Subject: [PATCH] Keep int32 class fields when a program registers process event listeners process.on/off for signals, uncaughtException, unhandledRejection, rejectionHandled and warning pass the listener to the runtime as a dynamic value. The int32 slot analysis treated those calls as possible dynamic stores into any field, so every class reaching dynamic code lost its int32 fields. The runtime only keeps each listener and calls it with fresh event values, so these helpers are read-only. --- packages/compiler/src/ir/int32-slots.test.ts | 11 +++ packages/compiler/src/ir/int32-slots.ts | 13 ++++ .../process-listener-int32-emission.test.ts | 69 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 packages/compiler/test/process-listener-int32-emission.test.ts diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index 6b75e203b..79042a063 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -282,6 +282,17 @@ test("dynamic stores disqualify the field names they can reach", () => { "insp.dynS", "dyn.objCreate", "dyn.getPrototype", + // Registering or removing a process event listener stores nothing either. + "process.onSignal", + "process.offSignal", + "process.onUncaughtException", + "process.offUncaughtException", + "process.onUnhandledRejection", + "process.offUnhandledRejection", + "process.onRejectionHandled", + "process.offRejectionHandled", + "process.onWarning", + "process.offWarning", ]) { const reflected = analyzeInt32Slots(program(write(reader, [view]))); expect([reader, reflected.isField("C", "a"), reflected.isField("C", "b")]).toEqual([ diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 3dec3397d..b8c33de0b 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -345,6 +345,19 @@ const DYN_READ_ONLY = new Set([ "json.stringifyReplacer", "json.stringifyValue", "num.toStringRadix", + // Process event listeners (signals, uncaughtException, unhandledRejection, + // rejectionHandled, warning): the runtime keeps the callback and calls it + // with fresh event values; off compares callbacks by identity. + "process.offRejectionHandled", + "process.offSignal", + "process.offUncaughtException", + "process.offUnhandledRejection", + "process.offWarning", + "process.onRejectionHandled", + "process.onSignal", + "process.onUncaughtException", + "process.onUnhandledRejection", + "process.onWarning", "sharedArrayBuffer.new", "text.decodeStream", "worker.new", diff --git a/packages/compiler/test/process-listener-int32-emission.test.ts b/packages/compiler/test/process-listener-int32-emission.test.ts new file mode 100644 index 000000000..1d7303b97 --- /dev/null +++ b/packages/compiler/test/process-listener-int32-emission.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile } from "../src/index.js"; + +async function classLayout(listeners: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-process-listener-int32-")); + try { + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ll"); + await writeFile( + entry, + ` +class P { + kind: number; + pos: number; + next: P | undefined; + constructor(kind: number, pos: number) { + this.kind = kind | 0; + this.pos = pos | 0; + this.next = undefined; + } +} +const n = process.argv.length; +const p = new P(n, n * 2); +p.next = new P(1, 2); +if (n > 100) console.log(p as unknown); +const listener = (): void => { + console.log("signal"); +}; +if (n > 100) { + ${listeners} +} +console.log(p.kind + p.pos); +`, + ); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" }); + expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true); + const llvm = await readFile(outPath, "utf8"); + return /^%sc_o_P = type \{[^}]*\}/m.exec(llvm)?.[0] ?? ""; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// uncaughtException listeners need @types/node, which this temporary +// project lacks; the int32-slots unit test covers their helpers. +test.for([ + [ + "signals", + `process.on("SIGINT", listener);\n process.once("SIGTERM", listener);\n process.off("SIGINT", listener);`, + ], + [ + "unhandledRejection", + `process.on("unhandledRejection", listener);\n process.off("unhandledRejection", listener);`, + ], + [ + "rejectionHandled", + `process.on("rejectionHandled", listener);\n process.off("rejectionHandled", listener);`, + ], + ["warning", `process.on("warning", listener);\n process.off("warning", listener);`], +] as const)( + "%s listeners keep int32 fields of classes that reach dynamic code", + async ([, listeners]) => { + const layout = await classLayout(listeners); + expect(layout).toMatch(/^%sc_o_P = type \{ i64, i32, i32, ptr/); + }, +);