From 13718c7ac136d292d889de6aea48d116d2efeca2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 29 Sep 2026 09:09:34 -0500 Subject: [PATCH] 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. --- docs/src/app/limitations/page.mdx | 7 + .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 22 +- .../generated/node-v24-backlog.json | 57 ++-- .../generated/node-v24-internal.json | 44 +++- internal/compatibility/static-support.json | 2 + packages/compiler/src/backend/c/c-emitter.ts | 14 +- packages/compiler/src/backend/c/exprs.ts | 48 ++-- packages/compiler/src/backend/c/walkers.ts | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 7 +- packages/compiler/src/backend/llvm/emitter.ts | 22 +- .../compiler/src/backend/llvm/expr-dynamic.ts | 7 +- .../src/backend/llvm/expr-stream-bridges.ts | 71 +++-- .../compiler/src/backend/llvm/lib-process.ts | 8 +- .../compiler/src/backend/llvm/lib-shared.ts | 3 + packages/compiler/src/backend/llvm/shapes.ts | 7 + .../lowering/class-dynamic-dispatch.ts | 247 +++++++++++++++++- .../src/frontend/lowering/lower-builtins.ts | 58 ++-- packages/compiler/src/ir/ir.ts | 23 +- packages/compiler/src/ir/validate.ts | 6 +- .../test/ts7/baselines/order-parity.json | 96 +++++++ packages/runtime/src/scr_async.c | 47 +++- packages/runtime/src/scr_async_dyn.c | 137 ++++++++-- packages/runtime/src/scr_events.c | 220 +++++++++------- packages/runtime/src/scr_exception.c | 13 +- packages/runtime/src/scr_json.c | 27 +- packages/runtime/src/scr_lib.c | 2 +- packages/runtime/src/scr_runtime.h | 16 +- tests/corpus/native-class-properties.cjs | 55 ++++ tests/corpus/native-class-property-cycles.cjs | 11 + tests/corpus/native-class-property-order.cjs | 17 ++ tests/corpus/process-named-signals.cjs | 31 +++ .../process-uncaught-handler-throws.cjs | 6 + tests/corpus/process-uncaught-loop.cjs | 20 ++ tests/corpus/process-uncaught-module.mjs | 7 + tests/corpus/process-uncaught-monitor.cjs | 4 + tests/corpus/process-uncaught-rejection.cjs | 8 + tests/corpus/process-uncaught-sync.cjs | 13 + tests/harness/differential.test.ts | 6 +- tests/harness/linux-differential.test.ts | 6 +- tests/harness/llvm-differential.test.ts | 6 +- tests/harness/windows-differential.test.ts | 5 +- 42 files changed, 1121 insertions(+), 289 deletions(-) create mode 100644 tests/corpus/native-class-properties.cjs create mode 100644 tests/corpus/native-class-property-cycles.cjs create mode 100644 tests/corpus/native-class-property-order.cjs create mode 100644 tests/corpus/process-named-signals.cjs create mode 100644 tests/corpus/process-uncaught-handler-throws.cjs create mode 100644 tests/corpus/process-uncaught-loop.cjs create mode 100644 tests/corpus/process-uncaught-module.mjs create mode 100644 tests/corpus/process-uncaught-monitor.cjs create mode 100644 tests/corpus/process-uncaught-rejection.cjs create mode 100644 tests/corpus/process-uncaught-sync.cjs diff --git a/docs/src/app/limitations/page.mdx b/docs/src/app/limitations/page.mdx index ec1b41ac..4e8291a4 100644 --- a/docs/src/app/limitations/page.mdx +++ b/docs/src/app/limitations/page.mdx @@ -13,6 +13,7 @@ These are rejected at compile time with an `SC` code, a code frame, and usually **Language edges** - `Error`, `TypeError`, `RangeError`, and `SyntaxError` accept `{ cause }` options, including through inherited constructors. Reading, assigning, deleting, and testing `"cause" in error` preserve the distinction between an absent cause and an explicitly undefined cause. Constructor causes are non-enumerable; assignment creates an enumerable property when the cause is absent. Property descriptors and checked-value aliases share the same cause, including getters and setters installed after construction. Accessor constructor options and explicit subclass redeclarations of `cause` remain unsupported. +- Native class fields, inherited accessors, and added properties retain their identity across untyped values. Reflection such as `JSON.stringify` and `Object.keys` still requires every declared field to have a supported checked-value representation; classes containing opaque fields such as native Maps retain a checked error for those operations. - Native class methods can be stored and passed as unbound function values. A field call supplies the containing instance as `this`; `call`, `apply`, and `bind` work when the function signature can cross the checked-value boundary. Extracted overrides keep the selected method even when called with another receiver. Receiver validation accepts the declaring class and its subclasses; borrowing a method onto an unrelated object remains unsupported. Detached methods that use `this`, builtin method values, abstract method declarations, unspecialized generic methods, and incompatible override signatures retain checked errors or compile-time refusals. - Loose `==`/`!=` comparisons compile across statically represented primitive and primitive-union operands, including number/string/boolean/BigInt coercions and the `x == null`/`x != null` nullish idiom. Object-to-primitive comparisons remain fenced because custom `valueOf`/`toString` methods can execute arbitrary code; convert the object explicitly first. - Block- and function-scoped `using` and `await using` compile for statically represented classes with zero-parameter disposal methods and for the supported FileHandle, timer, immediate, child-process, and readline handles. Array `for (using ... of ...)` loops compile. Top-level and switch-clause declarations, disposal through an engine-held package value, and the `DisposableStack` constructors remain fenced; a caught `SuppressedError` exposes its Node-compatible `name` and `message`, while its `error` and `suppressed` payload properties remain outside the static catch surface. @@ -120,6 +121,12 @@ A static-tier program otherwise produces byte-identical stdout and the same exit - **Top-level `await` in embedded ESM packages** is not supported yet. It does compile in your program's own ESM graph and in npm packages compiled through `--npm-static`; the remaining limit is package code running inside the `--dynamic` island. - **`--npm-static` and `--provenance-sources` are experimental** — see [npm Dependencies](/dependencies) for the maturity notes. +## Process events + +Static signal listeners resolve names on the target platform, including `SIGWINCH` for terminal resize on POSIX. `on`, `once`, `off`, and `removeListener` accept computed signal names and deliver the signal name and number. Computed names for other process-event families and chained registrations remain unsupported. Windows signal registration remains limited to the CRT signal set; WASI has no OS signals. + +Literal `uncaughtException` and `uncaughtExceptionMonitor` registrations compile without a JavaScript engine. Handled exceptions resume queued native work; monitors alone do not suppress failure. Error objects and primitive exception values cross the listener boundary, while arbitrary native objects retain the existing exception-value restrictions. Exception capture callbacks and dynamic-island exception handling remain unsupported. + ## Native addons Node-API (N-API) and V8 `.node` addons require Node's addon runtime, which scriptc does not embed in either static or `--dynamic` builds. Direct `createRequire` calls to local addons are rejected at compile time. Embedded npm packages receive a catchable `ERR_DLOPEN_FAILED` when they attempt to load an addon, allowing packages with JavaScript fallbacks to select them. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 65d3c047..c4892607 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "a063593865cc30056a9c", + "artifactVersion": "e6aa3e9df87eb18c506b", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 5c42113c..8ed794fe 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -842,9 +842,9 @@ "apiEntries": 119, "static": { "supported": 0, - "partial": 30, + "partial": 33, "refused": 0, - "not-implemented": 89, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -65109,9 +65109,9 @@ "anchor": "event-uncaughtexception", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -65155,9 +65155,9 @@ "anchor": "event-uncaughtexceptionmonitor", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", @@ -65247,9 +65247,9 @@ "anchor": "signal-events", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the documented scriptc module-loader subset.", + "verification": "test-backed" }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 11252c86..c1379467 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 47, - "verify-gap": 2565, - "audit-partial": 600, + "verify-gap": 2562, + "audit-partial": 603, "classify": 2, "implement": 81 }, @@ -70558,12 +70558,19 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:process", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:process.'uncaughtException'", + "tests": [ + "tests/corpus/process-uncaught-sync.cjs", + "tests/corpus/process-uncaught-loop.cjs", + "tests/corpus/process-uncaught-module.mjs", + "tests/corpus/process-uncaught-rejection.cjs", + "tests/corpus/process-uncaught-monitor.cjs", + "tests/corpus/process-uncaught-handler-throws.cjs" + ] }, "dynamic": { "status": "not-implemented", @@ -70614,12 +70621,19 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:process", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:process.'uncaughtExceptionMonitor'", + "tests": [ + "tests/corpus/process-uncaught-sync.cjs", + "tests/corpus/process-uncaught-loop.cjs", + "tests/corpus/process-uncaught-module.mjs", + "tests/corpus/process-uncaught-rejection.cjs", + "tests/corpus/process-uncaught-monitor.cjs", + "tests/corpus/process-uncaught-handler-throws.cjs" + ] }, "dynamic": { "status": "not-implemented", @@ -70726,12 +70740,15 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:process", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:process.SIGINT, SIGHUP, etc.", + "tests": [ + "tests/corpus/1443-signal-handlers.ts", + "tests/corpus/process-named-signals.cjs" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 5b9d5f2a..110a670b 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -915,9 +915,9 @@ "apiEntries": 119, "static": { "supported": 0, - "partial": 30, + "partial": 33, "refused": 0, - "not-implemented": 89, + "not-implemented": 86, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -86848,6 +86848,7 @@ "evidence": "derived:descendants", "tests": [ "packages/compiler/test/process-builtin-values.test.ts", + "tests/corpus/1443-signal-handlers.ts", "tests/corpus/2314-process-introspection.ts", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", @@ -86856,6 +86857,13 @@ "tests/corpus/2946-child-fork-ipc/main.ts", "tests/corpus/990-process-basics.ts", "tests/corpus/process-builtin-values.js", + "tests/corpus/process-named-signals.cjs", + "tests/corpus/process-uncaught-handler-throws.cjs", + "tests/corpus/process-uncaught-loop.cjs", + "tests/corpus/process-uncaught-module.mjs", + "tests/corpus/process-uncaught-monitor.cjs", + "tests/corpus/process-uncaught-rejection.cjs", + "tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-versions-storage.ts", "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", @@ -87072,8 +87080,16 @@ "anchor": "event-uncaughtexception", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "compiler-feature:process.'uncaughtException'", + "tests": [ + "tests/corpus/process-uncaught-sync.cjs", + "tests/corpus/process-uncaught-loop.cjs", + "tests/corpus/process-uncaught-module.mjs", + "tests/corpus/process-uncaught-rejection.cjs", + "tests/corpus/process-uncaught-monitor.cjs", + "tests/corpus/process-uncaught-handler-throws.cjs" + ] }, "dynamic": { "status": "not-implemented", @@ -87116,8 +87132,16 @@ "anchor": "event-uncaughtexceptionmonitor", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "compiler-feature:process.'uncaughtExceptionMonitor'", + "tests": [ + "tests/corpus/process-uncaught-sync.cjs", + "tests/corpus/process-uncaught-loop.cjs", + "tests/corpus/process-uncaught-module.mjs", + "tests/corpus/process-uncaught-rejection.cjs", + "tests/corpus/process-uncaught-monitor.cjs", + "tests/corpus/process-uncaught-handler-throws.cjs" + ] }, "dynamic": { "status": "not-implemented", @@ -87248,8 +87272,12 @@ "anchor": "signal-events", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "compiler-feature:process.SIGINT, SIGHUP, etc.", + "tests": [ + "tests/corpus/1443-signal-handlers.ts", + "tests/corpus/process-named-signals.cjs" + ] }, "dynamic": { "status": "not-implemented", diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1ef03772..ba5440ae 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -88,6 +88,8 @@ }, "features": [ { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, + { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, { "chapter": "process", "signatures": ["`versions` Type: {Object}"], "status": "partial", "evidence": ["tests/corpus/process-versions-storage.ts"] }, { "chapter": "http", "signatures": ["Class: `http.ClientRequest`", "Class: `http.Server`", "Class: `http.ServerResponse`", "Class: `http.IncomingMessage`"], "status": "partial", "evidence": ["tests/corpus/2981-http-static-events.ts", "tests/corpus/2976-http-static-incoming-state.ts"] }, diff --git a/packages/compiler/src/backend/c/c-emitter.ts b/packages/compiler/src/backend/c/c-emitter.ts index 36281961..d0483135 100644 --- a/packages/compiler/src/backend/c/c-emitter.ts +++ b/packages/compiler/src/backend/c/c-emitter.ts @@ -847,11 +847,11 @@ export class CEmitter { : runExitListeners !== "" ? ` ${runExitListeners.trim()}` : ` /* no refcounted globals */`; - const uncaught = (indent: string, releaseTop = false) => [ - `${indent}if (scr_exc_pending()) {`, + const uncaught = (indent: string, releaseTop = false, handle = false) => [ + `${indent}if (scr_exc_pending()${handle ? " && !scr_exc_handle_uncaught(false)" : ""}) {`, `${indent} scr_exc_print_uncaught();`, `${indent} ${releaseTop ? "scr_promise_release(sc_top); " : ""}` + - `${exitCleanup}return 1;`, + `${exitCleanup}return scr_exit_code_hint_get();`, `${indent}}`, ]; out.push( @@ -947,7 +947,7 @@ export class CEmitter { ? [` ScrPromise *sc_top = ${mangleAsyncSpawn(this.mod.entry)}();`] : [` ${mangleFunction(this.mod.entry)}();`]), // Uncaught exception from top-level code: Node exits 1. - ...(this.mayThrow.has(this.mod.entry) && !asyncEntry ? uncaught(" ") : []), + ...(this.mayThrow.has(this.mod.entry) && !asyncEntry ? uncaught(" ", false, true) : []), // The event loop runs to exhaustion (microtasks before timers). A // throw escaping a timer callback and unhandled promise rejections // both exit 1, like Node. @@ -958,7 +958,7 @@ export class CEmitter { ` if (sc_loop_rejection) {`, ` scr_discard_unhandled_rejections();`, ...(asyncEntry ? [` scr_promise_release(sc_top);`] : []), - ` ${exitCleanup}return 1;`, + ` ${exitCleanup}return scr_exit_code_hint_get();`, ` }`, ...(asyncEntry ? [ @@ -971,13 +971,13 @@ export class CEmitter { ` scr_promise_rethrow_top_level(sc_top);`, ` scr_promise_release(sc_top);`, ` scr_exc_print_uncaught();`, - ` ${exitCleanup}return 1;`, + ` ${exitCleanup}return scr_exit_code_hint_get();`, ` }`, ` scr_promise_release(sc_top);`, ] : []), ` if (scr_report_unhandled_rejections()) {`, - ` ${exitCleanup}return 1;`, + ` ${exitCleanup}return scr_exit_code_hint_get();`, ` }`, ...(asyncEntry ? [ diff --git a/packages/compiler/src/backend/c/exprs.ts b/packages/compiler/src/backend/c/exprs.ts index 20d70f66..a321f7d8 100644 --- a/packages/compiler/src/backend/c/exprs.ts +++ b/packages/compiler/src/backend/c/exprs.ts @@ -3,7 +3,7 @@ import { InternalCompilerError } from "../../errors.js"; * expression lands in a fresh C temp, with RC ownership tracked on the * emitter's frames (see the discipline comment in emitter core). */ import type { CEmitter, Temp } from "./c-emitter.js"; -import { BYTES_ELEMENT_SIZE, arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, CHILDWRITER_T, DYN, F64, type IrExpr, type IrLibFn, type IrRecordShape, type IrType, islandPromisePayloadTag, classDynViewSupported, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, NETSOCKET_T, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; +import { BYTES_ELEMENT_SIZE, arrayOf, BOOL, BYTES_U8, bytesOf, canMarshalFuncIntoIsland, CHILDSTREAM_T, CHILDWRITER_T, DYN, DYN_CLASS_PROPERTIES, F64, type IrExpr, type IrLibFn, type IrRecordShape, type IrType, islandPromisePayloadTag, classDynViewSupported, isClassOwnEnumerableFieldName, isDynTypedRefType, isFfiCallbackParam, isFfiContextParam, isFfiReleaseParam, isRefCounted, isUnitType, MAY_THROW_LIB_FNS, NETSOCKET_T, RUNTIME_ERROR_CLASSES, STRING, typeEquals, typeKey } from "../../ir/ir.js"; import { BYTES_NUM_KIND_C, BYTES_NUM_VAR_C, bytesElemKindC, cDecl, cFnPtrCast, cNumberLiteral, cStringLiteral, cType, DV_GET_KIND_C, DV_SET_KIND_C, elemAccess, mapKeyAccess, mapKeyKindC, mapValKindC, releaseCallC, retainCallC, vAdapters } from "./types.js"; import { mangleClassNew, mangleClassRetain, mangleClassStruct, mangleField, mangleFnClosure, mangleFunction, mangleGlobal, mangleLocal, mangleRecordClone, mangleRecordNew, mangleRecordStruct, mangleVtStruct } from "../mangle.js"; import { OVERFLOW_MEMBER } from "./shapes.js"; @@ -110,6 +110,22 @@ function streamTypedRefCommitAdapter( ` }`, ); } + if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { + const bag = `sc_target->${mangleField(DYN_CLASS_PROPERTIES)}`; + defs.push( + ` ScrDyn *sc_bag = scr_dyn_new_obj();`, + ` scr_dyn_release(scr_dyn_copy_data_properties(sc_bag, sc_d));`, + ` if (scr_exc_pending()) { scr_dyn_release(sc_bag); return; }`, + ); + for (const field of meta.def.fields.filter((f) => isClassOwnEnumerableFieldName(f.name))) { + const literal = cStringLiteral(Buffer.from(field.name, "utf8")); + defs.push( + ` { ScrStr *sc_key = scr_str_new(${literal}, ${Buffer.byteLength(field.name, "utf8")});`, + ` scr_dyn_key_delete(sc_bag, sc_key, false); scr_str_release(sc_key); }`, + ); + } + defs.push(` ScrDyn *sc_old_bag = ${bag};`, ` ${bag} = sc_bag;`, ` scr_dyn_release(sc_old_bag);`); + } defs.push(`}`, ``); return `&${commit}`; } @@ -194,7 +210,7 @@ function nestedTypedRefUnionAdapter( const armKey = typeKey(arm); const keyLit = cStringLiteral(Buffer.from(armKey, "utf8")); lines.push( - ` case ${tag}: return scr_dyn_new_typed_ref(scr_union_peek(sc_u), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, + ` case ${tag}: return scr_dyn_new_typed_ref${emitter.traceAdapterC(arm) !== null ? "_traced" : ""}(scr_union_peek(sc_u), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, ); } else if (arm.kind === "undefinedT") { lines.push(` case ${tag}: return scr_dyn_retain(scr_dyn_undefined());`); @@ -265,7 +281,7 @@ function streamTypedRefAdapter( const rc = vAdapters(child); const childKey = typeKey(child); const keyLit = cStringLiteral(Buffer.from(childKey, "utf8")); - return `scr_dyn_new_typed_ref(${expr}, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(childKey, "utf8")}, &${nested.snapshot}, ${nested.commit})`; + return `scr_dyn_new_typed_ref${emitter.traceAdapterC(child) !== null ? "_traced" : ""}(${expr}, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(childKey, "utf8")}, &${nested.snapshot}, ${nested.commit})`; }; const lines = [ @@ -284,6 +300,9 @@ function streamTypedRefAdapter( ` scr_dyn_obj_set(d, ${keyLit}, ${Buffer.byteLength(field.name, "utf8")}, ${box(field.type, `v->${mangleField(field.name)}`)});`, ); } + if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { + lines.push(` scr_dyn_release(scr_dyn_copy_data_properties(d, v->${mangleField(DYN_CLASS_PROPERTIES)}));`); + } lines.push(` return d;`); } else if (t.kind === "record") { const shape = emitter.recordsById.get(t.shapeId); @@ -431,7 +450,7 @@ function liveDynUnionRefAdapter( const keyLit = cStringLiteral(Buffer.from(armKey, "utf8")); defs.push( ` case ${tag}:`, - ` return scr_dyn_new_typed_ref(scr_union_peek(sc_u), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, + ` return scr_dyn_new_typed_ref${emitter.traceAdapterC(arm) !== null ? "_traced" : ""}(scr_union_peek(sc_u), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, ); } const mutableTags = new Set(mutableArms.map(({ tag }) => tag)); @@ -546,7 +565,7 @@ function streamFromArrayAdapter( const keyLit = cStringLiteral(Buffer.from(armKey, "utf8")); d.push( ` case ${tag}:`, - ` sc_d = scr_dyn_new_typed_ref(scr_union_peek(sc_v), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${snapshot}_${tag}, ${unionCommits.get(tag) ?? "NULL"});`, + ` sc_d = scr_dyn_new_typed_ref${emitter.traceAdapterC(arm) !== null ? "_traced" : ""}(scr_union_peek(sc_v), &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(armKey, "utf8")}, &${snapshot}_${tag}, ${unionCommits.get(tag) ?? "NULL"});`, ` break;`, ); } @@ -561,7 +580,7 @@ function streamFromArrayAdapter( const keyLit = cStringLiteral(Buffer.from(key, "utf8")); const keyLen = Buffer.byteLength(key, "utf8"); d.push( - ` ScrDyn *sc_d = scr_dyn_new_typed_ref(sc_v, &${rc.retain}, &${rc.release}, ${keyLit}, ${keyLen}, &${snapshot}, ${commit});`, + ` ScrDyn *sc_d = scr_dyn_new_typed_ref${emitter.traceAdapterC(elem) !== null ? "_traced" : ""}(sc_v, &${rc.retain}, &${rc.release}, ${keyLit}, ${keyLen}, &${snapshot}, ${commit});`, ); } else { d.push(` ScrDyn *sc_d = ${emitter.toDynHelper(elem)}(sc_v);`); @@ -2933,7 +2952,7 @@ function emitDynamicExpr( const keyLit = cStringLiteral(Buffer.from(key, "utf8")); return emitter.newTemp( e.type, - `scr_dyn_new_typed_ref(${v.name}, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(key, "utf8")}, &${adapter.snapshot}, ${adapter.commit})`, + `scr_dyn_new_typed_ref${emitter.traceAdapterC(v.type) !== null ? "_traced" : ""}(${v.name}, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(key, "utf8")}, &${adapter.snapshot}, ${adapter.commit})`, ); } if (v.type.kind === "func") { @@ -8071,6 +8090,11 @@ function emitProcessLibCall(state: LibCallState): Temp { return finish(`scr_process_off_warning(${arg(0)})`); case "process.emitWarning": return finish(`scr_process_emit_warning(${arg(0)})`); + case "process.onUncaughtException": + emitter.usesTimers = true; + return finish(`scr_process_on_uncaught_exception(${arg(0)}, ${arg(1)}, ${arg(2)})`); + case "process.offUncaughtException": + return finish(`scr_process_off_uncaught_exception(${arg(0)}, ${arg(1)})`); case "process.onUnhandledRejection": // The completed-checkpoint report dispatches the listeners. emitter.usesTimers = true; @@ -8276,16 +8300,10 @@ function emitProcessLibCall(state: LibCallState): Temp { emitter.moveTemp(cb); return finish(`scr_next_tick(${cb.name})`); } - case "process.onSignal": { - // The registry owns the callback (zero-param — frontend-pinned) - // until off/once removes it. The loop dispatches deliveries. + case "process.onSignal": emitter.usesTimers = true; - const cb = args[1]!; - emitter.moveTemp(cb); - return finish(`scr_signal_on(${arg(0)}, ${cb.name}, ${arg(2)})`); - } + return finish(`scr_signal_on(${arg(0)}, ${arg(1)}, ${arg(2)})`); case "process.offSignal": - // Borrowed callback: removal is by pointer identity. return finish(`scr_signal_off(${arg(0)}, ${arg(1)})`); case "process.onExit": { // Runtime adapters cover both shapes (the code is a plain diff --git a/packages/compiler/src/backend/c/walkers.ts b/packages/compiler/src/backend/c/walkers.ts index a06a8bff..3173fc0c 100644 --- a/packages/compiler/src/backend/c/walkers.ts +++ b/packages/compiler/src/backend/c/walkers.ts @@ -1595,7 +1595,7 @@ export function jsonWriteHelper(emitter: CEmitter, t: IrType): string { const key = typeKey(t); const keyLit = cStringLiteral(Buffer.from(key, "utf8")); d.push( - ` return scr_dyn_new_typed_ref(v, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(key, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, + ` return scr_dyn_new_typed_ref${emitter.traceAdapterC(t) !== null ? "_traced" : ""}(v, &${rc.retain}, &${rc.release}, ${keyLit}, ${Buffer.byteLength(key, "utf8")}, &${adapter.snapshot}, ${adapter.commit});`, ); } break; diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 262eec6b..d227433d 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1,3 +1,4 @@ +import { typedRefConstructor } from "./shapes.js"; import { BYTES_ELEM_NUM, f64Lit } from "./common.js"; import { InternalCompilerError } from "../../errors.js"; /* The dyn (ScrDyn dyn) helper EMITTERS for the LLVM backend — the .ll @@ -1423,12 +1424,10 @@ export class LlDyn { const adapter = host.liveDynRefAdapter(t); const rc = vAdapters(host, t); const keyLit = host.cstr(typeKey(t)); - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + const r = B.tmp(); B.line( - `${r} = call ptr @scr_dyn_new_typed_ref(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ptr ${keyLit}, ${host.sizeType} ${Buffer.byteLength(typeKey(t), "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, + `${r} = call ptr ${typedRefConstructor(host, t)}(ptr %v, ptr ${rc.retain}, ptr ${rc.release}, ptr ${keyLit}, ${host.sizeType} ${Buffer.byteLength(typeKey(t), "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, ); B.terminate(`ret ptr ${r}`); break; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 81526a17..617b6410 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -988,7 +988,7 @@ export class LlEmitter { // unchanged. const hasRefGlobals = globals.some((g) => isRefCounted(g.type)) || fnValueProps.length > 0; const inlineExitListeners = usesEvents && (hasRefGlobals || this.ffiHasRetainedCallback); - if (!usesNodeTest && !usesIsland) this.declare(`declare i32 @scr_exit_code_hint_get()`); + this.declare(`declare i32 @scr_exit_code_hint_get()`); if (inlineExitListeners) { this.declare(`declare void @scr_run_exit_listeners(double)`); this.declare(`declare i32 @scr_exit_code_hint_get()`); @@ -1394,7 +1394,7 @@ export class LlEmitter { // order-free — the scr_error_set_traced precedent). Only the // printer emits here (nothing else declares it); scr_exc_pending // and the loop entry points rode the Set before the flush. - out.push(`declare void @scr_exc_print_uncaught()`, ``); + out.push(`declare void @scr_exc_print_uncaught()`, `declare zeroext i1 @scr_exc_handle_uncaught(i1 zeroext)`, ``); } if (this.mod.lib !== undefined) { // LIBRARY mode: no @main — the profile-declared external @@ -1448,10 +1448,14 @@ export class LlEmitter { ` %exc = call zeroext i1 @scr_exc_pending()`, ` br i1 %exc, label %uncaught, label %ok`, `uncaught:`, + ` %handled = call zeroext i1 @scr_exc_handle_uncaught(i1 false)`, + ` br i1 %handled, label %ok, label %fatal`, + `fatal:`, ` call void @scr_exc_print_uncaught()`, ...exitListenerLines("xu"), ...uncaughtReleases, - ` ret i32 1`, + ` %failure_code_0 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_0`, `ok:`, ] : []), @@ -1468,7 +1472,8 @@ export class LlEmitter { ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), ...exitListenerLines("xl"), ...loopReleasesU, - ` ret i32 1`, + ` %failure_code_1 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_1`, `lok:`, ` br i1 %loop_rejection, label %lreported, label %lclean`, `lreported:`, @@ -1476,7 +1481,8 @@ export class LlEmitter { ...(asyncEntry ? [` call void @scr_promise_release(ptr %top)`] : []), ...exitListenerLines("xq"), ...loopReportedReleases, - ` ret i32 1`, + ` %failure_code_2 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_2`, `lclean:`, ...(asyncEntry ? [ @@ -1493,7 +1499,8 @@ export class LlEmitter { ` call void @scr_exc_print_uncaught()`, ...exitListenerLines("xt"), ...topRejectReleases, - ` ret i32 1`, + ` %failure_code_3 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_3`, `tla_not_rejected:`, ` call void @scr_promise_release(ptr %top)`, ] @@ -1503,7 +1510,8 @@ export class LlEmitter { `lrej:`, ...exitListenerLines("xr"), ...loopReleasesR, - ` ret i32 1`, + ` %failure_code_4 = call i32 @scr_exit_code_hint_get()`, + ` ret i32 %failure_code_4`, `lrok:`, ...(asyncEntry ? [ diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 8d83ab0e..df5972ca 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -1,3 +1,4 @@ +import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { streamTypedRefEligible } from "../../ir/analysis.js"; @@ -58,12 +59,10 @@ export function emitDynamicExpr(host: LlvmEmitterContext, e: ExprOf<"dynFrom" | host.liveDynRefAdapters.set(key, adapter); } const rc = vAdapters(host.shapeHost, v.type); - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + const boxed = B.tmp(); B.line( - `${boxed} = call ptr @scr_dyn_new_typed_ref(ptr ${v.name}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, + `${boxed} = call ptr ${typedRefConstructor(host, v.type)}(ptr ${v.name}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, ); return host.own({ name: boxed, type: e.type }); } diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index e6b0eb8b..340eb9e0 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -1,7 +1,8 @@ +import { typedRefConstructor } from "./shapes.js"; /* Focused LLVM expression emission extracted from emitter.ts. */ import { InternalCompilerError } from "../../errors.js"; import { streamTypedRefEligible } from "../../ir/analysis.js"; -import { type IrType, classDynViewSupported, isClassOwnEnumerableFieldName, isDynTypedRefType, isRefCounted, typeKey } from "../../ir/ir.js"; +import { type IrType, DYN_CLASS_PROPERTIES, classDynViewSupported, isClassOwnEnumerableFieldName, isDynTypedRefType, isRefCounted, typeKey } from "../../ir/ir.js"; import { mangleRecordStruct } from "../mangle.js"; import { BlockBuilder } from "./blocks.js"; import { classFieldIndex, classStructSym } from "./classes.js"; @@ -185,6 +186,34 @@ export function streamTypedRefCommitAdapter(host: LlvmEmitterContext, } lines.push(` br label %${after}`, `${after}:`); } + if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { + host.declare(`declare ptr @scr_dyn_new_obj()`); + host.declare(`declare ptr @scr_dyn_copy_data_properties(ptr, ptr)`); + host.declare(`declare void @scr_dyn_release_v(ptr)`); + host.declare(`declare ptr @scr_str_new(ptr, ${host.sizeType})`); + host.declare(`declare void @scr_str_release(ptr)`); + host.declare(`declare void @scr_dyn_key_delete(ptr, ptr, i1 zeroext)`); + lines.push( + ` %bag = call ptr @scr_dyn_new_obj()`, + ` %bag_copy = call ptr @scr_dyn_copy_data_properties(ptr %bag, ptr %d)`, + ` call void @scr_dyn_release_v(ptr %bag_copy)`, + ` %bag_pending = call zeroext i1 @scr_exc_pending()`, + ` br i1 %bag_pending, label %bag_fail, label %bag_keys`, + `bag_fail:`, ` call void @scr_dyn_release_v(ptr %bag)`, ` br label %done`, `bag_keys:`, + ); + fields.forEach((field, index) => lines.push( + ` %bag_key${index} = call ptr @scr_str_new(ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")})`, + ` call void @scr_dyn_key_delete(ptr %bag, ptr %bag_key${index}, i1 zeroext false)`, + ` call void @scr_str_release(ptr %bag_key${index})`, + )); + const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); + lines.push( + ` %bag_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${index}`, + ` %bag_old = load ptr, ptr %bag_ptr`, + ` store ptr %bag, ptr %bag_ptr`, + ` call void @scr_dyn_release_v(ptr %bag_old)`, + ); + } lines.push(` br label %done`, `done:`, ` ret void`, `}`, ``); host.resolveThunkDefs.push(...lines); return `@${commit}`; @@ -263,9 +292,7 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext, const sym = `sc_ldu_${host.liveDynUnionRefAdapters.size}`; host.liveDynUnionRefAdapters.set(key, sym); - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + const adapters = new Map(); for (const { arm, tag } of mutableArms) { const prefix = `${sym}_${tag}`; @@ -312,7 +339,7 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext, ); B.line(`${payload} = load ptr, ptr ${payloadPtr}`); B.line( - `${boxed} = call ptr @scr_dyn_new_typed_ref(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, + `${boxed} = call ptr ${typedRefConstructor(host, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, ); B.terminate(`ret ptr ${boxed}`); }); @@ -388,9 +415,7 @@ function nestedTypedRefUnionAdapter( } const sym = `${ctx.prefix}_union_${unions.size}`; unions.set(key, sym); - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + const B = new BlockBuilder(); const tagPtr = B.tmp(); const tagValue = B.tmp(); @@ -413,7 +438,7 @@ function nestedTypedRefUnionAdapter( B.line(`${payloadPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 5`); B.line(`${payload} = load ptr, ptr ${payloadPtr}`); B.line( - `${boxed} = call ptr @scr_dyn_new_typed_ref(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, + `${boxed} = call ptr ${typedRefConstructor(host, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, ); B.terminate(`ret ptr ${boxed}`); } else if (arm.kind === "undefinedT") { @@ -498,11 +523,9 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext, const nested = host.streamTypedRefMaterializeAdapter(t, ctx); const rc = vAdapters(host.shapeHost, t); const key = typeKey(t); - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + B.line( - `${boxed} = call ptr @scr_dyn_new_typed_ref(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${nested.snapshot}, ptr ${nested.commit})`, + `${boxed} = call ptr ${typedRefConstructor(host, t)}(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${nested.snapshot}, ptr ${nested.commit})`, ); return boxed; } @@ -555,6 +578,16 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue, ctx); B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`); } + if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { + host.declare(`declare ptr @scr_dyn_copy_data_properties(ptr, ptr)`); + host.declare(`declare void @scr_dyn_release_v(ptr)`); + const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); + const slot = B.tmp(), bag = B.tmp(), copied = B.tmp(); + B.line(`${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`); + B.line(`${bag} = load ptr, ptr ${slot}`); + B.line(`${copied} = call ptr @scr_dyn_copy_data_properties(ptr ${out}, ptr ${bag})`); + B.line(`call void @scr_dyn_release_v(ptr ${copied})`); + } B.terminate(`ret ptr ${out}`); } else if (t.kind === "record") { const shape = host.recordsById.get(t.shapeId); @@ -735,9 +768,7 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext, `${boxed} = call ptr @${host.dyn.toDynHelper(elem)}(ptr ${value})`, ); } else { - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + const boxedSlot = B.slot(); B.entryAllocas.push(`${boxedSlot} = alloca ptr`); const tagPtr = B.tmp(); @@ -769,7 +800,7 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext, ); B.line(`${payload} = load ptr, ptr ${payloadPtr}`); B.line( - `${armBoxed} = call ptr @scr_dyn_new_typed_ref(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${armSnapshot}, ptr ${armCommit})`, + `${armBoxed} = call ptr ${typedRefConstructor(host, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${armSnapshot}, ptr ${armCommit})`, ); B.line(`store ptr ${armBoxed}, ptr ${boxedSlot}`); B.br(join); @@ -815,11 +846,9 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext, ``, ); } - host.declare( - `declare ptr @scr_dyn_new_typed_ref(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`, - ); + B.line( - `${boxed} = call ptr @scr_dyn_new_typed_ref(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${keyPtr}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${snapshot}, ptr ${commit})`, + `${boxed} = call ptr ${typedRefConstructor(host, elem)}(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${keyPtr}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${snapshot}, ptr ${commit})`, ); } else { boxed = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 760f950e..827c6f5a 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -436,15 +436,13 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll return { name: "", type: e.type }; } if (e.fn === "process.onSignal") { - // The registry owns the callback (zero-param — frontend-pinned) - // until off/once removes it. The loop dispatches deliveries. host.usesTimers = true; const sig = host.emitExpr(e.args[0]!); const cb = host.emitExpr(e.args[1]!); const once = host.emitExpr(e.args[2]!); - host.moveTemp(cb); - host.declare(`declare void @scr_signal_on(double, ptr, i1 zeroext)`); - B.line(`call void @scr_signal_on(double ${sig.name}, ptr ${cb.name}, i1 ${once.name})`); + host.declare(`declare void @scr_signal_on(ptr, ptr, i1 zeroext)`); + B.line(`call void @scr_signal_on(ptr ${sig.name}, ptr ${cb.name}, i1 ${once.name})`); + host.emitPendingCheck(); return { name: "", type: e.type }; } if (e.fn === "process.onExit") { diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 2e2fd0f8..e41b76f8 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -849,6 +849,8 @@ export const LIB_FN_SYMS: Record = { "process.onWarning": "scr_process_on_warning", "process.offWarning": "scr_process_off_warning", "process.emitWarning": "scr_process_emit_warning", + "process.onUncaughtException": "scr_process_on_uncaught_exception", + "process.offUncaughtException": "scr_process_off_uncaught_exception", "process.onUnhandledRejection": "scr_process_on_unhandled_rejection", "process.offUnhandledRejection": "scr_process_off_unhandled_rejection", "process.onRejectionHandled": "scr_process_on_rejection_handled", @@ -944,6 +946,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ // the checkpoint unhandled-rejection report. "async.hop", "async.awaitDyn", "dc.tcTracePromise", + "process.onUncaughtException", "process.onUnhandledRejection", "process.onRejectionHandled", "timers.queueMicrotaskDyn", "timers.setImmediateFnValue", "timers.immediatePromise", ]); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index cd62ae21..28379492 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -176,6 +176,13 @@ export function traceArg(host: ShapeHost, t: IrType): string { return traceAdapter(host, t) ?? "null"; } +/** Keep native capsule edges visible when the referent is cycle-capable. */ +export function typedRefConstructor(host: ShapeHost, t: IrType): string { + const name = traceAdapter(host, t) === null ? "scr_dyn_new_typed_ref" : "scr_dyn_new_typed_ref_traced"; + host.declare(`declare ptr @${name}(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr, ptr)`); + return `@${name}`; +} + /* ── arrays ───────────────────────────────────────────────────────────── */ /** Runtime accessor suffix for an element type (matches types.ts: diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 44ee28cd..0c9fc06b 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,9 +1,10 @@ -import { BOOL, DYN, STRING, isDynTypedRefType, typeEquals, typeKey, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { BOOL, DYN, DYN_CLASS_PROPERTIES as PROPERTY_BAG, STRING, VOID, canConvertToDyn, canDynCheckTo, isClassOwnEnumerableFieldName, isDynTypedRefType, isUnitType, typeEquals, typeKey, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js"; +import { streamTypedRefEligible } from "../../ir/analysis.js"; import { varRef } from "../../ir/build.js"; import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; import { dynUndefinedExpr, PoisonError, type Lowerer } from "./lowerer.js"; import { implicitDefaultInstance, type ParamShape } from "./lower-calls.js"; -import { findGenericMethodOn, findMethodOn, upcastTo, type ClassInfo } from "./lower-classes.js"; +import { accessorCall, findGenericMethodOn, findMethodOn, upcastTo, type ClassInfo } from "./lower-classes.js"; type Invoke = Extract; interface Dispatch { @@ -12,6 +13,13 @@ interface Dispatch { classes: Set; } +interface PropertyDispatch { + name: string; + write: boolean; + fn: IrFunction; + classes: Set; +} + /** Calls on native class capsules keep the instance's compiled methods. The * reachable-body fixed point discovers both the boxed classes and method * names before generating checked native dispatch; ordinary dyn receivers @@ -19,6 +27,10 @@ interface Dispatch { export class ClassDynamicDispatch { private readonly boxed = new Set(); private readonly dispatches = new Map(); + private readonly properties = new Map(); + private readonly computed = new Map & { keyLocal: string; branchIndex: number }>(); + private propertyBag: IrFunction | null = null; + private readonly bagClasses = new Set(); private readonly generated = new Set(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { @@ -46,6 +58,39 @@ export class ClassDynamicDispatch { }); if (this.boxed.size === 0) return false; let changed = false; + if (!this.propertyBag) { + const loc = functions[0]!.loc; + this.propertyBag = { + name: "%dyn.class.properties", params: [{ localId: "p.0", name: "value", type: DYN }], returnType: DYN, + locals: [{ id: "p.0", name: "value", type: DYN, mutable: false }], + body: [{ kind: "return", value: varRef("p.0", DYN, loc), loc }], loc, + }; + this.generated.add(this.propertyBag); + lowerer.liftedFns.push(this.propertyBag); + changed = true; + } + for (const className of this.boxed) { + if (this.bagClasses.has(className)) continue; + const info = lowerer.classes.get(className); + if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue; + this.bagClasses.add(className); + this.ensurePropertyBag(info); + const loc = this.propertyBag.loc; + const type: IrType = { kind: "object", className }; + const value = varRef("p.0", DYN, loc); + const receiver: IrExpr = { kind: "dynCheck", value, type, loc }; + const bag: IrExpr = { kind: "fieldGet", obj: receiver, className, field: PROPERTY_BAG, type: DYN, loc }; + this.propertyBag.body.unshift({ + kind: "if", cond: { kind: "libCall", fn: "dyn.typedRefIs", args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc }, + then: [ + { kind: "if", cond: { kind: "dynTest", test: "undefined", value: bag, type: BOOL, loc }, then: [ + { kind: "fieldSet", obj: receiver, className, field: PROPERTY_BAG, value: { kind: "dynObjLit", fields: [], type: DYN, loc }, loc }, + ], else_: null, loc }, + { kind: "return", value: bag, loc }, + ], else_: null, loc, + }); + changed = true; + } const byMethod = new Map(); const candidates = (method: string): ClassInfo[] => { const found = byMethod.get(method); @@ -63,6 +108,74 @@ export class ClassDynamicDispatch { fn.body = transformStmtList(fn.body, { stmt: (stmt) => stmt, expr: (expr) => { + const computedRead = expr.kind === "dynKeyGet" && expr.key.kind !== "strLit" ? expr : null; + const computedWrite = expr.kind === "libCall" && (expr.fn === "dyn.keySetComputed" || expr.fn === "dyn.keySet" && expr.args[1]?.kind !== "strLit") ? expr : null; + if (computedRead || computedWrite) { + const dynamicKey = computedWrite?.fn === "dyn.keySetComputed"; + const key = JSON.stringify([!!computedWrite, computedRead?.optional ?? false, dynamicKey]); + let dispatch = this.computed.get(key); + if (!dispatch) { + const loc = expr.loc; + const params = [ + { localId: "p.0", name: "value", type: DYN }, + { localId: "p.key", name: "key", type: dynamicKey ? DYN : STRING }, + ...(computedWrite ? [{ localId: "p.1", name: "stored", type: DYN }] : []), + ]; + const keyLocal = dynamicKey ? "key.string" : "p.key"; + const bag: IrExpr = { kind: "call", callee: this.propertyBag!.name, args: [varRef("p.0", DYN, loc)], type: DYN, loc }; + const fallback: IrExpr = computedRead + ? { ...computedRead, value: bag, key: varRef("p.key", STRING, loc) } + : { ...computedWrite!, fn: "dyn.keySet", args: [bag, varRef(keyLocal, STRING, loc), varRef("p.1", DYN, loc)] }; + const helper: IrFunction = { + name: `%dyn.class.computed.${this.computed.size}`, params, returnType: computedWrite ? VOID : DYN, + locals: [...params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })), ...(dynamicKey ? [{ id: keyLocal, name: "key", type: STRING, mutable: false }] : [])], + body: computedWrite + ? [{ kind: "exprStmt", expr: fallback, loc }, { kind: "return", value: null, loc }] + : [{ kind: "return", value: fallback, loc }], loc, + }; + if (dynamicKey) helper.body.unshift( + { kind: "if", cond: { kind: "dynTest", test: "nullish", value: varRef("p.0", DYN, loc), type: BOOL, loc }, then: [ + { kind: "exprStmt", expr: { ...computedWrite!, args: [varRef("p.0", DYN, loc), varRef("p.key", DYN, loc), varRef("p.1", DYN, loc)] }, loc }, + { kind: "return", value: null, loc }, + ], else_: null, loc }, + { kind: "varDecl", localId: keyLocal, init: { kind: "libCall", fn: "dyn.toStringCoerce", args: [varRef("p.key", DYN, loc)], type: STRING, loc }, loc }, + ); + dispatch = { write: !!computedWrite, fn: helper, classes: new Set(), keyLocal, branchIndex: dynamicKey ? 2 : 0 }; + this.computed.set(key, dispatch); + this.generated.add(helper); + lowerer.liftedFns.push(helper); + changed = true; + } + return { kind: "call", callee: dispatch.fn.name, args: computedRead ? [computedRead.value, computedRead.key] : computedWrite!.args, type: dispatch.fn.returnType, loc: expr.loc }; + } + const read = expr.kind === "dynKeyGet" && expr.key.kind === "strLit" ? expr : null; + const write = expr.kind === "libCall" && expr.fn === "dyn.keySet" && expr.args[1]?.kind === "strLit" ? expr : null; + const name = read?.key.kind === "strLit" ? read.key.value : write?.args[1]?.kind === "strLit" ? write.args[1].value : null; + if (name !== null) { + const key = JSON.stringify([name, !!write, read?.optional ?? false]); + let dispatch = this.properties.get(key); + if (!dispatch) { + const loc = expr.loc; + const params = (write ? [0, 1] : [0]).map((i) => ({ localId: `p.${i}`, name: `p${i}`, type: DYN })); + const bag: IrExpr = { kind: "call", callee: this.propertyBag!.name, args: [varRef("p.0", DYN, loc)], type: DYN, loc }; + const fallback: IrExpr = read + ? { ...read, value: bag } + : { ...write!, args: [bag, write!.args[1]!, varRef("p.1", DYN, loc)] }; + const helper: IrFunction = { + name: `%dyn.class.property.${this.properties.size}`, params, returnType: write ? VOID : DYN, + locals: params.map((p) => ({ id: p.localId, name: p.name, type: DYN, mutable: false })), + body: write + ? [{ kind: "exprStmt", expr: fallback, loc }, { kind: "return", value: null, loc }] + : [{ kind: "return", value: fallback, loc }], loc, + }; + dispatch = { name, write: !!write, fn: helper, classes: new Set() }; + this.properties.set(key, dispatch); + this.generated.add(helper); + lowerer.liftedFns.push(helper); + changed = true; + } + return { kind: "call", callee: dispatch.fn.name, args: read ? [read.value] : [write!.args[0]!, write!.args[2]!], type: dispatch.fn.returnType, loc: expr.loc }; + } if (expr.kind !== "dynInvoke" || candidates(expr.method).length === 0) return expr; const key = JSON.stringify([expr.method, expr.calleeName, expr.args.length]); let dispatch = this.dispatches.get(key); @@ -109,9 +222,139 @@ export class ClassDynamicDispatch { changed = true; } } + for (const dispatch of this.properties.values()) { + for (const info of this.propertyCandidates(lowerer, dispatch.name)) { + if (dispatch.classes.has(info.def.name)) continue; + dispatch.classes.add(info.def.name); + const loc = dispatch.fn.loc; + const type: IrType = { kind: "object", className: info.def.name }; + const receiver = varRef("p.0", DYN, loc); + const before = lowerer.diags.length; + let branch: IrStmt[]; + try { + branch = this.propertyBody(lowerer, dispatch, info, { kind: "dynCheck", value: receiver, type, loc }); + } catch (error) { + if (!(error instanceof PoisonError) || !info.decl) throw error; + const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); + if (!fence) throw error; + branch = [fence]; + } + dispatch.fn.body.unshift({ + kind: "if", cond: { + kind: "libCall", fn: "dyn.typedRefIs", args: [receiver, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc, + }, then: branch, else_: null, loc, + }); + changed = true; + } + } + for (const dispatch of this.computed.values()) { + for (const className of this.boxed) { + if (dispatch.classes.has(className)) continue; + const info = lowerer.classes.get(className); + if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) continue; + dispatch.classes.add(className); + const loc = dispatch.fn.loc; + const type: IrType = { kind: "object", className }; + const value = varRef("p.0", DYN, loc); + const receiver: IrExpr = { kind: "dynCheck", value, type, loc }; + const names = new Set([...info.fields.keys()].filter(isClassOwnEnumerableFieldName)); + for (let owner: ClassInfo | null = info; owner; owner = owner.base) { + for (const method of owner.methods.keys()) { + if ((method.startsWith("get:") || method.startsWith("set:")) && isClassOwnEnumerableFieldName(method.slice(4))) names.add(method.slice(4)); + } + } + const branch: IrStmt[] = []; + for (const name of names) { + const before = lowerer.diags.length; + let body: IrStmt[]; + try { + body = this.propertyBody(lowerer, { ...dispatch, name }, info, receiver); + } catch (error) { + if (!(error instanceof PoisonError) || !info.decl) throw error; + const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); + if (!fence) throw error; + body = [fence]; + } + branch.push({ kind: "if", cond: { + kind: "strEq", left: varRef(dispatch.keyLocal, STRING, loc), right: { kind: "strLit", value: name, type: STRING, loc }, negated: false, type: BOOL, loc, + }, then: body, else_: null, loc }); + } + dispatch.fn.body.splice(dispatch.branchIndex, 0, { kind: "if", cond: { + kind: "libCall", fn: "dyn.typedRefIs", args: [value, { kind: "strLit", value: typeKey(type), type: STRING, loc }], type: BOOL, loc, + }, then: branch, else_: null, loc }); + changed = true; + } + } return changed; } + /** The hidden bag is part of the native object layout, so every capsule + * shares it and normal class tracing/disposal owns its values. Insert it + * at the same prefix offset throughout a hierarchy, including classes + * collected before this untyped crossing was discovered. */ + private ensurePropertyBag(info: ClassInfo): void { + if (info.fields.has(PROPERTY_BAG)) return; + let root = info; + while (root.base && !root.base.def.runtime) root = root.base; + const index = root.def.fields.length; + const add = (current: ClassInfo): void => { + if (!current.fields.has(PROPERTY_BAG)) { + current.fields.set(PROPERTY_BAG, DYN); + current.def.fields.splice(index, 0, { name: PROPERTY_BAG, type: DYN }); + } + current.subclasses.forEach(add); + }; + add(root); + } + + private propertyCandidates(lowerer: Lowerer, name: string): ClassInfo[] { + if (!isClassOwnEnumerableFieldName(name)) return []; + return [...this.boxed].flatMap((className) => { + const info = lowerer.classes.get(className); + if (!info || info.builtinEmitter || info.builtinStream || info.builtinError) return []; + return info.fields.has(name) || findMethodOn(lowerer, info, `get:${name}`) || findMethodOn(lowerer, info, `set:${name}`) ? [info] : []; + }); + } + + private propertyBody(lowerer: Lowerer, dispatch: PropertyDispatch, info: ClassInfo, receiver: IrExpr): IrStmt[] { + const { name, write } = dispatch; + const loc = dispatch.fn.loc; + const field = info.fields.get(name); + const member = `${write ? "set" : "get"}:${name}`; + const accessor = field ? null : findMethodOn(lowerer, info, member); + const fence = (): IrStmt[] => [{ kind: "runtimeFence", code: "SC2020", message: `${write ? "writing" : "reading"} '${name}' on this native class through an untyped value is not supported yet`, loc }]; + const getRecord = (id: string) => lowerer.shapes.get(id); + const getUnion = (id: string) => lowerer.unions.get(id); + if (!write) { + if (!field && !accessor) return [{ kind: "return", value: dynUndefinedExpr(loc), loc }]; + const type = field ?? accessor!.sig.ret; + if (!canConvertToDyn(type, getRecord, getUnion)) return fence(); + const value: IrExpr = field + ? { kind: "fieldGet", obj: receiver, className: info.def.name, field: name, type, loc } + : accessorCall(lowerer, info.def.name, member, receiver, [], type, loc); + const boxed = lowerer.coerceToExpected(value, DYN); + const mutable = (t: IrType): boolean => streamTypedRefEligible(t) || + (t.kind === "union" && (getUnion(t.unionId)?.arms.some(mutable) ?? false)); + if (boxed.kind === "dynFrom" && mutable(boxed.value.type)) boxed.liveRef = true; + return [{ kind: "return", value: boxed, loc }]; + } + if (!field && !accessor) return [{ kind: "throw", value: { + kind: "libCall", fn: "error.new", args: [{ + kind: "strLit", value: `Cannot set property ${name} of #<${info.def.jsName ?? info.def.name}> which has only a getter`, type: STRING, loc, + }], type: { kind: "object", className: "%TypeError" }, loc, + }, loc }]; + const type = field ?? accessor!.sig.params[0]!.type; + const checkable = (t: IrType): boolean => isDynTypedRefType(t) || isUnitType(t) || + (t.kind === "union" ? getUnion(t.unionId)?.arms.every(checkable) ?? false : canDynCheckTo(t, getRecord, getUnion)); + if (!checkable(type)) return fence(); + const value = lowerer.coerceToExpected(varRef("p.1", DYN, loc), type); + if (!typeEquals(value.type, type)) return fence(); + const store: IrStmt = field + ? { kind: "fieldSet", obj: receiver, className: info.def.name, field: name, value, loc } + : { kind: "exprStmt", expr: accessorCall(lowerer, info.def.name, member, receiver, [value], VOID, loc), loc }; + return [store, { kind: "return", value: null, loc }]; + } + private methodBody(lowerer: Lowerer, dispatch: Dispatch, info: ClassInfo, receiver: IrExpr): IrStmt[] { const { method, loc } = dispatch.source; const fence = (): IrStmt[] => [{ kind: "runtimeFence", code: "SC2020", message: `calling '${method}' on this native class through an untyped value is not supported yet`, loc }]; diff --git a/packages/compiler/src/frontend/lowering/lower-builtins.ts b/packages/compiler/src/frontend/lowering/lower-builtins.ts index 72e065fd..0b852d2b 100644 --- a/packages/compiler/src/frontend/lowering/lower-builtins.ts +++ b/packages/compiler/src/frontend/lowering/lower-builtins.ts @@ -8117,7 +8117,7 @@ function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr const member = lowerer.stdlibGlobalMember(access, "process"); if (member === null) return null; const loc = locOf(call); - // process.on/once/off: the CLI event slice — the SIGINT/SIGTERM + // process.on/once/off: the CLI event slice — named OS // signal handlers and the 'exit' hook. Signal listeners run as // macrotasks at loop turns, replace the default disposition while // registered (removing the last restores Ctrl-C death), and never @@ -8152,12 +8152,17 @@ function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr loc, }; } - // own(), not a bare index: the key is a USER-written event name, - // and `{ SIGINT: 2 }["__proto__"]` answers Object.prototype — an - // object flowed into a numLit and emitted itself into the C - // (test-event-emitter-special-event-names.js's process.on). - const SIGNALS: Record = { SIGINT: 2, SIGTERM: 15 }; - const signo = event !== null ? own(SIGNALS, event) : undefined; + if (event === "uncaughtException" || event === "uncaughtExceptionMonitor") { + if (!ts.isExpressionStatement(call.parent)) { + lowerer.unsupported("SC1090", call, "chaining process exception listener registration"); + } + const cb = dcSubscriberArg(lowerer, call.arguments[1]!); + const monitor = boolLit(event === "uncaughtExceptionMonitor", loc); + return { + kind: "libCall", fn: isOff ? "process.offUncaughtException" : "process.onUncaughtException", + args: isOff ? [cb, monitor] : [cb, boolLit(member === "once", loc), monitor], type: VOID, loc, + }; + } // 'unhandledRejection': the listener crosses as a dyn function and // the completed-checkpoint report dispatches it (reason, promise) per // never-observed rejection instead of printing and exiting 1 @@ -8201,12 +8206,23 @@ function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr loc, }; } - if (signo === undefined && event !== "exit") { - lowerer.noLowering( - `process.${member}(${event === null ? "non-literal event" : `"${event}"`}, ...)`, - call.arguments[0]!, - '"message", "disconnect", "SIGINT", "SIGTERM", "exit", "warning", "unhandledRejection", and "rejectionHandled" are the supported process events (as literals)', - ); + if (event === null || event.startsWith("SIG")) { + if (!ts.isExpressionStatement(call.parent)) { + lowerer.unsupported("SC1090", call, "chaining process signal listener registration"); + } + const name = lowerer.coerceToExpected(lowerer.lowerExpr(call.arguments[0]!), STRING); + if (name.type.kind !== "string" && name.type.kind !== "dyn") { + lowerer.noLowering("process signal event name that is not a string", call.arguments[0]!); + } + const signal = name.type.kind === "string" ? name : { kind: "dynCheck" as const, value: name, type: STRING, loc }; + const cb = dcSubscriberArg(lowerer, call.arguments[1]!); + return { + kind: "libCall", fn: isOff ? "process.offSignal" : "process.onSignal", + args: isOff ? [signal, cb] : [signal, cb, boolLit(member === "once", loc)], type: VOID, loc, + }; + } + if (event !== "exit") { + lowerer.noLowering(`process.${member}("${event}", ...)`, call.arguments[0]!); } if (!ts.isExpressionStatement(call.parent)) { lowerer.unsupported( @@ -8225,7 +8241,7 @@ function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr // result; a non-function dyn value throws the catchable TypeError // at REGISTRATION (Node's ERR_INVALID_ARG_TYPE moment). { - const target = funcOf(signo !== undefined || event !== "exit" ? [] : [F64], VOID); + const target = funcOf([F64], VOID); const exact = cb.type.kind === "func" && cb.type.ret.kind === "void" && @@ -8256,20 +8272,6 @@ function lowerProcessIpcSend(lowerer: Lowerer, call: ts.CallExpression): IrExpr } const param = cb.type.params[0]; const onceArg: IrExpr = { kind: "boolLit", value: member === "once", type: BOOL, loc }; - if (signo !== undefined) { - if (param !== undefined) { - lowerer.unsupported( - "SC1090", - call.arguments[1]!, - "signal listeners with parameters (the signal name argument has no lowering — use ())", - ); - } - const sig: IrExpr = { kind: "numLit", value: signo, type: F64, loc }; - if (isOff) { - return { kind: "libCall", fn: "process.offSignal", args: [sig, cb], type: VOID, loc }; - } - return { kind: "libCall", fn: "process.onSignal", args: [sig, cb, onceArg], type: VOID, loc }; - } if (param !== undefined && param.kind !== "f64") { lowerer.unsupported( "SC1090", diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index b5b5f1b1..aaaca6f8 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3811,14 +3811,10 @@ export type IrLibFn = | "process.cpuPrevValidate" | "process.rusage" | "process.activeResources" - /** Process signal events — process.on/once/off("SIGINT" | "SIGTERM"). - * args: [signo f64 (the frontend bakes the POSIX number), cb, once - * bool] for on; [signo, cb] for off (cb borrowed, removed by pointer - * identity — Node's removeListener contract). Handlers run as - * macrotasks at loop turns; watching replaces the default disposition - * and removing the last listener restores it; signal listeners never - * keep the loop alive (Node). Zero-param callbacks only (the ambient - * shape). Never throw. */ + /** Named process signal events: [name string, callback dyn, once bool] + * or [name, callback] for removal. The runtime resolves platform signal + * numbers and delivers (name, number). Listeners do not keep the loop + * alive. Invalid callbacks and uncatchable signals throw. */ | "process.onSignal" | "process.offSignal" /** The process 'exit' event — process.on/once/off("exit"). args: [cb, @@ -4502,6 +4498,9 @@ export type IrLibFn = * Node's once); off/removeListener remove by closure identity, the * offWarning stance. Throws Node's ERR_INVALID_ARG_TYPE on a * non-function. */ + /** Uncaught exception handlers and monitors: [callback dyn, once, monitor]. */ + | "process.onUncaughtException" + | "process.offUncaughtException" | "process.onUnhandledRejection" | "process.offUnhandledRejection" /** process.on/once/off('rejectionHandled', fn): the sibling registry. @@ -6230,6 +6229,9 @@ export function isClassOwnEnumerableFieldName(name: string): boolean { return !name.startsWith("#") && !name.startsWith("%"); } +/** Compiler-owned storage for properties added through untyped references. */ +export const DYN_CLASS_PROPERTIES = "%dynProperties"; + /** A class capsule can always preserve its exact native identity. Its * optional property view additionally needs converters in both directions; * fields such as Maps may remain opaque without preventing the round trip. */ @@ -6907,6 +6909,7 @@ export function moduleUsesDynInvoke(mod: IrModule): boolean { export function moduleUsesDynAsync(mod: IrModule): boolean { const fns = new Set([ "async.awaitDyn", "timers.immediatePromise", + "process.onUncaughtException", "process.offUncaughtException", "process.onUnhandledRejection", "process.offUnhandledRejection", "process.onRejectionHandled", "process.offRejectionHandled", "process.onWarning", "process.offWarning", "process.emitWarning", @@ -7697,6 +7700,10 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dc.tcTraceSync", "dc.tcTraceCallback", "dc.tcTracePromise", + "process.onUncaughtException", + "process.offUncaughtException", + "process.onSignal", + "process.offSignal", "process.onUnhandledRejection", "process.onRejectionHandled", "async.awaitDyn", diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 67a4c7d4..97c9fe9e 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -192,8 +192,8 @@ export const LIB_FN_SIGS: Record/tests/corpus/primitive-and-function-descriptors.js" ], "diags": [] + }, + "/tests/corpus/3068-url-checked-identity.ts": { + "order": [ + "/tests/corpus/3068-url-checked-identity.ts" + ], + "diags": [] + }, + "/tests/corpus/3069-set-checked-seed.js": { + "order": [ + "/tests/corpus/3069-set-checked-seed.js" + ], + "diags": [] + }, + "/tests/corpus/3070-checked-object-accessors.js": { + "order": [ + "/tests/corpus/3070-checked-object-accessors.js" + ], + "diags": [] + }, + "/tests/corpus/3071-checked-specialization-narrowing.js": { + "order": [ + "/tests/corpus/3071-checked-specialization-narrowing.js" + ], + "diags": [] + }, + "/tests/corpus/3072-weak-process-streams.js": { + "order": [ + "/tests/corpus/3072-weak-process-streams.js" + ], + "diags": [] + }, + "/tests/corpus/3073-optional-callable-module.js": { + "order": [ + "/tests/corpus/3073-optional-callable-module.js" + ], + "diags": [] + }, + "/tests/corpus/native-class-properties.cjs": { + "order": [ + "/tests/corpus/native-class-properties.cjs" + ], + "diags": [] + }, + "/tests/corpus/native-class-property-cycles.cjs": { + "order": [ + "/tests/corpus/native-class-property-cycles.cjs" + ], + "diags": [] + }, + "/tests/corpus/native-class-property-order.cjs": { + "order": [ + "/tests/corpus/native-class-property-order.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-named-signals.cjs": { + "order": [ + "/tests/corpus/process-named-signals.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-handler-throws.cjs": { + "order": [ + "/tests/corpus/process-uncaught-handler-throws.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-loop.cjs": { + "order": [ + "/tests/corpus/process-uncaught-loop.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-module.mjs": { + "order": [ + "/tests/corpus/process-uncaught-module.mjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-monitor.cjs": { + "order": [ + "/tests/corpus/process-uncaught-monitor.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-rejection.cjs": { + "order": [ + "/tests/corpus/process-uncaught-rejection.cjs" + ], + "diags": [] + }, + "/tests/corpus/process-uncaught-sync.cjs": { + "order": [ + "/tests/corpus/process-uncaught-sync.cjs" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 2f3ea1fb..ce51c8f0 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -156,6 +156,7 @@ typedef struct ScrFiber ScrFiber; struct ScrPromise { size_t rc; int state; + bool top_level_handled; /* uncaughtException handled entry evaluation */ /* Payload (fulfillment value or rejection reason), ScrExcCell-style. * trace_fn is non-NULL iff the REF payload type carries a cycle header * (a promise settled with a cycle-capable value can be a cycle member — @@ -1553,7 +1554,7 @@ int scr_promise_finish_top_level(ScrPromise *p) { return 13; } scr_prom_observe(p); - return p->state == SCR_PROM_REJECTED ? 1 : 0; + return p->state == SCR_PROM_REJECTED && !p->top_level_handled ? 1 : 0; } void scr_promise_rethrow_top_level(ScrPromise *p) { @@ -2487,14 +2488,13 @@ void scr_loop_set_stream(bool (*pending)(void), void (*dispatch)(void)) { * microtask checkpoints between macrotasks). */ bool scr_loop_has_ready(void) { return scr_ready_len > 0; } -bool scr_loop_run(ScrPromise *top_level) { +static bool scr_loop_run_pass(ScrPromise *top_level, bool first_checkpoint) { /* The FIRST checkpoint after the synchronous main body runs promise * jobs BEFORE the first tick drain: Node's main-module evaluation is * itself awaited (the runMain continuation is a microtask queued after * the body's own), so microtasks scheduled during the body beat ticks * scheduled during the body exactly once, at startup — differentially * pinned. Every later checkpoint drains ticks first. */ - bool first_checkpoint = true; bool rejection_failed = false; for (;;) { if (top_level != NULL && top_level->state == SCR_PROM_REJECTED) break; @@ -2860,14 +2860,14 @@ bool scr_loop_run(ScrPromise *top_level) { scr_firing_refresh = false; } ((void (*)(ScrClosure *))t.cb->fn)(t.cb); - if (t.id != 0 && t.repeat_ms > 0 && !scr_firing_cleared && !scr_exc_pending()) { + if (t.id != 0 && t.repeat_ms > 0 && !scr_firing_cleared) { /* Re-arm relative to the post-callback clock (libuv's uv_timer * repeat behavior: no catch-up bursts after a slow callback). The * ref state carries across ticks (a self-unref'd interval stays * unref'd). */ ScrTimer again = {scr_now_ms() + t.repeat_ms, scr_timer_seq++, t.cb, t.repeat_ms, t.id, scr_firing_reffed, t.delay_ms}; scr_timer_push(again); - } else if (t.id != 0 && scr_firing_refresh && !scr_firing_cleared && !scr_exc_pending()) { + } else if (t.id != 0 && scr_firing_refresh && !scr_firing_cleared) { /* refresh() from inside the one-shot's own callback: re-arm to * now + the original delay, ref state carried (Node's * Timeout.refresh — the timer fires again). */ @@ -2916,6 +2916,32 @@ bool scr_loop_run(ScrPromise *top_level) { } } } + return rejection_failed; +} + +bool scr_loop_run(ScrPromise *top_level) { + bool first_checkpoint = true; + bool rejection_failed; + for (;;) { + rejection_failed = scr_loop_run_pass(top_level && !top_level->top_level_handled ? top_level : NULL, first_checkpoint); + first_checkpoint = false; + if (scr_exc_pending()) { + if (scr_exc_handle_uncaught(false)) continue; + break; /* main reports the original or handler-thrown exception */ + } + if (rejection_failed) break; + if (top_level && top_level->state == SCR_PROM_REJECTED && !top_level->top_level_handled) { + scr_promise_rethrow(top_level); + if (scr_exc_handle_uncaught(true)) { + top_level->top_level_handled = true; + scr_prom_observe(top_level); + continue; + } + scr_discard_unhandled_rejections(); + break; + } + break; + } /* Exit can now leave UNREF'd timers armed in the heap (ordinary * exhaustion, a fatal module root, or an unhandled rejection). They * never fire, so release their closures here or the RC audit counts @@ -3000,7 +3026,15 @@ bool scr_report_unhandled_rejections(void) { * (scr_prom_observe). */ p->rejection_observed = true; p->reported_unhandled = true; - if (!scr_urj_deliver_fn(p)) crashed = true; + if (!scr_urj_deliver_fn(p) && !scr_exc_handle_uncaught(false)) crashed = true; + } else if (scr_uncaught_exception_hook != NULL) { + scr_promise_rethrow(p); + if (scr_exc_handle_uncaught(true)) { + p->rejection_observed = true; + p->reported_unhandled = true; + } else { + crashed = true; + } } else if (!any) { any = true; fflush(stdout); @@ -3038,7 +3072,6 @@ bool scr_report_unhandled_rejections(void) { scr_nunhandled = remaining; if (crashed) { scr_exc_print_uncaught(); - scr_exit_code_note(1); return true; } if (scr_island_rejections_fn != NULL) { diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 5de7a7b2..8969614f 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -149,25 +149,39 @@ ScrDyn *scr_als_exit_run(double id, ScrDyn *fn, ScrDyn *args) { /* The two rejection-event registries share one shape: listeners with a * `once` flag (auto-removed after one delivery, Node's once) and * identity-based removal (the offWarning stance). */ +typedef struct { + size_t refs; + bool fired; +} ScrRejState; + typedef struct { ScrDyn *fn; bool once; + uint64_t id; + ScrRejState *state; /* snapshots share the once-fired state */ } ScrRejListener; +static void scr_rej_release(ScrRejListener *l) { + scr_dyn_release(l->fn); + if (--l->state->refs == 0) free(l->state); +} + +static uint64_t scr_rej_id = 0; + static ScrRejListener *scr_urj_listeners = NULL; static size_t scr_nurj = 0, scr_urj_cap = 0; static ScrRejListener *scr_rjh_listeners = NULL; static size_t scr_nrjh = 0, scr_rjh_cap = 0; static void scr_urj_teardown(void) { - for (size_t i = 0; i < scr_nurj; i++) scr_dyn_release(scr_urj_listeners[i].fn); + for (size_t i = 0; i < scr_nurj; i++) scr_rej_release(&scr_urj_listeners[i]); free(scr_urj_listeners); scr_urj_listeners = NULL; scr_nurj = scr_urj_cap = 0; } static void scr_rjh_teardown(void) { - for (size_t i = 0; i < scr_nrjh; i++) scr_dyn_release(scr_rjh_listeners[i].fn); + for (size_t i = 0; i < scr_nrjh; i++) scr_rej_release(&scr_rjh_listeners[i]); free(scr_rjh_listeners); scr_rjh_listeners = NULL; scr_nrjh = scr_rjh_cap = 0; @@ -191,16 +205,20 @@ static void scr_rej_push(ScrRejListener **list, size_t *n, size_t *cap, ScrDyn * *list = realloc(*list, *cap * sizeof **list); if (!*list) scr_ad_oom(); } - (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once}; + ScrRejState *state = calloc(1, sizeof *state); + if (!state) scr_ad_oom(); + state->refs = 1; + (*list)[(*n)++] = (ScrRejListener){scr_dyn_retain(fn), once, ++scr_rej_id, state}; } static void scr_rej_remove(ScrRejListener *list, size_t *n, ScrDyn *fn) { - for (size_t i = 0; i < *n; i++) { + for (size_t left = *n; left > 0; left--) { + size_t i = left - 1; ScrDyn *l = list[i].fn; bool same = l == fn || (l->kind == SCR_DYN_FUNC && fn->kind == SCR_DYN_FUNC && scr_dyn_strict_eq(l, fn)); if (same) { - scr_dyn_release(l); + scr_rej_release(&list[i]); memmove(list + i, list + i + 1, (*n - i - 1) * sizeof *list); (*n)--; return; @@ -251,35 +269,96 @@ void scr_process_off_rejection_handled(ScrDyn *fn) { scr_rjh_sync_hook(); } -/* One registry pass: call every listener with `args`, removing once- - * listeners BEFORE their call (Node's once removes at dispatch, so a - * re-registration inside the listener sticks). The registry is accessed - * THROUGH its pointers per step — a listener that registers can realloc - * the array mid-pass. Returns false when a listener threw (the caller's - * crash path). */ +/* Snapshot dispatch preserves EventEmitter mutation rules: removals do + * not skip a pending listener, additions wait for the next delivery, and + * once entries leave the live list before invoking user code. */ static bool scr_rej_fire(ScrRejListener **list, size_t *n, ScrDyn **args, size_t argc) { - size_t i = 0; - bool ok = true; - while (i < *n && ok) { - /* Own +1 across the call: a once-removal (here) or the listener - * removing itself (off inside the body) must not free a running - * function. */ - ScrDyn *fn = scr_dyn_retain((*list)[i].fn); - if ((*list)[i].once) { - scr_dyn_release((*list)[i].fn); - memmove(*list + i, *list + i + 1, (*n - i - 1) * sizeof **list); - (*n)--; - } else { - i++; - } - ScrDyn *r = scr_dyn_call(fn, args, argc, "listener"); - if (r == NULL) ok = false; - else scr_dyn_release(r); - scr_dyn_release(fn); + size_t count = *n; + ScrRejListener *snap = count ? malloc(count * sizeof *snap) : NULL; + if (count && !snap) scr_ad_oom(); + for (size_t i = 0; i < count; i++) { + snap[i] = (*list)[i]; + scr_dyn_retain(snap[i].fn); + snap[i].state->refs++; } + bool ok = true; + for (size_t i = 0; i < count; i++) { + if (ok && !(snap[i].once && snap[i].state->fired)) { + if (snap[i].once) { + snap[i].state->fired = true; + for (size_t j = 0; j < *n; j++) { + if ((*list)[j].id == snap[i].id) { + scr_rej_release(&(*list)[j]); + memmove(*list + j, *list + j + 1, (*n - j - 1) * sizeof **list); + (*n)--; + break; + } + } + } + ScrDyn *r = scr_dyn_call(snap[i].fn, args, argc, "listener"); + ok = !scr_exc_pending(); + scr_dyn_release(r); + } + scr_rej_release(&snap[i]); + } + free(snap); return ok; } +/* Uncaught exception listeners share the checked-dynamic callback ABI. + * Monitors run first but do not handle the exception by themselves. */ +static ScrRejListener *scr_uncaught_ls[2]; +static size_t scr_uncaught_n[2], scr_uncaught_cap[2]; +static int scr_uncaught_dispatch(bool from_promise); + +static void scr_uncaught_sync_hook(void) { + scr_uncaught_exception_hook = (scr_uncaught_n[0] || scr_uncaught_n[1]) ? scr_uncaught_dispatch : NULL; +} + +static void scr_uncaught_teardown(void) { + for (size_t k = 0; k < 2; k++) { + for (size_t i = 0; i < scr_uncaught_n[k]; i++) scr_rej_release(&scr_uncaught_ls[k][i]); + free(scr_uncaught_ls[k]); + scr_uncaught_ls[k] = NULL; + scr_uncaught_n[k] = scr_uncaught_cap[k] = 0; + } + scr_uncaught_sync_hook(); +} + +void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor) { + if (!scr_rej_check_listener(fn)) return; + static bool armed = false; + if (!armed) { armed = true; atexit(scr_uncaught_teardown); } + size_t k = monitor ? 1 : 0; + scr_rej_push(&scr_uncaught_ls[k], &scr_uncaught_n[k], &scr_uncaught_cap[k], fn, once); + scr_uncaught_sync_hook(); +} + +void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { + if (!scr_rej_check_listener(fn)) return; + size_t k = monitor ? 1 : 0; + scr_rej_remove(scr_uncaught_ls[k], &scr_uncaught_n[k], fn); + scr_uncaught_sync_hook(); +} + +static int scr_uncaught_dispatch(bool from_promise) { + ScrCaught *caught = scr_exc_take(); + ScrDyn *error = scr_caught_to_dyn(caught); + const char *origin = from_promise ? "unhandledRejection" : "uncaughtException"; + ScrStr *text = scr_str_new(origin, strlen(origin)); + ScrDyn *args[2] = {error, scr_dyn_new_str(text)}; + scr_str_release(text); + bool ok = scr_rej_fire(&scr_uncaught_ls[1], &scr_uncaught_n[1], args, 2); + bool handled = ok && scr_uncaught_n[0] > 0; + if (handled) ok = scr_rej_fire(&scr_uncaught_ls[0], &scr_uncaught_n[0], args, 2); + scr_dyn_release(args[0]); + scr_dyn_release(args[1]); + scr_uncaught_sync_hook(); + if (ok && !handled) scr_rethrow(caught); + scr_caught_release(caught); + return !ok ? -1 : handled ? 1 : 0; +} + /* Dispatch one unhandled rejection to the registered listeners — * (reason, promise), Node's signature. A listener throw is an uncaught * exception (Node crashes there too): the caller prints it and exits 1. diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index d622f054..891cf494 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1,5 +1,5 @@ /* Process events and stdin streaming — the OPTIONAL half of the event - * loop: signal handlers (process.on/once/off of SIGINT/SIGTERM), the + * loop: signal handlers (named process.on/once/off signal handlers), the * process 'exit' event, and the piped-stdin surface ('data'/'end'/'error' * listeners plus the for-await chunk source). This translation unit links * ONLY into binaries whose IR uses those surfaces (moduleUsesProcessEvents @@ -59,7 +59,7 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { } #endif -/* ── process signal events (process.on("SIGINT" | "SIGTERM")) ───────── +/* ── process signal events (process.on(name, listener)) ───────── * Classic self-pipe integration: a watched signal's sigaction handler * (installed WITHOUT SA_RESTART) sets a per-signal flag and writes one * byte into the wake pipe. The loop drains flags at every turn and the @@ -75,8 +75,10 @@ void scr_child_err_thunk_error(ScrClosure *cb, ScrStr *msg) { * kernel coalesces pending non-RT signals the same way). */ typedef struct { - ScrClosure *cb; /* owned */ + ScrDyn *cb; /* owned; checked callable */ + ScrStr *name; /* aliases of one signum remain distinct event names */ bool once; + uint64_t id; } ScrSigListener; typedef struct ScrSigReg { @@ -91,18 +93,20 @@ typedef struct ScrSigReg { struct ScrSigReg *next; } ScrSigReg; -/* Classic signal numbers only (SIGINT 2 / SIGTERM 15 today; every POSIX - * classic fits) — NSIG is not visible under strict _XOPEN_SOURCE. */ +/* Every named classic POSIX signal fits; NSIG is hidden by strict + * _XOPEN_SOURCE. Unknown SIG-prefixed names use the unwatched slot zero. */ #define SCR_SIG_MAX 32 static ScrSigReg *scr_sig_regs = NULL; +static uint64_t scr_sig_id = 0; static size_t scr_sig_watched = 0; /* watched signal COUNT (regs) */ static volatile sig_atomic_t scr_sig_flag[SCR_SIG_MAX]; static volatile sig_atomic_t scr_sig_any = 0; static int scr_wake_pipe[2] = {-1, -1}; static void scr_sig_handler(int sig) { - if (sig < 0 || sig >= SCR_SIG_MAX) return; + if (sig <= 0 || sig >= SCR_SIG_MAX) return; + int saved_errno = errno; #ifdef _WIN32 /* msvcrt reset the disposition to SIG_DFL before this call (SysV * semantics); re-arm so the next delivery reaches us too. Only ever @@ -116,6 +120,7 @@ static void scr_sig_handler(int sig) { ssize_t ignored = write(scr_wake_pipe[1], "s", 1); (void)ignored; /* a full pipe still wakes the poller */ } + errno = saved_errno; } static void scr_sig_reg_drop(ScrSigReg *reg); @@ -128,7 +133,10 @@ static void scr_sig_reg_drop(ScrSigReg *reg); static void scr_sig_cleanup(void) { while (scr_sig_regs) { ScrSigReg *reg = scr_sig_regs; - for (size_t i = 0; i < reg->n; i++) scr_closure_release(reg->ls[i].cb); + for (size_t i = 0; i < reg->n; i++) { + scr_dyn_release(reg->ls[i].cb); + scr_str_release(reg->ls[i].name); + } reg->n = 0; scr_sig_reg_drop(reg); } @@ -159,143 +167,161 @@ static void scr_wake_pipe_drain(void) { while (read(scr_wake_pipe[0], buf, sizeof buf) > 0) {} } -void scr_signal_on(double signum, ScrClosure *cb /*moves*/, bool once) { -#ifdef __wasi__ - (void)signum; - (void)once; - scr_closure_release(cb); - /* The compiler rejects this capability before linking. This guard keeps - * the optional events unit honest if hand-authored IR reaches it. */ - scr_trap("scriptc: internal error: OS signal surface reached on WASI\n"); - return; -#else - int sig = (int)signum; - if (sig <= 0 || sig >= SCR_SIG_MAX) { - scr_closure_release(cb); /* frontend only emits classic signals */ - return; +/* A computed process-event name is accepted here only for the signal + * family. Other process-event families retain their explicit lowerings. */ +static bool scr_signal_check(ScrStr *name, ScrDyn *cb) { + if (cb->kind != SCR_DYN_FUNC) { + const char *msg = "The \"listener\" argument must be of type function"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, strlen(msg), "ERR_INVALID_ARG_TYPE"); + return false; } + if (name->len < 3 || memcmp(name->data, "SIG", 3) != 0) { + const char *msg = "scriptc: computed process event names currently support the SIG signal family"; + scr_throw_error_msg(SCR_ERR_ERROR, msg, strlen(msg)); + return false; + } + return true; +} + +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once) { + if (!scr_signal_check(name, cb)) return; +#ifdef __wasi__ + (void)once; + scr_trap("scriptc: internal error: OS signal surface reached on WASI\n"); +#else + int sig = scr_signal_from_name(name); + if (sig < 0) sig = 0; /* e.g. SIGBREAK on POSIX: ordinary inert event */ ScrSigReg *reg = scr_sig_regs; while (reg && reg->sig != sig) reg = reg->next; if (!reg) { reg = calloc(1, sizeof *reg); if (!reg) scr_events_oom(); reg->sig = sig; + if (sig > 0) { + bool failed = sig >= SCR_SIG_MAX; +#ifdef _WIN32 + /* The Windows CRT rejects non-CRT signal numbers via its fatal + * invalid-parameter handler. Keep these forms explicitly fenced. */ + if (sig != SIGINT && sig != SIGILL && sig != SIGABRT && sig != SIGFPE && + sig != SIGSEGV && sig != SIGTERM && sig != SIGBREAK) failed = true; + if (!failed) { + reg->prev = signal(sig, scr_sig_handler); + failed = reg->prev == SIG_ERR; + } +#else + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = scr_sig_handler; + sigemptyset(&sa.sa_mask); + if (!failed) failed = sigaction(sig, &sa, ®->prev) != 0; +#endif + if (failed) { + free(reg); + const char *msg = "uv_signal_start EINVAL"; + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, strlen(msg), "EINVAL"); + return; + } + scr_sig_watched++; + scr_wake_pipe_init(); + } reg->next = scr_sig_regs; scr_sig_regs = reg; - scr_sig_watched++; - scr_wake_pipe_init(); -#ifdef _WIN32 - reg->prev = signal(sig, scr_sig_handler); - if (reg->prev == SIG_ERR) reg->prev = SIG_DFL; -#else - struct sigaction sa; - memset(&sa, 0, sizeof sa); - sa.sa_handler = scr_sig_handler; - sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; /* NO SA_RESTART: idle sleeps must EINTR */ - sigaction(sig, &sa, ®->prev); -#endif } if (reg->n == reg->cap) { reg->cap = reg->cap ? reg->cap * 2 : 2; reg->ls = realloc(reg->ls, reg->cap * sizeof *reg->ls); if (!reg->ls) scr_events_oom(); } - reg->ls[reg->n].cb = cb; - reg->ls[reg->n].once = once; - reg->n++; + reg->ls[reg->n++] = (ScrSigListener){scr_dyn_retain(cb), scr_str_retain(name), once, ++scr_sig_id}; #endif } static void scr_sig_reg_drop(ScrSigReg *reg) { + if (reg->sig > 0) { #ifdef _WIN32 - signal(reg->sig, reg->prev); /* default disposition back */ -#elif defined(__wasi__) - /* No signal disposition exists in WASI Preview 1. The compiler refuses - * signal registrations, so this arm is only the cleanup safety net. */ -#else - sigaction(reg->sig, ®->prev, NULL); /* default disposition back */ + signal(reg->sig, reg->prev); +#elif !defined(__wasi__) + sigaction(reg->sig, ®->prev, NULL); #endif + scr_sig_watched--; + scr_sig_flag[reg->sig] = 0; + } ScrSigReg **link = &scr_sig_regs; while (*link != reg) link = &(*link)->next; *link = reg->next; - scr_sig_watched--; free(reg->ls); free(reg); } -void scr_signal_off(double signum, ScrClosure *cb /*borrowed*/) { -#ifdef __wasi__ - (void)signum; - (void)cb; - scr_trap("scriptc: internal error: OS signal surface reached on WASI\n"); - return; -#else - int sig = (int)signum; +static void scr_sig_remove(ScrSigReg *reg, size_t i) { + scr_dyn_release(reg->ls[i].cb); + scr_str_release(reg->ls[i].name); + memmove(reg->ls + i, reg->ls + i + 1, (reg->n - i - 1) * sizeof *reg->ls); + if (--reg->n == 0) scr_sig_reg_drop(reg); +} + +void scr_signal_off(ScrStr *name, ScrDyn *cb) { + if (!scr_signal_check(name, cb)) return; + int sig = scr_signal_from_name(name); + if (sig < 0) sig = 0; ScrSigReg *reg = scr_sig_regs; while (reg && reg->sig != sig) reg = reg->next; if (!reg) return; - for (size_t i = 0; i < reg->n; i++) { - if (reg->ls[i].cb == cb) { /* first match, like Node's removeListener */ - scr_closure_release(reg->ls[i].cb); - memmove(reg->ls + i, reg->ls + i + 1, (reg->n - i - 1) * sizeof *reg->ls); - reg->n--; - if (reg->n == 0) scr_sig_reg_drop(reg); + /* EventEmitter removes the most recently registered matching listener. */ + for (size_t i = reg->n; i > 0; i--) { + ScrSigListener *l = ®->ls[i - 1]; + if (scr_str_eq(l->name, name) && scr_dyn_strict_eq(l->cb, cb)) { + scr_sig_remove(reg, i - 1); return; } } -#endif } -/* One dispatch pass: every flagged signal fires its listener SNAPSHOT - * (Node emits over a copy — a listener removed mid-emit still runs for - * this delivery; one added mid-emit waits for the next). `once` entries - * leave the live list BEFORE their callback runs; an emptied list - * restores the default disposition, so the next delivery of that signal - * kills the process — Node-exact. A throw stops the pass and leaves the - * exception for the loop. */ +/* Snapshot all pending deliveries before invoking user code. No live + * registry pointer survives a callback: off() can remove this signal or + * another pending signal and free either registry. */ static void scr_signals_drain(void) { if (!scr_sig_any) return; scr_sig_any = 0; - ScrSigReg *reg = scr_sig_regs; - while (reg) { - ScrSigReg *next = reg->next; /* reg may drop below */ - if (scr_sig_flag[reg->sig]) { - scr_sig_flag[reg->sig] = 0; - size_t n = reg->n; - ScrSigListener *snap = malloc(n * sizeof *snap); - if (!snap) scr_events_oom(); - for (size_t i = 0; i < n; i++) { - snap[i] = reg->ls[i]; - scr_closure_retain(snap[i].cb); - } - bool dropped = false; - for (size_t i = 0; i < n; i++) { - if (snap[i].once && !dropped) { - /* remove from the live list before invoking */ + ScrSigListener *snap = NULL; + size_t n = 0; + for (ScrSigReg *reg = scr_sig_regs; reg; reg = reg->next) { + if (reg->sig <= 0 || !scr_sig_flag[reg->sig]) continue; + scr_sig_flag[reg->sig] = 0; + snap = realloc(snap, (n + reg->n) * sizeof *snap); + if (!snap) scr_events_oom(); + for (size_t i = 0; i < reg->n; i++) { + snap[n] = reg->ls[i]; + scr_dyn_retain(snap[n].cb); + scr_str_retain(snap[n].name); + n++; + } + } + for (size_t i = 0; i < n; i++) { + if (!scr_exc_pending()) { + if (snap[i].once) { + for (ScrSigReg *reg = scr_sig_regs; reg; reg = reg->next) { + bool found = false; for (size_t j = 0; j < reg->n; j++) { - if (reg->ls[j].cb == snap[i].cb) { - scr_closure_release(reg->ls[j].cb); - memmove(reg->ls + j, reg->ls + j + 1, (reg->n - j - 1) * sizeof *reg->ls); - reg->n--; + if (reg->ls[j].id == snap[i].id) { + scr_sig_remove(reg, j); + found = true; break; } } - if (reg->n == 0) { - scr_sig_reg_drop(reg); - dropped = true; - } + if (found) break; } - if (!scr_exc_pending()) { - ((void (*)(ScrClosure *))snap[i].cb->fn)(snap[i].cb); - } - scr_closure_release(snap[i].cb); } - free(snap); - if (scr_exc_pending()) return; + ScrDyn *args[2] = {scr_dyn_new_str(snap[i].name), scr_dyn_new_num(scr_signal_from_name(snap[i].name))}; + ScrDyn *result = scr_dyn_call(snap[i].cb, args, 2, "listener"); + scr_dyn_release(result); + scr_dyn_release(args[0]); + scr_dyn_release(args[1]); } - reg = next; + scr_dyn_release(snap[i].cb); + scr_str_release(snap[i].name); } + free(snap); } /* ── process.stdin events and async iteration ───────────────────────── diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index 9a290136..265fa3fc 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -362,8 +362,19 @@ static SCR_TL int scr_exit_code_hint = 0; void scr_exit_code_note(int code) { scr_exit_code_hint = code; } int scr_exit_code_hint_get(void) { return scr_exit_code_hint; } +int (*scr_uncaught_exception_hook)(bool from_promise) = NULL; +static bool scr_uncaught_handler_failed = false; + +bool scr_exc_handle_uncaught(bool from_promise) { + if (!scr_exc_pending()) return true; + if (!scr_uncaught_exception_hook || scr_uncaught_handler_failed) return false; + int result = scr_uncaught_exception_hook(from_promise); + if (result < 0) scr_uncaught_handler_failed = true; + return result > 0; +} + void scr_exc_print_uncaught(void) { - scr_exit_code_note(1); + scr_exit_code_note(scr_uncaught_handler_failed ? 7 : 1); /* Settle any runtime-internal stdout fragment before the stderr line when * both share an fd. JavaScript-visible writes already flush themselves. */ fflush(stdout); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index e5c79c3f..6deef5d7 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -353,7 +353,10 @@ void scr_dyn_trace_v(void *ptr, ScrTraceVisit visit, void *ctx) { visit(d->v.proxy.target, ctx); visit(d->v.proxy.handler, ctx); break; - case SCR_DYN_TYPED_REF: visit(d->v.typed_ref.materialized, ctx); break; + case SCR_DYN_TYPED_REF: + visit(d->v.typed_ref.materialized, ctx); + if (d->v.typed_ref.traced) visit(d->v.typed_ref.ptr, ctx); + break; case SCR_DYN_HANDLE: if (d->v.handle.traced) visit(d->v.handle.ptr, ctx); break; @@ -469,7 +472,7 @@ static void scr_dyn_dispose(ScrDyn *d, bool collected) { cast->release(cast->ptr); free(cast); } - d->v.typed_ref.release(d->v.typed_ref.ptr); + if (!collected || !d->v.typed_ref.traced) d->v.typed_ref.release(d->v.typed_ref.ptr); break; case SCR_DYN_PROXY: if (!collected) { @@ -1119,6 +1122,12 @@ static bool scr_dyn_canonical_own_index(const ScrStr *key, size_t length); ScrDyn *scr_dyn_own_descriptor(const ScrDyn *value, const ScrStr *key) { if (scr_dyn_class_reflection_fence(value)) return NULL; + if (value->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(value); + ScrDyn *result = scr_exc_pending() ? NULL : scr_dyn_own_descriptor(view, key); + scr_dyn_release(view); + return result; + } if (value->kind == SCR_DYN_FUNC) { if (value->v.fn.clo->props) { ScrDyn *table = (ScrDyn *)scr_box_get_ref(value->v.fn.clo->props); @@ -1354,9 +1363,21 @@ ScrDyn *scr_dyn_new_typed_ref( d->v.typed_ref.commit = commit; d->v.typed_ref.materialized = NULL; d->v.typed_ref.casts = NULL; + d->v.typed_ref.traced = false; return d; } +ScrDyn *scr_dyn_new_typed_ref_traced( + void *ptr, void *(*retain)(void *), void (*release)(void *), + const char *type_key, size_t type_key_len, + ScrDyn *(*materialize)(void *), + void (*commit)(void *, const ScrDyn *)) { + ScrDyn *value = scr_dyn_new_typed_ref(ptr, retain, release, type_key, + type_key_len, materialize, commit); + value->v.typed_ref.traced = true; + return value; +} + bool scr_dyn_typed_ref_is( const ScrDyn *d, const char *type_key, size_t type_key_len) { return d && d->kind == SCR_DYN_TYPED_REF && @@ -2352,7 +2373,7 @@ ScrDyn *scr_dyn_get_own_property_descriptor(ScrDyn *target, ScrDyn *key) { scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); return NULL; } - if (target->kind != SCR_DYN_OBJ && target->kind != SCR_DYN_PROXY && target->kind != SCR_DYN_FUNC && target->kind != SCR_DYN_ARR && target->kind != SCR_DYN_STR && target->kind != SCR_DYN_BOOL && target->kind != SCR_DYN_NUM) { + if (target->kind != SCR_DYN_OBJ && target->kind != SCR_DYN_PROXY && target->kind != SCR_DYN_FUNC && target->kind != SCR_DYN_ARR && target->kind != SCR_DYN_STR && target->kind != SCR_DYN_BOOL && target->kind != SCR_DYN_NUM && target->kind != SCR_DYN_TYPED_REF) { static const char msg[] = "Object.getOwnPropertyDescriptor on this value is not supported yet"; scr_throw_error_msg(SCR_ERR_ERROR, msg, sizeof msg - 1); return NULL; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index d1b53dd5..b8acaeca 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -850,7 +850,7 @@ static int scr_signal_by_name(const char *name) { /* The table above for other units (scr_child.c's child.kill shares Node's * one signal-name story): the resolved number, or -1 for unknown names. */ int scr_signal_from_name(const ScrStr *signal) { - return scr_signal_by_name(signal->data); + return strlen(signal->data) == signal->len ? scr_signal_by_name(signal->data) : -1; } /* The reverse walk, for spawnSync's result.signal: the FIRST name with diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index dd02835e..ab120d13 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -2231,6 +2231,12 @@ ScrDyn *scr_caught_to_dyn(const ScrCaught *c); * "Uncaught " to stderr (strings raw, numbers JS-exact, booleans * true/false, ref payloads as "[object]"), and releases the payload. */ void scr_exc_print_uncaught(void); +/* Optional native process exception registry. Hook returns 1 when handled, + * 0 when unhandled, and -1 when a handler itself threw (Node exit 7). */ +extern int (*scr_uncaught_exception_hook)(bool from_promise); +bool scr_exc_handle_uncaught(bool from_promise); +void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor); +void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor); /* ── standard library: process + node:fs (scr_lib.c) ───────────────── * Called once at the top of main, right after scr_init: stashes argc/argv @@ -3503,6 +3509,7 @@ struct ScrDyn { * identity while the source reference remains observable. */ ScrDyn *materialized; ScrDynTypedCast *casts; + bool traced; /* native referent has a cycle header */ } typed_ref; /* SCR_DYN_PROMISE: the retained promise. The boundary contract: it * settles with a dyn payload (SCR_EXC_REF ScrDyn fulfillment or a @@ -3661,6 +3668,11 @@ ScrDyn *scr_dyn_new_typed_ref( const char *type_key, size_t type_key_len, ScrDyn *(*materialize)(void *), void (*commit)(void *, const ScrDyn *)); +ScrDyn *scr_dyn_new_typed_ref_traced( + void *ptr, void *(*retain)(void *), void (*release)(void *), + const char *type_key, size_t type_key_len, + ScrDyn *(*materialize)(void *), + void (*commit)(void *, const ScrDyn *)); bool scr_dyn_typed_ref_is( const ScrDyn *d, const char *type_key, size_t type_key_len); bool scr_dyn_typed_ref_is_key(const ScrDyn *d, const ScrStr *type_key); @@ -4531,8 +4543,8 @@ void scr_nticks_teardown(void); * loop alive, like Node's flowing stdin. */ typedef struct ScrBytes ScrBytes; /* full definition below (C11 repeat) */ void scr_events_install(void); -void scr_signal_on(double signum, ScrClosure *cb, bool once); -void scr_signal_off(double signum, ScrClosure *cb); +void scr_signal_on(ScrStr *name, ScrDyn *cb, bool once); +void scr_signal_off(ScrStr *name, ScrDyn *cb); void scr_process_on_exit(ScrClosure *cb, void (*fn)(ScrClosure *, double), bool once); void scr_process_off_exit(ScrClosure *cb); void scr_run_exit_listeners(double code); diff --git a/tests/corpus/native-class-properties.cjs b/tests/corpus/native-class-properties.cjs new file mode 100644 index 00000000..d6638b51 --- /dev/null +++ b/tests/corpus/native-class-properties.cjs @@ -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)); diff --git a/tests/corpus/native-class-property-cycles.cjs b/tests/corpus/native-class-property-cycles.cjs new file mode 100644 index 00000000..f20b21cc --- /dev/null +++ b/tests/corpus/native-class-property-cycles.cjs @@ -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(); diff --git a/tests/corpus/native-class-property-order.cjs b/tests/corpus/native-class-property-order.cjs new file mode 100644 index 00000000..9580d9ac --- /dev/null +++ b/tests/corpus/native-class-property-order.cjs @@ -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); } diff --git a/tests/corpus/process-named-signals.cjs b/tests/corpus/process-named-signals.cjs new file mode 100644 index 00000000..d22c0471 --- /dev/null +++ b/tests/corpus/process-named-signals.cjs @@ -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); diff --git a/tests/corpus/process-uncaught-handler-throws.cjs b/tests/corpus/process-uncaught-handler-throws.cjs new file mode 100644 index 00000000..f3d1d654 --- /dev/null +++ b/tests/corpus/process-uncaught-handler-throws.cjs @@ -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); diff --git a/tests/corpus/process-uncaught-loop.cjs b/tests/corpus/process-uncaught-loop.cjs new file mode 100644 index 00000000..2ea063e9 --- /dev/null +++ b/tests/corpus/process-uncaught-loop.cjs @@ -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); diff --git a/tests/corpus/process-uncaught-module.mjs b/tests/corpus/process-uncaught-module.mjs new file mode 100644 index 00000000..cb43fdd9 --- /dev/null +++ b/tests/corpus/process-uncaught-module.mjs @@ -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"); diff --git a/tests/corpus/process-uncaught-monitor.cjs b/tests/corpus/process-uncaught-monitor.cjs new file mode 100644 index 00000000..283bcabd --- /dev/null +++ b/tests/corpus/process-uncaught-monitor.cjs @@ -0,0 +1,4 @@ +// @exit: 1 +// @no-node-shims +process.on("uncaughtExceptionMonitor", (error, origin) => console.log("monitor", error.message, origin)); +throw new Error("unhandled"); diff --git a/tests/corpus/process-uncaught-rejection.cjs b/tests/corpus/process-uncaught-rejection.cjs new file mode 100644 index 00000000..0b19f4f1 --- /dev/null +++ b/tests/corpus/process-uncaught-rejection.cjs @@ -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")); diff --git a/tests/corpus/process-uncaught-sync.cjs b/tests/corpus/process-uncaught-sync.cjs new file mode 100644 index 00000000..677914e2 --- /dev/null +++ b/tests/corpus/process-uncaught-sync.cjs @@ -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"); diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index f6ca5c2d..f03d8223 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -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 diff --git a/tests/harness/linux-differential.test.ts b/tests/harness/linux-differential.test.ts index 6aec522a..2be15bd9 100644 --- a/tests/harness/linux-differential.test.ts +++ b/tests/harness/linux-differential.test.ts @@ -231,8 +231,10 @@ async function runLinuxNode(file: string): Promise { ...(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)), ]); } diff --git a/tests/harness/llvm-differential.test.ts b/tests/harness/llvm-differential.test.ts index 739dbe3e..3ced1751 100644 --- a/tests/harness/llvm-differential.test.ts +++ b/tests/harness/llvm-differential.test.ts @@ -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[] { diff --git a/tests/harness/windows-differential.test.ts b/tests/harness/windows-differential.test.ts index 93433d3b..ea613958 100644 --- a/tests/harness/windows-differential.test.ts +++ b/tests/harness/windows-differential.test.ts @@ -85,6 +85,7 @@ const WINDOWS_SKIPS: Record = { // 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 { 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}`, ); }