Preserve native class state and handle terminal process events (#571)

- Preserve fields, accessors, added properties, and cyclic references across untyped class boundaries.
- Support platform signal names and native exception handlers that resume queued work.
This commit is contained in:
Chris Tate
2026-09-29 09:09:34 -05:00
committed by GitHub
parent f8906d0b75
commit 13718c7ac1
42 changed files with 1121 additions and 289 deletions
+55
View File
@@ -0,0 +1,55 @@
"use strict";
// An opaque field does not prevent reading or updating the other properties
// of the same native instance through an untyped package boundary.
class Base {
cache = new Map();
width = 40;
child = { value: 1 };
events = [];
get size() {
this.events.push("get");
return this.width * 2;
}
set size(value) {
this.events.push("set");
this.width = value / 2;
}
}
class Renderer extends Base {
height = 24;
get label() { return `size:${this.width}`; }
}
const renderer = new Renderer();
renderer.cache.set("kept", 7);
const view = /** @type {unknown} */ (renderer);
console.log("dimensions", view.width, view.height, view.size, view.label);
view.width = 60;
view.size = 160;
console.log("updates", renderer.width, view.width, view.size, view.label);
console.log("state", renderer.cache.get("kept"), renderer.events.join(","));
console.log("same", view === renderer);
console.log("missing", view.generation === undefined);
view.generation = 1;
const again = /** @type {unknown} */ (renderer);
console.log("shared", again.generation);
again.generation = 2;
console.log("shared update", view.generation);
view.child.value = 9;
console.log("child", renderer.child.value, view.child === again.child);
for (const key of ["width", "height", "size", "label", "generation"]) console.log("computed", key, view[key]);
for (const key of ["width", "generation"]) view[key] = 100;
console.log("computed update", renderer.width, view.generation);
try { view.label = "changed"; } catch (error) { console.log("readonly", error.name, error.message); }
class Data {
first = 1;
second = 2;
}
const data = new Data();
const record = /** @type {unknown} */ (data);
record.extra = 3;
console.log("keys", Object.keys(record).join(","));
console.log("json", JSON.stringify(record));
console.log("descriptor", JSON.stringify(Object.getOwnPropertyDescriptor(record, "extra")));
Object.assign(record, { first: 4, appended: 5 });
console.log("assigned", data.first, record.extra, record.appended, JSON.stringify(record));
@@ -0,0 +1,11 @@
class Context {
id = 1;
}
function createCycle() {
const context = new Context();
const view = /** @type {unknown} */ (context);
view.self = view;
view.nested = { owner: view };
console.log(view.self === view, view.nested.owner === view, context.id);
}
for (let i = 0; i < 3; i++) createCycle();
@@ -0,0 +1,17 @@
class State {
width = 1;
get size() { console.log("get"); return this.width; }
set size(value) { console.log("set", value); this.width = value; }
get failed() { if (this.width > 0) throw new Error("getter"); return 0; }
}
const state = new State();
const view = /** @type {unknown} */ (state);
const key = { toString() { console.log("key"); return "size"; } };
function value() { console.log("value"); return 2; }
view[key] = value();
console.log("read", view[String("size")], state.width);
const throwingKey = { toString() { throw new Error("key conversion"); } };
try { view[throwingKey] = value(); } catch (error) { console.log(error.message); }
try { console.log(view.failed); } catch (error) { console.log(error.message); }
const absent = /** @type {unknown} */ (null);
try { absent[key] = value(); } catch (error) { console.log("absent", error.name); }
+31
View File
@@ -0,0 +1,31 @@
// Named signals use host numbers, deliver their name, and preserve callback identity.
const signal = String("SIGWINCH");
let deliveries = 0;
const keepAlive = setInterval(() => {}, 1000);
const late = () => console.log("late");
const removed = () => console.log("removed snapshot");
const listener = (name, number) => {
console.log("signal", name, number > 0, ++deliveries);
process.off(signal, removed);
if (deliveries === 1) {
process.on(signal, late);
setTimeout(() => process.kill(process.pid, signal), 20);
} else {
process.off(signal, listener);
process.off(signal, late);
clearInterval(keepAlive);
}
};
process.on(signal, listener);
process.once(signal, (name) => console.log("once", name));
process.on(signal, removed);
process.on("SIGBREAK", late);
process.removeListener("SIGBREAK", late);
for (const name of ["SIGKILL", "SIGSTOP"]) {
try {
process.on(name, late);
} catch (error) {
console.log("uncatchable", error.code);
}
}
setTimeout(() => process.kill(process.pid, signal), 20);
@@ -0,0 +1,6 @@
// @exit: 7
process.once("uncaughtException", (error, origin) => {
console.log("handler", error.message, origin);
throw new Error("handler failed");
});
setTimeout(() => { throw new Error("timer"); }, 1);
+20
View File
@@ -0,0 +1,20 @@
let count = 0;
const late = (error) => console.log("late", error.message);
const removed = (error) => console.log("snapshot", error.message);
const handler = (error, origin) => {
console.log("handled", error.message, origin);
process.off("uncaughtException", removed);
if (count === 1) process.once("uncaughtException", late);
process.nextTick(() => console.log("tick", count));
};
process.on("uncaughtException", handler);
process.on("uncaughtException", removed);
process.once("uncaughtExceptionMonitor", (error, origin) => console.log("monitor", error.message, origin));
const interval = setInterval(() => {
count++;
if (count < 3) throw new Error("interval " + count);
clearInterval(interval);
process.off("uncaughtException", handler);
process.once("uncaughtException", (value, origin) => console.log("primitive", value, origin));
setImmediate(() => { throw "immediate"; });
}, 10);
+7
View File
@@ -0,0 +1,7 @@
process.once("uncaughtExceptionMonitor", (error, origin) => console.log("monitor", error.message, origin));
process.once("uncaughtException", (error, origin) => {
console.log("handled module", error.message, origin);
setTimeout(() => console.log("resumed"), 10);
});
await Promise.resolve();
throw new Error("module");
@@ -0,0 +1,4 @@
// @exit: 1
// @no-node-shims
process.on("uncaughtExceptionMonitor", (error, origin) => console.log("monitor", error.message, origin));
throw new Error("unhandled");
@@ -0,0 +1,8 @@
process.once("uncaughtExceptionMonitor", (error, origin) => console.log("monitor", error.message, origin));
process.once("uncaughtException", (error, origin) => {
console.log("handled rejection", error.message, origin);
process.once("unhandledRejection", () => { throw new Error("listener"); });
process.once("uncaughtException", (error, origin) => console.log("handled listener", error.message, origin));
setTimeout(() => Promise.reject(new Error("next")), 10);
});
Promise.reject(new Error("first"));
+13
View File
@@ -0,0 +1,13 @@
// @no-node-shims
process.once("uncaughtExceptionMonitor", (error, origin) => {
console.log("monitor", error.message, origin);
});
const removed = () => console.log("should not run");
process.on("uncaughtException", removed);
process.off("uncaughtException", removed);
process.once("uncaughtException", (error, origin) => {
console.log("handled", error instanceof Error, error.message, origin);
process.nextTick(() => console.log("handler tick"));
});
setTimeout(() => console.log("survived"), 10);
throw new Error("entry");
+5 -1
View File
@@ -143,7 +143,11 @@ function nodeOracleArgs(file: string): string[] {
? ["--experimental-transform-types", "--disable-warning=ExperimentalWarning"]
: [];
const nodep = wantsNoDeprecation(file) ? ["--no-deprecation"] : [];
return [...transform, ...nodep, "--import", comptimeShim, "--import", islandShim, nodeOracleFile(file)];
// --import makes Node load even a CJS entry through its ESM loader,
// changing an entry throw's uncaughtException origin to unhandledRejection.
const shims = directiveHead(file).includes("// @no-node-shims")
? [] : ["--import", comptimeShim, "--import", islandShim];
return [...transform, ...nodep, ...shims, nodeOracleFile(file)];
}
/** Runs a binary, tolerating an expected nonzero exit (execFile rejects on
+4 -2
View File
@@ -231,8 +231,10 @@ async function runLinuxNode(file: string): Promise<RunResult> {
...(directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l))
? ["--no-deprecation"]
: []),
"--import", inContainer(join(repoRoot, "tests/harness/comptime-shim.mjs")),
"--import", inContainer(join(repoRoot, "tests/harness/island-shim.mjs")),
...(directiveHead(file).includes("// @no-node-shims") ? [] : [
"--import", inContainer(join(repoRoot, "tests/harness/comptime-shim.mjs")),
"--import", inContainer(join(repoRoot, "tests/harness/island-shim.mjs")),
]),
inContainer(nodeOracleFile(file)),
]);
}
+5 -1
View File
@@ -190,7 +190,11 @@ function nodeOracleArgs(file: string): string[] {
? ["--experimental-transform-types", "--disable-warning=ExperimentalWarning"]
: [];
const nodep = wantsNoDeprecation(file) ? ["--no-deprecation"] : [];
return [...transform, ...nodep, "--import", comptimeShim, "--import", islandShim, nodeOracleFile(file)];
// --import makes Node load even a CJS entry through its ESM loader,
// changing an entry throw's uncaughtException origin to unhandledRejection.
const shims = directiveHead(file).includes("// @no-node-shims")
? [] : ["--import", comptimeShim, "--import", islandShim];
return [...transform, ...nodep, ...shims, nodeOracleFile(file)];
}
function programInputs(file: string): string[] {
+4 -1
View File
@@ -85,6 +85,7 @@ const WINDOWS_SKIPS: Record<string, string> = {
// stdout/stderr read "" here where Node types them null (the
// documented spawnSync stance) — invisible on POSIX lanes where these
// spawns succeed, exposed here where every one fails.
"process-named-signals.cjs": "POSIX signal delivery: Windows Node cannot send SIGWINCH or register SIGSTOP",
"1360-spawn-sync.ts": "posix-shaped: every spawn is ENOENT on Windows Node too, exposing the documented spawn-failure \"\"-vs-null stdout stance (1644 covers spawnSync here)",
"1361-spawn-events.ts": "posix-shaped: the unlistened /bin/sh spawn failure crashes both sides, rendered differently (1646 covers spawn events here)",
"1362-spawn-timers.ts": "posix-shaped: the unlistened /bin/sh spawn failure crashes both sides, rendered differently",
@@ -307,8 +308,10 @@ async function runWindowsNode(file: string): Promise<RunResult> {
const nodep = directiveHead(file).some((l) => /^\/\/ @no-deprecation\s*$/.test(l))
? "--no-deprecation "
: "";
const shims = directiveHead(file).includes("// @no-node-shims")
? "" : "--import ./comptime-shim.mjs --import ./island-shim.mjs ";
return runOnBox(
`node ${transform}${nodep}--import ./comptime-shim.mjs --import ./island-shim.mjs ${entry}`,
`node ${transform}${nodep}${shims}${entry}`,
);
}