From a5c7c09ad3f9d2a43aa61481a8af20659cb79acf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 20:55:17 -0500 Subject: [PATCH 01/17] Add mock.timers to node:test --- .../limitations/builtin-apis/test-mocks.mdx | 3 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 40 +- .../generated/node-v24-backlog.json | 109 ++-- .../generated/node-v24-internal.json | 71 ++- internal/compatibility/static-support.json | 2 + .../ambient/scriptc-node-fallback.d.ts | 15 + packages/compiler/src/backend/llvm/emitter.ts | 6 +- .../compiler/src/backend/llvm/lib-dispatch.ts | 9 +- .../compiler/src/backend/llvm/lib-process.ts | 40 +- .../compiler/src/backend/llvm/lib-shared.ts | 37 ++ .../compiler/src/backend/llvm/lib-test.ts | 20 + .../src/frontend/lowering/builtins/timers.ts | 9 +- .../src/frontend/lowering/lower-calls.ts | 3 + .../src/frontend/lowering/lower-mock.ts | 154 ++++- .../src/frontend/lowering/lower-test.ts | 69 ++- .../src/frontend/lowering/lower-timers.ts | 47 +- packages/compiler/src/ir/builtin-effects.ts | 13 + .../compiler/src/ir/builtin-signatures.ts | 21 + packages/compiler/src/ir/ir.ts | 5 + packages/compiler/src/ir/runtime-features.ts | 16 + packages/compiler/src/ir/validate.ts | 7 +- .../corpus/5813-mock-timers-module.ts.json | 6 + .../corpus/5814-mock-timers-date.ts.json | 6 + .../diagnostics/mock-timers-fences.ts.json | 6 + packages/runtime/src/scr_async.c | 3 + packages/runtime/src/scr_async.h | 8 + packages/runtime/src/scr_event_target.c | 15 + packages/runtime/src/scr_event_target.h | 5 + packages/runtime/src/scr_fetch.c | 21 +- packages/runtime/src/scr_lib.c | 4 +- packages/runtime/src/scr_number.h | 4 + packages/runtime/src/scr_test.c | 524 +++++++++++++++++- packages/runtime/src/scr_test.h | 38 ++ tests/corpus/5813-mock-timers-module.ts | 91 +++ tests/corpus/5814-mock-timers-date.ts | 83 +++ tests/diagnostics/mock-timers-fences.ts | 35 ++ .../node-test/cases/mock-timers-date.ts | 197 +++++++ tests/fixtures/node-test/cases/mock-timers.ts | 348 ++++++++++++ .../__snapshots__/mock-timers-fences.ts.txt | 44 ++ 40 files changed, 2026 insertions(+), 110 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5813-mock-timers-module.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5814-mock-timers-date.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/mock-timers-fences.ts.json create mode 100644 tests/corpus/5813-mock-timers-module.ts create mode 100644 tests/corpus/5814-mock-timers-date.ts create mode 100644 tests/diagnostics/mock-timers-fences.ts create mode 100644 tests/fixtures/node-test/cases/mock-timers-date.ts create mode 100644 tests/fixtures/node-test/cases/mock-timers.ts create mode 100644 tests/harness/__snapshots__/mock-timers-fences.ts.txt diff --git a/docs/content/limitations/builtin-apis/test-mocks.mdx b/docs/content/limitations/builtin-apis/test-mocks.mdx index c9e84a80d..a0c63cd98 100644 --- a/docs/content/limitations/builtin-apis/test-mocks.mdx +++ b/docs/content/limitations/builtin-apis/test-mocks.mdx @@ -1 +1,2 @@ -- **Test mocks:** `mock.fn`, `mock.method`, `mock.restoreAll` and `mock.reset` compile, on the module's `mock` and on `t.mock`. `mock.method` mocks a function-valued field of a record, such as an object literal or an interface-typed object. Class instances, builtin objects, and object literals whose methods use `this` throw `SC2020`. Calls through the object record it as `this`, including calls of a method read off the object and called on its own, where Node records `undefined`. `object.method === mock` is `false`, because the field holds a wrapper of the mock. `mock.fn()` without an original, `mock.getter`, `mock.setter`, `mock.property`, `mock.module`, `mock.timers` and mock options throw `SC2020`. +- **Test mocks:** `mock.fn`, `mock.method`, `mock.restoreAll` and `mock.reset` compile, on the module's `mock` and on `t.mock`. `mock.method` mocks a function-valued field of a record, such as an object literal or an interface-typed object. Class instances, builtin objects, and object literals whose methods use `this` throw `SC2020`. Calls through the object record it as `this`, including calls of a method read off the object and called on its own, where Node records `undefined`. `object.method === mock` is `false`, because the field holds a wrapper of the mock. `mock.fn()` without an original, `mock.getter`, `mock.setter`, `mock.property`, `mock.module` and mock options throw `SC2020`. +- **Mock timers:** `mock.timers.enable`, `tick`, `runAll`, `reset`, `setTime` and `[Symbol.dispose]()` compile, on the module's `mock` and on `t.mock`, for the `setTimeout`, `setInterval`, `setImmediate`, `Date`, `scheduler.wait` and `AbortSignal.timeout` APIs. `enable()` takes no options or an object literal whose `apis`, when given, is an array literal. Options and `apis` lists that are not literals, and the `MockTimers` object used as a value, throw `SC2020`. As in Node, the global timer functions, `Date.now()`, `new Date()` and `scheduler.wait` are mocked, and other calls through named and namespace imports of `node:timers` and `node:timers/promises` use real timers. As in Node, once a reset gives `scheduler.wait` back, calling it throws `ERR_INVALID_THIS`. While `scheduler.wait` is mocked, calling it with a `signal` option throws an `SC2020` error. A mock timer's handle converts to a negative number, where Node's mock `Timeout` converts to `NaN`. When two trackers have timers enabled, clearing a timer through the tracker that did not create it leaves the timer queued, where Node removes the entry at the same queue position in the clearing tracker. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 93149c51c..fd99c1a78 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "a1ff929d49885bd8c92e", + "artifactVersion": "1bf81d93ee20dd4cea82", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 352c211b5..c93e45565 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -689,9 +689,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 45, + "partial": 51, "refused": 0, - "not-implemented": 72, + "not-implemented": 66, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -65550,9 +65550,9 @@ "inherited": true }, "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" } }, { @@ -65573,9 +65573,9 @@ "inherited": true }, "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" } }, { @@ -65596,9 +65596,9 @@ "inherited": true }, "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" } }, { @@ -65619,9 +65619,9 @@ "inherited": true }, "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" } }, { @@ -65642,9 +65642,9 @@ "inherited": true }, "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" } }, { @@ -65665,9 +65665,9 @@ "inherited": true }, "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" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index fff784adc..6aed267e8 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1091, - "verify-gap": 2238, + "audit-partial": 1097, + "verify-gap": 2232, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -66670,12 +66670,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers.enable", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -66695,12 +66701,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers.reset", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -66720,12 +66732,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -66745,12 +66760,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers.tick", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -66770,12 +66791,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers.runAll", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -66795,12 +66822,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.timers.setTime", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 559bd48ca..86b66f736 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -746,9 +746,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 45, + "partial": 51, "refused": 0, - "not-implemented": 72, + "not-implemented": 66, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -90928,10 +90928,14 @@ "tests/corpus/5515-assert-deep-strict-typed-messages.ts", "tests/corpus/5810-mock-method-records.ts", "tests/corpus/5811-mock-tracker-restore.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", "tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/fixtures/node-test/cases/mock-fn.ts", "tests/fixtures/node-test/cases/mock-method.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/fixtures/node-test/cases/mock-timers.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/todo-suites.ts", @@ -92562,8 +92566,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers.enable", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -92585,8 +92596,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers.reset", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -92608,8 +92626,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -92631,8 +92653,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers.tick", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -92700,8 +92729,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers.runAll", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -92723,8 +92759,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.timers.setTime", + "tests": [ + "tests/fixtures/node-test/cases/mock-timers.ts", + "tests/fixtures/node-test/cases/mock-timers-date.ts", + "tests/corpus/5813-mock-timers-module.ts", + "tests/corpus/5814-mock-timers-date.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index d68011393..44aa67174 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -345,6 +345,8 @@ { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.diagnostic", "context.name"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.fn"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.method", "mock.reset", "mock.restoreAll"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-method.ts", "tests/corpus/5810-mock-method-records.ts", "tests/corpus/5811-mock-tracker-restore.ts", "tests/harness/node-test.test.ts"] }, + { "chapter": "test", "parent": "Class: `MockTimers`", "symbols": ["timers.enable", "timers.reset", "timers.tick", "timers.runAll", "timers.setTime"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-timers.ts", "tests/fixtures/node-test/cases/mock-timers-date.ts", "tests/corpus/5813-mock-timers-module.ts", "tests/corpus/5814-mock-timers-date.ts", "tests/harness/node-test.test.ts"] }, + { "chapter": "test", "parent": "Class: `MockTimers`", "signatures": ["`timers[Symbol.dispose]()`"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-timers-date.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockFunctionContext`", "symbols": ["calls", "ctx.callCount", "ctx.mockImplementation", "ctx.mockImplementationOnce", "ctx.resetCalls", "ctx.restore"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/fixtures/node-test/cases/mock-method.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.before", "context.after", "context.beforeEach", "context.afterEach", "context.plan", "context.fullName"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/ctx-hooks.ts", "tests/fixtures/node-test/cases/ctx-plan.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "symbols": ["describe.skip", "describe.todo", "describe.only", "suite.skip", "suite.todo", "suite.only", "it.skip", "it.todo", "it.only"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/only-nested.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index d87172f61..37abb2ae3 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3410,6 +3410,19 @@ declare module "node:test" { : never; reset(): void; restoreAll(): void; + readonly timers: MockTimers; + } + interface MockTimersOptions { + apis: ReadonlyArray<"setInterval" | "setTimeout" | "setImmediate" | "Date">; + now?: number | Date | undefined; + } + interface MockTimers { + enable(options?: MockTimersOptions): void; + setTime(milliseconds?: number): void; + reset(): void; + tick(milliseconds?: number): void; + runAll(): void; + [Symbol.dispose](): void; } const mock: MockTracker; /* Declared for typechecking only — every use fences (no lowering). @@ -3437,6 +3450,8 @@ declare module "node:test" { MockFunctionContext, MockFunctionCall, MockTracker, + MockTimers, + MockTimersOptions, }; export default test; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 2c3a71457..c4c04a68a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -49,7 +49,7 @@ import { STRING, VOID, } from "../../ir/ir.js"; -import { moduleRuntimeFeatures } from "../../ir/runtime-features.js"; +import { moduleRuntimeFeatures, moduleUsesMockTimers } from "../../ir/runtime-features.js"; import { matchIntegerArrayForLoop, matchIntegerBytesForLoop, @@ -882,6 +882,9 @@ export class LlEmitter { /** setTimeout and friends appeared somewhere: main must run the event * loop even in programs with no async functions. */ usesTimers = false; + /** The program calls mock.timers members (moduleUsesMockTimers): timer + * calls emit scr_test.c's mock-aware entry points. */ + readonly mockTimers: boolean; /** Emitted ref-kind resolve thunks for new Promise, interned per inner * typeKey → thunk symbol. */ readonly resolveThunks = new Map(); @@ -1108,6 +1111,7 @@ export class LlEmitter { }), ); this.nullableUnions = new NullableUnions(mod); + this.mockTimers = moduleUsesMockTimers(mod); this.debug = options.debugSources === undefined ? null diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 50804bf5a..27c8f1ff4 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -11,7 +11,12 @@ import { InternalCompilerError } from "../../errors.js"; import { MAY_THROW_LIB_FNS } from "../../ir/builtin-effects.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./expr-context.js"; -import { ASSERT_VERDICT_LIB_FNS, LIB_FN_SYMS, USES_TIMERS_LIB_FNS } from "./lib-shared.js"; +import { + ASSERT_VERDICT_LIB_FNS, + LIB_FN_SYMS, + mockTimerSym, + USES_TIMERS_LIB_FNS, +} from "./lib-shared.js"; import { isDynUndefined } from "../../ir/runtime-features.js"; import { DYN_KIND } from "./dyn.js"; import { rawBytes } from "./lib-abi.js"; @@ -431,7 +436,7 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll if (MAY_THROW_LIB_FNS.has(e.fn) && LIB_FN_SYMS[e.fn] === undefined) { throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc); } - const sym = LIB_FN_SYMS[e.fn]; + const sym = mockTimerSym(host, e) ?? LIB_FN_SYMS[e.fn]; if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc); const args = READ_ONLY_STRING_LIB_FNS.has(e.fn) ? emitBorrowedInputs(host, e.args) diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 46d08a58f..b30c65b8d 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -4,6 +4,7 @@ import { undefinedArmTag } from "../../ir/analysis.js"; import { F64, RUNTIME_ERROR_CLASSES, STRING } from "../../ir/ir.js"; import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js"; import { f64Lit } from "./common.js"; +import { mockTimerSym } from "./lib-shared.js"; export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; @@ -372,14 +373,16 @@ export function emitAsyncContextLibCall(host: LlvmEmitterContext, e: LibCallExpr host.usesTimers = true; const cb = host.emitExpr(e.args[0]!); host.moveTemp(cb); - const sym = { - "timers.setTimeout": "scr_set_timeout", - "timers.setInterval": "scr_set_interval", - "timers.setTimeoutHandle": "scr_set_timeout_handle", - "timers.setImmediate": "scr_set_immediate", - "process.nextTick": "scr_next_tick", - "timers.queueMicrotask": "scr_queue_microtask", - }[e.fn]; + const sym = + mockTimerSym(host, e) ?? + { + "timers.setTimeout": "scr_set_timeout", + "timers.setInterval": "scr_set_interval", + "timers.setTimeoutHandle": "scr_set_timeout_handle", + "timers.setImmediate": "scr_set_immediate", + "process.nextTick": "scr_next_tick", + "timers.queueMicrotask": "scr_queue_microtask", + }[e.fn]; const rest = e.args.slice(1).map((a) => host.emitExpr(a)); const argList = [`ptr ${cb.name}`, ...rest.map((a) => `${host.llType(a.type)} ${a.name}`)].join( ", ", @@ -405,21 +408,24 @@ export function emitAsyncContextLibCall(host: LlvmEmitterContext, e: LibCallExpr // The chaining forms: bookkeep, then yield the handle itself (the // C comma expressions). const h = host.emitExpr(e.args[0]!); - const sym = { - "timers.unref": "scr_timer_unref", - "timers.ref": "scr_timer_ref", - "timers.refresh": "scr_timer_refresh", - "timers.immediateUnref": "scr_immediate_unref", - "timers.immediateRef": "scr_immediate_ref", - }[e.fn]; + const sym = + mockTimerSym(host, e) ?? + { + "timers.unref": "scr_timer_unref", + "timers.ref": "scr_timer_ref", + "timers.refresh": "scr_timer_refresh", + "timers.immediateUnref": "scr_immediate_unref", + "timers.immediateRef": "scr_immediate_ref", + }[e.fn]; host.declare(`declare void @${sym}(double)`); B.line(`call void @${sym}(double ${h.name})`); return { name: h.name, type: e.type }; } if (e.fn === "timers.clearTimeout" || e.fn === "timers.clearInterval") { const h = host.emitExpr(e.args[0]!); - host.declare(`declare void @scr_clear_interval(double)`); - B.line(`call void @scr_clear_interval(double ${h.name})`); + const sym = mockTimerSym(host, e) ?? "scr_clear_interval"; + host.declare(`declare void @${sym}(double)`); + B.line(`call void @${sym}(double ${h.name})`); return { name: "", type: e.type }; } if (e.fn === "timers.clearNoop") { diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9232cbd03..2c442756e 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1223,10 +1223,13 @@ export const LIB_FN_SYMS: Record = { // loop gate already runs) and diagnostics_channel (scr_dc.c): all // generic shapes; the throwing members ride the pending check. "tp.setTimeout": "scr_tp_set_timeout", + "tp.schedulerWait": "scr_tp_set_timeout", "tp.setImmediate": "scr_tp_set_immediate", "tp.setTimeoutValue": "scr_tp_set_timeout_value", "tp.setImmediateValue": "scr_tp_set_immediate_value", "tp.setTimeoutSignal": "scr_tp_set_timeout_signal", + "tp.schedulerWaitValue": "scr_tp_set_timeout_value", + "tp.schedulerWaitSignal": "scr_tp_set_timeout_signal", "tp.setImmediateSignal": "scr_tp_set_immediate_signal", // stream/promises' finished form (+1 promise; may-throw pending check; // the pipeline form needs the stream-array compound and emits below). @@ -1376,6 +1379,40 @@ export const LIB_FN_SYMS: Record = { "timers.immediatePromise": "scr_immediate_promise", }; +/** The mock-aware entry points (scr_test.c) that the timer calls of a + * program using mock.timers emit in place of the loop's functions; each + * falls back to the loop's function while its API is not mocked. */ +export const MOCK_TIMER_SYMS: Readonly> = { + "timers.setTimeout": "scr_test_mt_set_timeout", + "timers.setTimeoutHandle": "scr_test_mt_set_timeout_handle", + "timers.setInterval": "scr_test_mt_set_interval", + "timers.clearTimeout": "scr_test_mt_clear_timeout", + "timers.clearInterval": "scr_test_mt_clear_interval", + "timers.unref": "scr_test_mt_unref", + "timers.ref": "scr_test_mt_ref", + "timers.refresh": "scr_test_mt_refresh", + "timers.hasRef": "scr_test_mt_has_ref", + "timers.setImmediate": "scr_test_mt_set_immediate", + "timers.clearImmediate": "scr_test_mt_clear_immediate", + "timers.immediateUnref": "scr_test_mt_immediate_unref", + "timers.immediateRef": "scr_test_mt_immediate_ref", + "timers.immediateHasRef": "scr_test_mt_immediate_has_ref", + "date.now": "scr_test_mt_date_now", + "date.newNow": "scr_test_mt_date_now", + "tp.schedulerWait": "scr_test_mt_scheduler_wait", + "tp.schedulerWaitValue": "scr_test_mt_scheduler_wait_value", + "tp.schedulerWaitSignal": "scr_tp_scheduler_wait_signal", +}; + +/** The mock-aware symbol for a timer call of a program that uses + * mock.timers, undefined for other calls and for `realTimer` calls. */ +export function mockTimerSym( + host: { readonly mockTimers: boolean }, + e: { fn: string; realTimer?: true }, +): string | undefined { + return host.mockTimers && !e.realTimer ? MOCK_TIMER_SYMS[e.fn] : undefined; +} + export const USES_TIMERS_LIB_FNS = new Set([ "process.emitWarning", // Decipheriv.setAuthTag reports DEP0182 on the next tick. diff --git a/packages/compiler/src/backend/llvm/lib-test.ts b/packages/compiler/src/backend/llvm/lib-test.ts index 79590cd9d..8ceadae2f 100644 --- a/packages/compiler/src/backend/llvm/lib-test.ts +++ b/packages/compiler/src/backend/llvm/lib-test.ts @@ -66,6 +66,26 @@ export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVal return finishRuntimeCall(host, e, "scr_test_ctx_mock_restore_all", a); case "test.ctxMockReset": return finishRuntimeCall(host, e, "scr_test_ctx_mock_reset", a); + case "test.mockTimersEnable": + return finishRuntimeCall(host, e, "scr_test_mock_timers_enable", a); + case "test.mockTimersTick": + return finishRuntimeCall(host, e, "scr_test_mock_timers_tick", a); + case "test.mockTimersRunAll": + return finishRuntimeCall(host, e, "scr_test_mock_timers_run_all", a); + case "test.mockTimersReset": + return finishRuntimeCall(host, e, "scr_test_mock_timers_reset", a); + case "test.mockTimersSetTime": + return finishRuntimeCall(host, e, "scr_test_mock_timers_set_time", a); + case "test.ctxMockTimersEnable": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_timers_enable", a); + case "test.ctxMockTimersTick": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_timers_tick", a); + case "test.ctxMockTimersRunAll": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_timers_run_all", a); + case "test.ctxMockTimersReset": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_timers_reset", a); + case "test.ctxMockTimersSetTime": + return finishRuntimeCall(host, e, "scr_test_ctx_mock_timers_set_time", a); default: throw new InternalCompilerError(`unhandled test call ${e.fn}`); } diff --git a/packages/compiler/src/frontend/lowering/builtins/timers.ts b/packages/compiler/src/frontend/lowering/builtins/timers.ts index 33a907abd..98624dc4a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/timers.ts +++ b/packages/compiler/src/frontend/lowering/builtins/timers.ts @@ -308,12 +308,15 @@ function lowerSchedulerCall( const promiseVoid: IrType = { kind: "promise", inner: VOID }; if (member === "scheduler.yield") return { kind: "libCall", fn: "tp.setImmediate", args: [], type: promiseVoid, loc }; + // The scheduler.wait libCalls emit timers/promises setTimeout's runtime + // calls; mock.timers mocks scheduler.wait and leaves setTimeout's named + // and namespace imports real. An omitted delay is NaN, Node's undefined. const [delayNode, optionsNode] = expr.arguments; const ms: IrExpr = delayNode ? lowerer.lowerExprExpecting(delayNode, F64) - : { kind: "numLit", value: 1, type: F64, loc }; + : { kind: "numLit", value: NaN, type: F64, loc }; if (optionsNode === undefined) - return { kind: "libCall", fn: "tp.setTimeout", args: [ms], type: promiseVoid, loc }; + return { kind: "libCall", fn: "tp.schedulerWait", args: [ms], type: promiseVoid, loc }; const { ref, signal } = timersPromisesOptions(lowerer, member, optionsNode, loc); const value: IrExpr = { kind: "intrinsic", @@ -324,7 +327,7 @@ function lowerSchedulerCall( }; return { kind: "libCall", - fn: signal === null ? "tp.setTimeoutValue" : "tp.setTimeoutSignal", + fn: signal === null ? "tp.schedulerWaitValue" : "tp.schedulerWaitSignal", args: signal === null ? [ms, value, ref] : [ms, value, ref, signal], type: promiseVoid, loc, diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2ba83a3d5..b2d8990eb 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -13,6 +13,7 @@ import { lowerTimersMemberCall, TIMER_MODULE_MEMBERS, } from "./lower-timers.js"; +import { lowerMockTimersElementCall } from "./lower-test.js"; import { objectEnumerationReceiver } from "./object-enumeration-receiver.js"; import { InternalCompilerError } from "../../errors.js"; import { isFsStreamMember, lowerFsStreamMethodCall } from "./builtins/fs-streams.js"; @@ -1519,6 +1520,8 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // The element spelling of an unsupported native Web method must fence // before lowering the callee into a checked-dynamic keyed read. if (ts.isElementAccessExpression(expr.expression)) { + const timersDispose = lowerMockTimersElementCall(lowerer, expr, expr.expression); + if (timersDispose) return timersDispose; const symbolMethod = lowerClassSymbolMethodCall(lowerer, expr); if (symbolMethod) return symbolMethod; lowerer.fenceUnsupportedFetchConstructorMember(expr.expression); diff --git a/packages/compiler/src/frontend/lowering/lower-mock.ts b/packages/compiler/src/frontend/lowering/lower-mock.ts index b49cd045b..2022d8232 100644 --- a/packages/compiler/src/frontend/lowering/lower-mock.ts +++ b/packages/compiler/src/frontend/lowering/lower-mock.ts @@ -32,7 +32,8 @@ import { type IrType, type SrcLoc, } from "../../ir/ir.js"; -import { countedFor, dynUndefinedExpr, numLit, varRef } from "../../ir/build.js"; +import { countedFor, dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js"; +import type { IrLibFn } from "../../ir/builtin-signatures.js"; import { mockType as mockHybridType } from "../mock-shapes.js"; import { locOf } from "../program.js"; import { resultIsDiscarded } from "./call-position.js"; @@ -862,6 +863,157 @@ export function lowerMockTrackerCall( }; } +const MOCK_TIMERS_HINT = + "enable, tick, runAll, reset and setTime are the supported mock.timers members"; +const MOCK_TIMERS_APIS_HINT = + "list the mocked APIs as string literals: enable({ apis: ['setTimeout', 'Date'] })"; +/** The codes scr_test.c reads for the APIs enable() mocks, in Node's + * SUPPORTED_APIS order. */ +const MOCK_TIMERS_API_CODES: Readonly> = { + setTimeout: "T", + setInterval: "I", + setImmediate: "M", + Date: "D", + "scheduler.wait": "W", + "AbortSignal.timeout": "A", +}; +/** enable() and enable({ now }) without `apis` mock every API. */ +const MOCK_TIMERS_ALL_APIS = "TIMDWA"; + +/** `mock.timers.enable(...)` and the other MockTimers members, on the + * module's `mock` (tracker null) and t.mock. enable() takes no options or + * an object literal whose `apis`, when given, is an array literal of API + * names. */ +export function lowerMockTimersCall( + lowerer: Lowerer, + call: ts.CallExpression, + member: string, + what: string, + tracker: () => MockTracker, + sym: ts.Symbol | null, +): IrExpr { + const loc = locOf(call); + const args = call.arguments; + const optionalNumber = (fallback: number): IrExpr[] => { + if (args.length > 1) + lowerer.noLowering(`${what}.${member} with ${args.length} arguments`, call); + return [args.length === 1 ? lowerer.lowerExprExpecting(args[0]!, F64) : numLit(fallback, loc)]; + }; + if (!["enable", "tick", "setTime", "runAll", "reset"].includes(member)) { + lowerer.noLowering(`${what}.${member}`, call, MOCK_TIMERS_HINT, sym); + } + // The receiver evaluates before the arguments. + const t = tracker(); + let rest: IrExpr[]; + switch (member) { + case "enable": + rest = lowerMockTimersOptions(lowerer, call, what); + break; + case "tick": + rest = optionalNumber(1); + break; + case "setTime": + rest = optionalNumber(0); + break; + default: + if (args.length > 0) { + lowerer.noLowering(`${what}.${member} with ${args.length} arguments`, call); + } + rest = []; + } + const name = member[0]!.toUpperCase() + member.slice(1); + return { + kind: "libCall", + fn: (t ? `test.ctxMockTimers${name}` : `test.mockTimers${name}`) as IrLibFn, + args: t ? [t, ...rest] : rest, + type: VOID, + loc, + }; +} + +/** enable()'s { apis, now } as the libCall's API codes, `now` and flags + * (1 = `now` given, 2 = it is a Date). */ +function lowerMockTimersOptions(lowerer: Lowerer, call: ts.CallExpression, what: string): IrExpr[] { + const loc = locOf(call); + const options = call.arguments[0]; + if (call.arguments.length > 1) { + lowerer.noLowering(`${what}.enable with ${call.arguments.length} arguments`, call); + } + if (options === undefined) { + return [strLit(MOCK_TIMERS_ALL_APIS, loc), numLit(0, loc), numLit(0, loc)]; + } + if (!ts.isObjectLiteralExpression(options)) { + lowerer.noLowering( + `${what}.enable with a non-literal options object`, + options, + MOCK_TIMERS_APIS_HINT, + ); + } + let codes = ""; + let listed = false; + let now: IrExpr = numLit(0, loc); + let flags = 0; + for (const prop of options.properties) { + // `{ apis: [...] }`, `{ "now": n }` and the `{ now }` shorthand. + const init = ts.isPropertyAssignment(prop) + ? prop.initializer + : ts.isShorthandPropertyAssignment(prop) && ts.isIdentifier(prop.name) + ? prop.name + : null; + const key = + init && prop.name && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) + ? prop.name.text + : null; + if (key === "apis") { + const list = init!; + if (!ts.isArrayLiteralExpression(list)) { + lowerer.noLowering( + `${what}.enable with a non-literal apis list`, + list, + MOCK_TIMERS_APIS_HINT, + ); + } + listed = true; + for (const el of list.elements) { + if (!ts.isStringLiteralLike(el)) { + lowerer.noLowering( + `${what}.enable with a non-literal API name`, + el, + MOCK_TIMERS_APIS_HINT, + ); + } + const code = MOCK_TIMERS_API_CODES[el.text] ?? ""; + if (code === "") { + lowerer.noLowering(`${what}.enable with the ${el.text} API`, el, MOCK_TIMERS_APIS_HINT); + } + codes += code; + } + } else if (key === "now") { + const value = lowerer.lowerExpr(init!); + if (value.type.kind === "date") { + now = { kind: "libCall", fn: "date.getTime", args: [value], type: F64, loc }; + flags = 3; + } else if (value.type.kind === "f64") { + now = value; + flags = 1; + } else { + lowerer.noLowering( + `${what}.enable with a now value of this type`, + prop, + "now takes a number or a Date", + ); + } + } else { + lowerer.noLowering( + `${what}.enable with this option`, + prop, + "apis and now are the supported enable options", + ); + } + } + return [strLit(listed ? codes : MOCK_TIMERS_ALL_APIS, loc), now, numLit(flags, loc)]; +} + /** `fn.mock.calls`: a copy of the log, as Node's getter returns. Null for * other reads. */ export function lowerMockContextProperty( diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index 6b2563414..c616f3e73 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -32,12 +32,13 @@ import { lowerMockContextProperty, lowerMockFn, lowerMockMethod, + lowerMockTimersCall, lowerMockTrackerCall, type MockTracker, } from "./lower-mock.js"; const MOCK_HINT = - "mock.fn, mock.method, mock.restoreAll and mock.reset are the supported mock members"; + "mock.fn, mock.method, mock.restoreAll, mock.reset and mock.timers are the supported mock members"; /** The mock tracker members that lower, for the module's `mock` (tracker * null) and t.mock. Null for other members. */ @@ -556,6 +557,65 @@ export function lowerTestDirectCall( return lowerRegistration(lowerer, expr, "test(...)", loc, 0, null); } +/** `mock.timers` (tracker null) or `t.mock.timers` on a TestContext; + * null for other expressions. */ +function mockTimersReceiver( + lowerer: Lowerer, + expr: ts.Expression, +): { what: string; tracker: () => MockTracker } | null { + if ( + !ts.isPropertyAccessExpression(expr) || + expr.questionDotToken || + expr.name.text !== "timers" || + !lowerer.isStdlibMember(expr) + ) { + return null; + } + const owner = expr.expression; + if (ts.isIdentifier(owner)) { + const bi = lowerer.builtinImportOf(owner); + return bi && bi.module === "test" && bi.member === "mock" + ? { what: "mock.timers", tracker: () => null } + : null; + } + if ( + ts.isPropertyAccessExpression(owner) && + !owner.questionDotToken && + owner.name.text === "mock" && + lowerer.mapTypeOf(lowerer.typeOf(owner.expression))?.kind === "testCtx" && + lowerer.isStdlibMember(owner) + ) { + const testCtx = owner.expression; + return { what: "t.mock.timers", tracker: () => lowerer.lowerExpr(testCtx) }; + } + return null; +} + +/** `mock.timers[Symbol.dispose]()` and its t.mock twin: an alias of + * reset(), as in Node. Null for other element-access calls. */ +export function lowerMockTimersElementCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.ElementAccessExpression, +): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + const timers = mockTimersReceiver(lowerer, access.expression); + if (!timers) return null; + const key = access.argumentExpression; + const isDispose = + ts.isPropertyAccessExpression(key) && + key.name.text === "dispose" && + lowerer.stdlibGlobalMember(key, "Symbol") === "dispose"; + if (!isDispose) { + lowerer.noLowering( + `${timers.what} computed member calls`, + call, + "call timers[Symbol.dispose]()", + ); + } + return lowerMockTimersCall(lowerer, call, "reset", timers.what, timers.tracker, null); +} + /** Method-position node:test calls — one entry in lower-calls.ts's * intrinsic chain: skip/todo/only twins on NAMED import bindings * (`test.skip(...)` where `test` came from `{ test }`), the TestContext @@ -634,6 +694,13 @@ export function lowerTestMethodCall( ); if (served) return served; } + // mock.timers.enable(...) and the other MockTimers members, on the + // module's `mock` and on t.mock. + const timers = mockTimersReceiver(lowerer, access.expression); + if (timers) { + const sym = lowerer.checker.getSymbolAtLocation(access.name) ?? null; + return lowerMockTimersCall(lowerer, call, member, timers.what, timers.tracker, sym); + } // t.assert.strictEqual(...) — the callee's receiver is `t.assert` on a // TestContext: Node's t.assert methods ARE the assert functions (bound // to the test for its counters — not modeled; the throw is identical), diff --git a/packages/compiler/src/frontend/lowering/lower-timers.ts b/packages/compiler/src/frontend/lowering/lower-timers.ts index 47e929b49..44b2b6a1d 100644 --- a/packages/compiler/src/frontend/lowering/lower-timers.ts +++ b/packages/compiler/src/frontend/lowering/lower-timers.ts @@ -299,6 +299,40 @@ function tolerantClearNoop(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLo return null; } +const MOCKABLE_TIMER_FNS: ReadonlySet = new Set([ + "timers.setTimeout", + "timers.setTimeoutHandle", + "timers.setInterval", + "timers.clearTimeout", + "timers.clearInterval", + "timers.setImmediate", + "timers.clearImmediate", +]); + +/** Whether a timer call's callee is an ESM named or namespace import of + * node:timers (destructured bindings included): Node's mock.timers leaves + * those bindings alone and replaces the globals. */ +function isRealTimerCallee(lowerer: Lowerer, callee: ts.Expression): boolean { + if (ts.isIdentifier(callee)) return lowerer.builtinImportOf(callee)?.module === "timers"; + if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) { + const sym = lowerer.checker.getSymbolAtLocation(callee.expression); + const decl = sym ? lowerer.checker.declarationsOf(sym)[0] : undefined; + return decl !== undefined && ts.isNamespaceImport(decl); + } + return false; +} + +/** Marks a lowered timer call `realTimer` when its callee is a binding + * mock.timers does not replace. */ +function markRealTimerCall( + lowerer: Lowerer, + expr: ts.CallExpression, + lowered: IrExpr | null, +): IrExpr | null { + if (lowered?.kind !== "libCall" || !MOCKABLE_TIMER_FNS.has(lowered.fn)) return lowered; + return isRealTimerCallee(lowerer, expr.expression) ? { ...lowered, realTimer: true } : lowered; +} + /** One timer call by MEMBER NAME — the shared lowering behind the ambient * globals, the node:timers named/destructured imports, and the namespace * form (`timers.setTimeout(...)`). Null when the member isn't a lowered @@ -308,6 +342,15 @@ export function lowerTimersMemberCall( expr: ts.CallExpression, member: string, loc: SrcLoc, +): IrExpr | null { + return markRealTimerCall(lowerer, expr, lowerTimerCall(lowerer, expr, member, loc)); +} + +function lowerTimerCall( + lowerer: Lowerer, + expr: ts.CallExpression, + member: string, + loc: SrcLoc, ): IrExpr | null { // Delayed timers share callback adaptation and the 1ms default. Trailing // arguments are captured now and delivered when the callback fires. @@ -328,10 +371,12 @@ export function lowerTimersMemberCall( expr.arguments[0]!, loc, ); + // An omitted delay is NaN, Node's undefined: the loop clamps it to + // 1ms, and mock.timers queues it to run at the next tick. const ms: IrExpr = expr.arguments.length >= 2 ? lowerer.lowerExpr(expr.arguments[1]!) - : { kind: "numLit", value: 1, type: F64, loc }; + : { kind: "numLit", value: NaN, type: F64, loc }; if (member === "setInterval") { return { kind: "libCall", fn: "timers.setInterval", args: [cb, ms], type: F64, loc }; } diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 2381413f8..480a1df82 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -16,6 +16,19 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "test.sub", "test.subEmpty", "test.suite", + // mock.timers: Node's ERR_INVALID_STATE and ERR_INVALID_ARG_VALUE, and + // the throws of the timer callbacks tick and runAll run. + "test.mockTimersEnable", + "test.mockTimersTick", + "test.mockTimersRunAll", + "test.mockTimersSetTime", + "test.ctxMockTimersEnable", + "test.ctxMockTimersTick", + "test.ctxMockTimersRunAll", + "test.ctxMockTimersSetTime", + "tp.schedulerWait", + "tp.schedulerWaitValue", + "tp.schedulerWaitSignal", "console.write", "console.label", "console.countReset", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 5e219ecf6..447974b32 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1443,6 +1443,20 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ }, "test.ctxMockRestoreAll": { argTypes: [TESTCTX_T], result: VOID }, "test.ctxMockReset": { argTypes: [TESTCTX_T], result: VOID }, + // mock.timers on the module's `mock` and on t.mock. enable takes one + // code per listed API ('T' setTimeout, 'I' setInterval), the `now` + // value and its flags (1 given, 2 from a Date). enable, tick, runAll and + // setTime throw Node's errors; tick and runAll run timer callbacks. + "test.mockTimersEnable": { argTypes: [STRING, F64, F64], result: VOID }, + "test.mockTimersTick": { argTypes: [F64], result: VOID }, + "test.mockTimersRunAll": { argTypes: [], result: VOID }, + "test.mockTimersReset": { argTypes: [], result: VOID }, + "test.mockTimersSetTime": { argTypes: [F64], result: VOID }, + "test.ctxMockTimersEnable": { argTypes: [TESTCTX_T, STRING, F64, F64], result: VOID }, + "test.ctxMockTimersTick": { argTypes: [TESTCTX_T, F64], result: VOID }, + "test.ctxMockTimersRunAll": { argTypes: [TESTCTX_T], result: VOID }, + "test.ctxMockTimersReset": { argTypes: [TESTCTX_T], result: VOID }, + "test.ctxMockTimersSetTime": { argTypes: [TESTCTX_T, F64], result: VOID }, // node:http (scr_http.c over scr_net.c). The handler and the data // listeners are program-dependent closures (null slots, checked in the // libCall case); reqHeader's result is the interned string|undefined @@ -3889,6 +3903,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * through the immediate queue (fires before due timers of later loop * turns, Node's check phase). Neither throws. */ "tp.setTimeout": { argTypes: [F64], result: { kind: "promise", inner: VOID } }, + // scheduler.wait(delay): tp.setTimeout's promise. It throws Node's + // ERR_INVALID_THIS once mock.timers has restored the real scheduler.wait. + "tp.schedulerWait": { argTypes: [F64], result: { kind: "promise", inner: VOID } }, "tp.setImmediate": { argTypes: [], result: { kind: "promise", inner: VOID } }, /** events.once (scr_events_emitter.c): [emitter, name, boxer, * errorBoxer, listenForError] → a pending promise of any[]; the boxers @@ -3905,6 +3922,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * AbortError (scr_event_target.c). */ "tp.setTimeoutSignal": { argTypes: [F64, null, BOOL, DYN], result: VOID }, "tp.setImmediateSignal": { argTypes: [null, BOOL, DYN], result: VOID }, + /** scheduler.wait(delay, options): tp.setTimeoutValue's and + * tp.setTimeoutSignal's shapes, with tp.schedulerWait's throws. */ + "tp.schedulerWaitValue": { argTypes: [F64, null, BOOL], result: VOID }, + "tp.schedulerWaitSignal": { argTypes: [F64, null, BOOL, DYN], result: VOID }, /** node:diagnostics_channel (scr_dc.c, linked when any dc.* appears — * the zlib gating precedent): a process-global name→channel registry; * channel values are f64 handles (type-mapper.ts maps Channel to F64, the diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index bfaa13c0e..840e58f1f 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -3556,6 +3556,11 @@ export type IrExpr = type: IrType; loc: SrcLoc; prototypeIdentityOnly?: true; + /** A timer call through an ESM named or namespace import of + * node:timers. Node's mock.timers replaces the globals and the + * module object's properties, which these bindings do not follow, + * so the call keeps the loop's timer. */ + realTimer?: true; /** An assertion call made through a node:assert Assert instance: the * instance options the runtime applies to this call. Backends set * them right before the runtime call, after its arguments are diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 80a9c9b57..dea7118e2 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -34,6 +34,9 @@ export interface RuntimeFeatures { parseArgs: boolean; fsWatch: boolean; nodeTest: boolean; + /** The program calls mock.timers members: its timer calls go through + * scr_test.c's mock-aware entry points. */ + mockTimers: boolean; dgram: boolean; http: boolean; http2: boolean; @@ -205,6 +208,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run parseArgs: false, fsWatch: false, nodeTest: false, + mockTimers: false, dgram: false, http: false, http2: false, @@ -252,6 +256,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run fn === "rl.questionAsyncSignal" || fn === "http.clientSignal" || fn === "tp.setTimeoutSignal" || + fn === "tp.schedulerWaitSignal" || fn === "tp.setImmediateSignal" || fn === "stream.addAbortSignal" || fn === "stream.addAbortSignalFn" || @@ -409,6 +414,9 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run if (fn.startsWith("test.")) { features.nodeTest = true; features.inspect = true; + if (fn.startsWith("test.mockTimers") || fn.startsWith("test.ctxMockTimers")) { + features.mockTimers = true; + } } if (fn.startsWith("dgram.") || fn.startsWith("dns.")) features.dgram = true; if (fn.startsWith("tlsca.")) features.tlsCa = true; @@ -793,6 +801,14 @@ export function moduleUsesNodeTest(mod: IrModule): boolean { return scanRuntimeFeatures(mod, "nodeTest").nodeTest; } +/** True when the module calls a mock.timers member (a test.mockTimers* or + * test.ctxMockTimers* libCall). The emitter then routes the timer calls + * through scr_test.c's mock-aware entry points; other programs keep the + * loop's timer functions. */ +export function moduleUsesMockTimers(mod: IrModule): boolean { + return scanRuntimeFeatures(mod, "mockTimers").mockTimers; +} + /** True when the module contains any dgram.* or dns.* libCall — the * link switch that pulls scr_dgram.c into the binary and has the emitted * main call scr_dgram_install (native-toolchain.ts + emitter; the scr_net gating diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index d526e7cb8..fa9a8777c 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4116,9 +4116,12 @@ function validateFunction( e.fn === "tp.setTimeoutValue" || e.fn === "tp.setImmediateValue" || e.fn === "tp.setTimeoutSignal" || - e.fn === "tp.setImmediateSignal" + e.fn === "tp.setImmediateSignal" || + e.fn === "tp.schedulerWaitValue" || + e.fn === "tp.schedulerWaitSignal" ) { - const value = e.args[e.fn === "tp.setTimeoutValue" || e.fn === "tp.setTimeoutSignal" ? 1 : 0]; + const value = + e.args[e.fn === "tp.setImmediateValue" || e.fn === "tp.setImmediateSignal" ? 0 : 1]; if (value?.type.kind !== "promise" || !typeEquals(e.type, value.type)) { err(`libCall ${e.fn} must be the value's promise type`, e.loc); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5813-mock-timers-module.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5813-mock-timers-module.ts.json new file mode 100644 index 000000000..bc8a46ae1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5813-mock-timers-module.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5813-mock-timers-module.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5814-mock-timers-date.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5814-mock-timers-date.ts.json new file mode 100644 index 000000000..edb0dba8c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5814-mock-timers-date.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5814-mock-timers-date.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/mock-timers-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/mock-timers-fences.ts.json new file mode 100644 index 000000000..f8800b52c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/mock-timers-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/mock-timers-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 46175cf82..f7a701b45 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -994,6 +994,9 @@ double scr_set_interval(ScrClosure *cb, double ms) { return (double)id; } +SCR_TL void (*scr_mt_abort_timeout_arm)(ScrClosure *cb, double ms) = NULL; +SCR_TL int scr_mt_wait_state = 0; + double scr_set_timeout_handle(ScrClosure *cb, double ms) { ms = scr_timer_coerce_ms(ms); unsigned long id = scr_interval_next_id++; diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index a391d2a26..7f294ca29 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -367,6 +367,14 @@ ScrPromise *scr_immediate_promise(void); void scr_clear_interval(double handle); double scr_set_timeout_handle(ScrClosure *cb, double ms); /* cb ownership moves in */ +/* Queues `cb` (moved) on the MockTimers that mocks AbortSignal.timeout + * (scr_test.c), NULL while it is not mocked. AbortSignal.timeout then + * aborts its signal from that queue instead of arming a loop timer. */ +extern SCR_TL void (*scr_mt_abort_timeout_arm)(ScrClosure *cb, double ms); +/* scheduler.wait's mock.timers state (scr_test.c): 0 real, 1 mocked, 2 the + * real one restored after a reset, which throws ERR_INVALID_THIS in Node. + * scr_tp_scheduler_wait_signal reads it. */ +extern SCR_TL int scr_mt_wait_state; void scr_timers_teardown(void); /* Timeout.unref()/ref()/hasRef(): loop-liveness bookkeeping over the timer * handle (setTimeout/setInterval's id). unref drops the timer from the diff --git a/packages/runtime/src/scr_event_target.c b/packages/runtime/src/scr_event_target.c index 68b7f6e87..e0a7c5969 100644 --- a/packages/runtime/src/scr_event_target.c +++ b/packages/runtime/src/scr_event_target.c @@ -1873,6 +1873,21 @@ ScrPromise *scr_tp_set_timeout_signal(double ms, ScrPromise *value, bool ref, Sc return scr_tp_signal(ms, value, ref, signal, false); } +ScrPromise *scr_tp_scheduler_wait_signal(double ms, ScrPromise *value, bool ref, ScrDyn *signal) { + if (scr_mt_wait_state == 2) { + static const char msg[] = "Value of \"this\" must be of type Scheduler"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_THIS"); + return NULL; + } + if (scr_mt_wait_state == 1) { + static const char msg[] = + "scheduler.wait with a signal while mock.timers mocks it has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, sizeof msg - 1, "SC2020"); + return NULL; + } + return scr_tp_signal(ms, value, ref, signal, false); +} + ScrPromise *scr_tp_set_immediate_signal(ScrPromise *value, bool ref, ScrDyn *signal) { return scr_tp_signal(0, value, ref, signal, true); } diff --git a/packages/runtime/src/scr_event_target.h b/packages/runtime/src/scr_event_target.h index 6ed1475c6..c126edd54 100644 --- a/packages/runtime/src/scr_event_target.h +++ b/packages/runtime/src/scr_event_target.h @@ -89,6 +89,11 @@ ScrDyn *scr_events_add_abort_listener(ScrDyn *signal, ScrDyn *listener); * `value` is the already-resolved promise of the resolve value, borrowed. * Never throw; +1. */ ScrPromise *scr_tp_set_timeout_signal(double ms, ScrPromise *value, bool ref, ScrDyn *signal); +/* scheduler.wait(delay, { signal }) in a program that uses mock.timers: + * scr_tp_set_timeout_signal, except that it throws Node's ERR_INVALID_THIS + * once a reset restored the real scheduler.wait, and SC2020 while + * mock.timers mocks it. NULL after a throw. */ +ScrPromise *scr_tp_scheduler_wait_signal(double ms, ScrPromise *value, bool ref, ScrDyn *signal); ScrPromise *scr_tp_set_immediate_signal(ScrPromise *value, bool ref, ScrDyn *signal); /* Compiled subclasses (`class Bus extends EventTarget`, `class Ping diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index a7b540adb..f586b96f8 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -496,6 +496,15 @@ static void sf_signal_timer_fire(ScrClosure *cb) { sf_signal_release(s); } +/* The mocked AbortSignal.timeout's timer: Node's mock calls abort() on + * the signal's controller, so the reason is the AbortError. */ +static void sf_signal_mock_timer_fire(ScrClosure *cb) { + SfSignal *s = (SfSignal *)scr_box_get_ref(cb->caps[0]); + if (!s) return; + if (!s->aborted) sf_signal_abort_default(s, false); + sf_signal_release(s); +} + ScrDyn *scr_fetch_abort_timeout(ScrDyn *delay) { if (!delay || delay->kind != SCR_DYN_NUM) { if (delay) { @@ -527,13 +536,19 @@ ScrDyn *scr_fetch_abort_timeout(ScrDyn *delay) { return NULL; } SfSignal *s = sf_signal_new(); - ScrClosure *cb = scr_closure_new((void *)&sf_signal_timer_fire, 1); + bool mocked = scr_mt_abort_timeout_arm != NULL; + ScrClosure *cb = scr_closure_new( + mocked ? (void *)&sf_signal_mock_timer_fire : (void *)&sf_signal_timer_fire, 1); ScrBox *box = scr_box_new_obj(&sf_signal_retain_v, &sf_signal_release_v, NULL); scr_box_set_ref(box, sf_signal_retain(s)); cb->caps[0] = box; - s->timer_id = scr_set_timeout_handle(cb, ms); - scr_timer_unref(s->timer_id); + if (mocked) { + scr_mt_abort_timeout_arm(cb, ms); + } else { + s->timer_id = scr_set_timeout_handle(cb, ms); + scr_timer_unref(s->timer_id); + } ScrDyn *out = scr_dyn_new_handle(s, SCR_DYNH_ABORT_SIGNAL); sf_signal_release(s); return out; diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 5f595f263..2f4ce3750 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10171,6 +10171,8 @@ ScrStr *scr_str_from_code_point_bytes(ScrBytes *codes) { * frontend-fenced; construction, storage, getters, and ISO formatting * observe exactly this payload. */ +SCR_TL const double *scr_date_mock_now = NULL; + double scr_date_now(void) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); @@ -10861,7 +10863,7 @@ static ScrDyn *scr_native_date_invoke(void *ptr, ScrDyn *self, const char *metho ScrDyn *scr_dyn_native_date_new(const ScrDyn *arguments) { double ms; - if (arguments->v.arr.len == 0) ms = scr_date_now(); + if (arguments->v.arr.len == 0) ms = scr_date_mock_now ? *scr_date_mock_now : scr_date_now(); else { ScrDyn *value = arguments->v.arr.items[0]; if (scr_dyn_native_date_is(value)) ms = scr_dyn_native_date_value(value); diff --git a/packages/runtime/src/scr_number.h b/packages/runtime/src/scr_number.h index 50b270587..84db27075 100644 --- a/packages/runtime/src/scr_number.h +++ b/packages/runtime/src/scr_number.h @@ -91,6 +91,10 @@ ScrStr *scr_intl_num_format_en_us(double x); * mutation are frontend-fenced; construction, storage, getters, and ISO * formatting are exact over this representation. */ double scr_date_now(void); /* integer ms since epoch, like Node */ +/* The clock of the MockTimers that mocks Date (scr_test.c), NULL while + * Date is not mocked. Dynamic `new Date()` reads it; the runtime's own + * time stamps keep scr_date_now, as Node's internals keep the real Date. */ +extern SCR_TL const double *scr_date_mock_now; double scr_date_new_ms(double ms); /* TimeClip (NaN when invalid) */ double scr_date_get_time(double ms); /* Node's exact ISO 8601 UTC format (expanded ±YYYYYY years outside diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 89301cbd3..6388857a6 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -120,6 +120,7 @@ struct ScrTestCtx { bool on_fiber; /* started on its own fiber by a concurrent parent */ ScrPromise *done; /* owned; settles when a concurrent subtest finishes */ ScrPromise *slot; /* owned; the parent waiting for its next subtest to finish */ + struct ScrMockTimers *mock_timers; /* t.mock.timers, made on first use; freed at exit */ }; static SCR_TL ScrTestCtx *scr_test_root = NULL; @@ -443,6 +444,8 @@ static bool scr_test_any_only(ScrTestCtx *parent) { } static void scr_test_run_node(ScrTestCtx *t); +typedef struct ScrMockTimers ScrMockTimers; +static void scr_mt_reset(ScrMockTimers *m); /* ── concurrency ─────────────────────────────────────────────────────── */ @@ -679,8 +682,9 @@ static void scr_test_run_node(ScrTestCtx *t) { scr_exc_clear(); } } - /* Then t.mock is reset: its mocks are restored. */ + /* Then t.mock is reset: its mocks are restored and its timers reset. */ scr_test_mocks_restore(&t->mocks, true); + scr_mt_reset(t->mock_timers); scr_test_parent_set(prev_parent); t->dur_ms = scr_now_ms() - start; if (t->mode == SCR_TEST_SKIP) { @@ -1042,16 +1046,525 @@ static void scr_test_mocks_add(ScrTestHooks *h, ScrClosure *restore /*moves*/) { h->n++; } +/* ── mock.timers ────────────────────────────────────────────────────── + * Node's MockTimers (lib/internal/test_runner/mock/mock_timers.js). The + * module's `mock` and every t.mock own one. enable() makes it the owner + * of the APIs it lists: setTimeout, setInterval, setImmediate and their + * clear functions then queue entries on it instead of arming loop timers, + * AbortSignal.timeout aborts from its queue, timers/promises + * scheduler.wait settles from it, Date.now() and new Date() read its + * clock, and tick() runs the due entries synchronously. enable() saves the owner each API had and reset() restores it, as Node + * saves and restores the functions it replaces. The queue is Node's + * binary heap (internal/priority_queue.js) with Node's comparator, so + * entries with equal, negative or NaN run times fire in Node's order. + * Mock handles are negative, which the loop's timer functions ignore. + * + * The scr_test_mt_* entry points replace the timer calls of programs + * that use mock.timers (the moduleUsesMockTimers switch), so other + * programs call the loop's timer functions directly. */ + +/* The APIs enable() mocks, in Node's SUPPORTED_APIS order. */ +enum { + SCR_MT_TIMEOUT, + SCR_MT_INTERVAL, + SCR_MT_IMMEDIATE, + SCR_MT_DATE, + SCR_MT_WAIT, + SCR_MT_ABORT, + SCR_MT_NAPIS +}; + +typedef struct { + double run_at; + double id; /* Node's #currentTimer: the tie-break between equal run times */ + double interval; /* the period of a setInterval entry */ + bool has_interval; /* false for timeouts and cleared intervals (Node's undefined) */ + ScrClosure *cb; /* owned */ + size_t pos; /* heap slot, 0 when unset (Node's priorityQueuePosition) */ + double handle; /* the negative handle the program holds */ + ScrMockTimers *owner; + int refs; /* heap slots holding the entry, plus a running callback */ +} ScrMockTimer; + +struct ScrMockTimers { + bool enabled; + char apis[16]; /* the APIs enable() listed, in order (Node's #timersInContext) */ + size_t napis; + double now; + double next_id; + ScrMockTimer **heap; /* 1-based, as Node's #heap */ + size_t size, cap; + ScrMockTimers *saved[SCR_MT_NAPIS]; /* each API's owner before enable() */ + ScrMockTimers *next_all; +}; + +/* The owner of each API: the MockTimers its calls queue on, NULL for the + * loop's timers. */ +static SCR_TL ScrMockTimers *scr_mt_owner[SCR_MT_NAPIS]; +static SCR_TL ScrMockTimers *scr_mt_module = NULL; +/* Every MockTimers, freed at exit: an owner or saved slot can still name + * one after its test ended, as Node keeps the replaced functions. */ +static SCR_TL ScrMockTimers *scr_mt_all = NULL; +static SCR_TL double scr_mt_next_handle = -1; +/* Set once a MockTimers that mocked scheduler.wait gives the real one + * back: Node restores it bound to the MockTimers, so every later call + * throws ERR_INVALID_THIS. */ +static SCR_TL bool scr_mt_wait_unbound = false; +/* Entries the program can still reach through a handle. */ +static SCR_TL ScrMockTimer **scr_mt_live = NULL; +static SCR_TL size_t scr_mt_nlive = 0, scr_mt_live_cap = 0; + +static ScrMockTimers *scr_mt_new(void) { + ScrMockTimers *m = calloc(1, sizeof *m); + if (!m) scr_test_oom(); + m->next_id = 1; + m->next_all = scr_mt_all; + scr_mt_all = m; + return m; +} + +static ScrMockTimers *scr_mt_of(ScrTestCtx *t) { + ScrMockTimers **slot = t ? &t->mock_timers : &scr_mt_module; + if (!*slot) *slot = scr_mt_new(); + return *slot; +} + +static void scr_mt_unref(ScrMockTimer *e) { + if (--e->refs > 0) return; + for (size_t i = 0; i < scr_mt_nlive; i++) { + if (scr_mt_live[i] == e) { + scr_mt_live[i] = scr_mt_live[--scr_mt_nlive]; + break; + } + } + scr_closure_release(e->cb); + free(e); +} + +static ScrMockTimer *scr_mt_find(double handle) { + if (!(handle < 0)) return NULL; + for (size_t i = 0; i < scr_mt_nlive; i++) { + if (scr_mt_live[i]->handle == handle) return scr_mt_live[i]; + } + return NULL; +} + +/* Node's compareTimersLists: (a.runAt - b.runAt) || (a.id - b.id). */ +static double scr_mt_compare(const ScrMockTimer *a, const ScrMockTimer *b) { + double d = a->run_at - b->run_at; + if (d != 0 && !isnan(d)) return d; + return a->id - b->id; +} + +static void scr_mt_percolate_down(ScrMockTimers *m, size_t pos) { + ScrMockTimer **heap = m->heap; + size_t size = m->size, hsize = size >> 1; + ScrMockTimer *item = heap[pos]; + while (pos <= hsize) { + size_t child = pos << 1, next = child + 1; + ScrMockTimer *child_item = heap[child]; + if (next <= size && scr_mt_compare(heap[next], child_item) < 0) { + child = next; + child_item = heap[next]; + } + if (scr_mt_compare(item, child_item) <= 0) break; + child_item->pos = pos; + heap[pos] = child_item; + pos = child; + } + heap[pos] = item; + item->pos = pos; +} + +static void scr_mt_percolate_up(ScrMockTimers *m, size_t pos) { + ScrMockTimer **heap = m->heap; + ScrMockTimer *item = heap[pos]; + while (pos > 1) { + size_t parent = pos >> 1; + ScrMockTimer *parent_item = heap[parent]; + if (scr_mt_compare(parent_item, item) <= 0) break; + heap[pos] = parent_item; + parent_item->pos = pos; + pos = parent; + } + heap[pos] = item; + item->pos = pos; +} + +static void scr_mt_insert(ScrMockTimers *m, ScrMockTimer *e) { + if (m->size + 2 > m->cap) { + m->cap = m->cap ? m->cap * 2 : 8; + m->heap = realloc(m->heap, m->cap * sizeof *m->heap); + if (!m->heap) scr_test_oom(); + } + e->refs++; + m->heap[++m->size] = e; + scr_mt_percolate_up(m, m->size); +} + +static void scr_mt_remove_at(ScrMockTimers *m, size_t pos) { + ScrMockTimer **heap = m->heap; + ScrMockTimer *removed = heap[pos]; + heap[pos] = heap[m->size]; + heap[m->size] = NULL; + size_t size = --m->size; + if (size > 0 && pos <= size) { + if (pos > 1 && scr_mt_compare(heap[pos >> 1], heap[pos]) > 0) scr_mt_percolate_up(m, pos); + else scr_mt_percolate_down(m, pos); + } + scr_mt_unref(removed); +} + +static ScrMockTimer *scr_mt_peek(ScrMockTimers *m) { return m->size > 0 ? m->heap[1] : NULL; } + +static void scr_mt_throw_state(const char *what) { + char msg[160]; + int len = snprintf(msg, sizeof msg, "Invalid state: %s", what); + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, (size_t)len, "ERR_INVALID_STATE"); +} + +/* Node's #assertTimeArg: ERR_INVALID_ARG_VALUE('time', 'positive integer', time). */ +static bool scr_mt_time_ok(double time) { + if (!(time < 0)) return true; + char num[32]; + size_t n = scr_f64_to_str(time, num); + char msg[96]; + int len = snprintf(msg, sizeof msg, "The argument 'time' %.*s. Received 'positive integer'", + (int)n, num); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_INVALID_ARG_VALUE"); + return false; +} + +static int scr_mt_api(char code) { + switch (code) { + case 'I': + return SCR_MT_INTERVAL; + case 'M': + return SCR_MT_IMMEDIATE; + case 'D': + return SCR_MT_DATE; + case 'W': + return SCR_MT_WAIT; + case 'A': + return SCR_MT_ABORT; + default: + return SCR_MT_TIMEOUT; + } +} + +/* Node's #createTimer: the delay is used as given, except that one past + * TIMEOUT_MAX becomes 1; an omitted delay arrives as NaN (Node's + * undefined). */ +static ScrMockTimer *scr_mt_create(ScrMockTimers *m, bool is_interval, ScrClosure *cb /*moves*/, + double delay) { + if (delay > 2147483647.0) delay = 1; + ScrMockTimer *e = calloc(1, sizeof *e); + if (!e) scr_test_oom(); + e->id = m->next_id++; + e->cb = cb; + e->run_at = m->now + delay; + e->has_interval = is_interval && !isnan(delay); + e->interval = delay; + e->handle = scr_mt_next_handle--; + e->owner = m; + if (scr_mt_nlive == scr_mt_live_cap) { + scr_mt_live_cap = scr_mt_live_cap ? scr_mt_live_cap * 2 : 8; + scr_mt_live = realloc(scr_mt_live, scr_mt_live_cap * sizeof *scr_mt_live); + if (!scr_mt_live) scr_test_oom(); + } + scr_mt_live[scr_mt_nlive++] = e; + scr_mt_insert(m, e); + return e; +} + +/* Node's #clearTimer on the API's owner. A handle the owner did not + * create is left alone. */ +static void scr_mt_clear(ScrMockTimers *m, double handle) { + ScrMockTimer *e = scr_mt_find(handle); + if (!e || e->owner != m || e->pos == 0) return; + size_t pos = e->pos; + e->pos = 0; + e->has_interval = false; + scr_mt_remove_at(m, pos); +} + +static void scr_mt_tick(ScrMockTimers *m, double time) { + if (!m->enabled) { + scr_mt_throw_state("You should enable MockTimers first by calling the .enable function"); + return; + } + if (!scr_mt_time_ok(time)) return; + m->now += time; + ScrMockTimer *timer = scr_mt_peek(m); + while (timer) { + if (timer->run_at > m->now) break; + timer->refs++; + ((void (*)(ScrClosure *))timer->cb->fn)(timer->cb); + if (scr_exc_pending()) { + /* The throw leaves the entry queued, as in Node. */ + scr_mt_unref(timer); + return; + } + /* A callback that cleared its own timer already removed it. */ + ScrMockTimer *after = scr_mt_peek(m); + if (after && after->id == timer->id) { + timer->pos = 0; + scr_mt_remove_at(m, 1); + } + if (timer->has_interval) { + timer->run_at += timer->interval; + scr_mt_insert(m, timer); + } + scr_mt_unref(timer); + timer = scr_mt_peek(m); + } +} + +static void scr_mt_abort_arm(ScrClosure *cb /*moves*/, double ms) { + (void)scr_mt_create(scr_mt_owner[SCR_MT_ABORT], false, cb, ms); +} + +/* Points the Date, AbortSignal.timeout and scheduler.wait hooks at their + * owners. */ +static void scr_mt_hooks(void) { + ScrMockTimers *date = scr_mt_owner[SCR_MT_DATE]; + scr_date_mock_now = date ? &date->now : NULL; + scr_mt_abort_timeout_arm = scr_mt_owner[SCR_MT_ABORT] ? &scr_mt_abort_arm : NULL; + scr_mt_wait_state = scr_mt_owner[SCR_MT_WAIT] ? 1 : scr_mt_wait_unbound ? 2 : 0; +} + +/* Node's #toggleEnableTimers. Mocking Date while another MockTimers mocks + * it throws ERR_INVALID_STATE; the APIs listed before Date stay mocked, + * and the MockTimers stays disabled, as in Node. */ +static bool scr_mt_toggle(ScrMockTimers *m, bool activate) { + for (size_t i = 0; i < m->napis; i++) { + int api = scr_mt_api(m->apis[i]); + if (activate) { + if (api == SCR_MT_DATE && scr_mt_owner[api] != NULL) { + scr_mt_hooks(); + scr_mt_throw_state("Date is already being mocked!"); + return false; + } + m->saved[api] = scr_mt_owner[api]; + scr_mt_owner[api] = m; + } else { + scr_mt_owner[api] = m->saved[api]; + if (api == SCR_MT_WAIT && scr_mt_owner[api] == NULL) scr_mt_wait_unbound = true; + } + } + scr_mt_hooks(); + m->enabled = activate; + return true; +} + +static void scr_mt_reset(ScrMockTimers *m) { + if (!m || !m->enabled) return; + scr_mt_toggle(m, false); + m->napis = 0; + m->now = 0; + while (m->size > 0) scr_mt_remove_at(m, 1); +} + +/* enable({ apis, now }): `apis` holds one code per listed API, in order + * ('T' setTimeout, 'I' setInterval, 'M' setImmediate, 'D' Date, 'W' + * scheduler.wait, 'A' AbortSignal.timeout). flags: 1 = `now` is given, 2 = it + * came from a Date (Node then takes its time without checks). */ +static void scr_mt_enable(ScrMockTimers *m, ScrStr *apis, double now, double flags) { + if (m->enabled) { + scr_mt_throw_state("MockTimers is already enabled!"); + return; + } + bool given = ((int)flags & 1) != 0, from_date = ((int)flags & 2) != 0; + if (given && !from_date && isnan(now)) { + static const char msg[] = + "The argument 'now' epoch must be a positive integer received NaN. Received NaN"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_ARG_VALUE"); + return; + } + if (!given || (!from_date && now == 0)) now = 0; + m->napis = apis->len < sizeof m->apis ? apis->len : sizeof m->apis; + memcpy(m->apis, apis->data, m->napis); + if (!from_date && !scr_mt_time_ok(now)) return; + m->now = now; + (void)scr_mt_toggle(m, true); +} + +void scr_test_mock_timers_enable(ScrStr *apis, double now, double flags) { + scr_mt_enable(scr_mt_of(NULL), apis, now, flags); +} +void scr_test_ctx_mock_timers_enable(ScrTestCtx *t, ScrStr *apis, double now, double flags) { + scr_mt_enable(scr_mt_of(t), apis, now, flags); +} +void scr_test_mock_timers_tick(double time) { scr_mt_tick(scr_mt_of(NULL), time); } +void scr_test_ctx_mock_timers_tick(ScrTestCtx *t, double time) { scr_mt_tick(scr_mt_of(t), time); } + +static void scr_mt_run_all(ScrMockTimers *m) { + if (!m->enabled) { + scr_mt_throw_state("You should enable MockTimers first by calling the .enable function"); + return; + } + /* Node ticks to the entry in the heap's last slot. */ + if (m->size == 0) return; + scr_mt_tick(m, m->heap[m->size]->run_at - m->now); +} +void scr_test_mock_timers_run_all(void) { scr_mt_run_all(scr_mt_of(NULL)); } +void scr_test_ctx_mock_timers_run_all(ScrTestCtx *t) { scr_mt_run_all(scr_mt_of(t)); } +void scr_test_mock_timers_reset(void) { scr_mt_reset(scr_mt_module); } +void scr_test_ctx_mock_timers_reset(ScrTestCtx *t) { scr_mt_reset(t->mock_timers); } + +/* setTime(time): Node checks the time before whether timers are enabled. */ +static void scr_mt_set_time(ScrMockTimers *m, double time) { + if (!scr_mt_time_ok(time)) return; + if (!m->enabled) { + scr_mt_throw_state("You should enable MockTimers first by calling the .enable function"); + return; + } + m->now = time; +} +void scr_test_mock_timers_set_time(double time) { scr_mt_set_time(scr_mt_of(NULL), time); } +void scr_test_ctx_mock_timers_set_time(ScrTestCtx *t, double time) { + scr_mt_set_time(scr_mt_of(t), time); +} + +/* The timer calls of a program that uses mock.timers: an owned API + * queues on its owner, else the loop's timer functions run. */ +void scr_test_mt_set_timeout(ScrClosure *cb /*moves*/, double ms) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_TIMEOUT]; + if (m) (void)scr_mt_create(m, false, cb, ms); + else scr_set_timeout(cb, ms); +} +double scr_test_mt_set_timeout_handle(ScrClosure *cb /*moves*/, double ms) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_TIMEOUT]; + return m ? scr_mt_create(m, false, cb, ms)->handle : scr_set_timeout_handle(cb, ms); +} +double scr_test_mt_set_interval(ScrClosure *cb /*moves*/, double ms) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_INTERVAL]; + return m ? scr_mt_create(m, true, cb, ms)->handle : scr_set_interval(cb, ms); +} +void scr_test_mt_clear_timeout(double handle) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_TIMEOUT]; + if (m) scr_mt_clear(m, handle); + else scr_clear_interval(handle); +} +void scr_test_mt_clear_interval(double handle) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_INTERVAL]; + if (m) scr_mt_clear(m, handle); + else scr_clear_interval(handle); +} +/* A mock Timeout's ref() and unref() return it unchanged, refresh() does + * nothing, and hasRef() is true. */ +void scr_test_mt_unref(double handle) { + if (!(handle < 0)) scr_timer_unref(handle); +} +void scr_test_mt_ref(double handle) { + if (!(handle < 0)) scr_timer_ref(handle); +} +void scr_test_mt_refresh(double handle) { + if (!(handle < 0)) scr_timer_refresh(handle); +} +bool scr_test_mt_has_ref(double handle) { return handle < 0 || scr_timer_has_ref(handle); } +/* Node's mocked setImmediate queues a timer with delay -1, so it runs + * before timers due at the same tick; its handle is a mock Timeout. */ +double scr_test_mt_set_immediate(ScrClosure *cb /*moves*/) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_IMMEDIATE]; + return m ? scr_mt_create(m, false, cb, -1)->handle : scr_set_immediate(cb); +} +void scr_test_mt_clear_immediate(double handle) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_IMMEDIATE]; + if (m) scr_mt_clear(m, handle); + else scr_clear_immediate(handle); +} +void scr_test_mt_immediate_unref(double handle) { + if (!(handle < 0)) scr_immediate_unref(handle); +} +void scr_test_mt_immediate_ref(double handle) { + if (!(handle < 0)) scr_immediate_ref(handle); +} +bool scr_test_mt_immediate_has_ref(double handle) { + return handle < 0 || scr_immediate_has_ref(handle); +} +/* timers/promises scheduler.wait(delay). Node's mock settles the result + * from an async function that awaits an inner promise the entry + * resolves, so the result settles one microtask after the entry fires. + * The entry's closure holds the result promise until it first fires. */ +static void scr_mt_wait_fire(ScrClosure *self) { + ScrPromise *p = (ScrPromise *)scr_box_get_ref(self->caps[0]); + if (!p) return; + scr_box_set_ref(self->caps[0], NULL); + scr_queue_microtask(scr_make_resolve(p, 3 /* void */)); + scr_promise_release(p); +} + +static bool scr_mt_wait_bound(void) { + if (!scr_mt_wait_unbound) return true; + static const char msg[] = "Value of \"this\" must be of type Scheduler"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_THIS"); + return false; +} + +static ScrPromise *scr_mt_wait(ScrMockTimers *m, double ms) { + ScrPromise *p = scr_promise_new(); + ScrClosure *c = scr_closure_new((void *)&scr_mt_wait_fire, 1); + ScrBox *b = scr_box_new_obj(scr_promise_retain_v, scr_promise_release_v, scr_promise_trace_v); + scr_box_set_ref(b, scr_promise_retain(p)); + c->caps[0] = b; + (void)scr_mt_create(m, false, c, ms); + return p; +} + +ScrPromise *scr_test_mt_scheduler_wait(double ms) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_WAIT]; + if (m) return scr_mt_wait(m, ms); + return scr_mt_wait_bound() ? scr_tp_set_timeout(ms) : NULL; +} + +/* scheduler.wait(delay, { ref }): Node's mock ignores ref. */ +ScrPromise *scr_test_mt_scheduler_wait_value(double ms, ScrPromise *value, bool ref) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_WAIT]; + if (m) return scr_mt_wait(m, ms); + return scr_mt_wait_bound() ? scr_tp_set_timeout_value(ms, value, ref) : NULL; +} + +/* Date.now() and new Date() under a mocked Date read its clock. */ +double scr_test_mt_date_now(void) { + ScrMockTimers *m = scr_mt_owner[SCR_MT_DATE]; + return m ? m->now : scr_date_now(); +} + +/* Releases every MockTimers and its queued entries at exit. */ +static void scr_mt_free_all(void) { + while (scr_mt_all) { + ScrMockTimers *m = scr_mt_all; + scr_mt_all = m->next_all; + while (m->size > 0) scr_mt_remove_at(m, 1); + free(m->heap); + free(m); + } + free(scr_mt_live); + scr_mt_live = NULL; + scr_mt_nlive = scr_mt_live_cap = 0; + for (int i = 0; i < SCR_MT_NAPIS; i++) scr_mt_owner[i] = NULL; + scr_mt_hooks(); + scr_mt_module = NULL; +} + void scr_test_mock_track(ScrClosure *restore /*moves*/) { scr_test_mocks_add(&scr_test_mocks, restore); } void scr_test_mock_restore_all(void) { scr_test_mocks_restore(&scr_test_mocks, false); } -void scr_test_mock_reset(void) { scr_test_mocks_restore(&scr_test_mocks, true); } +/* reset() restores the tracker's mocks and resets its timers. */ +void scr_test_mock_reset(void) { + scr_test_mocks_restore(&scr_test_mocks, true); + scr_mt_reset(scr_mt_module); +} void scr_test_ctx_mock_track(ScrTestCtx *t, ScrClosure *restore /*moves*/) { scr_test_mocks_add(&t->mocks, restore); } void scr_test_ctx_mock_restore_all(ScrTestCtx *t) { scr_test_mocks_restore(&t->mocks, false); } -void scr_test_ctx_mock_reset(ScrTestCtx *t) { scr_test_mocks_restore(&t->mocks, true); } +void scr_test_ctx_mock_reset(ScrTestCtx *t) { + scr_test_mocks_restore(&t->mocks, true); + scr_mt_reset(t->mock_timers); +} void scr_test_ctx_plan(ScrTestCtx *t, double count) { t->plan = (int)count; } @@ -1098,7 +1611,10 @@ ScrStr *scr_test_ctx_full_name(ScrTestCtx *t) { int scr_test_exit_code(void) { /* The program is done: the module tracker's restore closures go. */ - if (scr_exit_frees()) scr_test_hooks_free(&scr_test_mocks); + if (scr_exit_frees()) { + scr_test_hooks_free(&scr_test_mocks); + scr_mt_free_all(); + } /* The runner never finished: a test awaited a promise nobody settles * and the loop exhausted (Node cancels the test and exits 1 — the * summary it prints there is not reproduced; SEMANTICS.md). The run diff --git a/packages/runtime/src/scr_test.h b/packages/runtime/src/scr_test.h index e1d97e12c..3e60f6abd 100644 --- a/packages/runtime/src/scr_test.h +++ b/packages/runtime/src/scr_test.h @@ -57,6 +57,44 @@ void scr_test_mock_reset(void); void scr_test_ctx_mock_track(ScrTestCtx *t, ScrClosure *restore); void scr_test_ctx_mock_restore_all(ScrTestCtx *t); void scr_test_ctx_mock_reset(ScrTestCtx *t); +/* mock.timers on the module's `mock` and on t.mock: enable({ apis, now }) + * (`apis` one code per listed API: 'T' setTimeout, 'I' setInterval, 'M' + * setImmediate, 'D' Date, 'W' scheduler.wait, 'A' AbortSignal.timeout; + * flags 1 = `now` given, 2 = it came from a Date), tick, runAll, reset + * and setTime. enable, tick, runAll and setTime throw Node's + * ERR_INVALID_STATE and ERR_INVALID_ARG_VALUE errors, and tick and runAll + * run the due timer callbacks, whose throws propagate. */ +void scr_test_mock_timers_enable(ScrStr *apis, double now, double flags); +void scr_test_ctx_mock_timers_enable(ScrTestCtx *t, ScrStr *apis, double now, double flags); +void scr_test_mock_timers_tick(double time); +void scr_test_ctx_mock_timers_tick(ScrTestCtx *t, double time); +void scr_test_mock_timers_run_all(void); +void scr_test_ctx_mock_timers_run_all(ScrTestCtx *t); +void scr_test_mock_timers_reset(void); +void scr_test_ctx_mock_timers_reset(ScrTestCtx *t); +void scr_test_mock_timers_set_time(double time); +void scr_test_ctx_mock_timers_set_time(ScrTestCtx *t, double time); +/* The timer calls of programs that use mock.timers (moduleUsesMockTimers): + * each queues on the API's MockTimers owner when it has one, else calls + * the loop's function of the same shape. */ +void scr_test_mt_set_timeout(ScrClosure *cb, double ms); +double scr_test_mt_set_timeout_handle(ScrClosure *cb, double ms); +double scr_test_mt_set_interval(ScrClosure *cb, double ms); +void scr_test_mt_clear_timeout(double handle); +void scr_test_mt_clear_interval(double handle); +void scr_test_mt_unref(double handle); +void scr_test_mt_ref(double handle); +void scr_test_mt_refresh(double handle); +bool scr_test_mt_has_ref(double handle); +double scr_test_mt_set_immediate(ScrClosure *cb); +void scr_test_mt_clear_immediate(double handle); +void scr_test_mt_immediate_unref(double handle); +void scr_test_mt_immediate_ref(double handle); +bool scr_test_mt_immediate_has_ref(double handle); +double scr_test_mt_date_now(void); +/* timers/promises scheduler.wait(delay) (+1 promise; NULL after a throw). */ +ScrPromise *scr_test_mt_scheduler_wait(double ms); +ScrPromise *scr_test_mt_scheduler_wait_value(double ms, ScrPromise *value, bool ref); /* Main's epilogue (emitted when moduleUsesNodeTest): 1 when any non-todo * test failed, Node's exit contract. */ int scr_test_exit_code(void); diff --git a/tests/corpus/5813-mock-timers-module.ts b/tests/corpus/5813-mock-timers-module.ts new file mode 100644 index 000000000..e9a343336 --- /dev/null +++ b/tests/corpus/5813-mock-timers-module.ts @@ -0,0 +1,91 @@ +// The module's mock.timers outside a test: a debounce and a retry loop +// driven by tick and runAll, the order due timers run in, intervals that +// clear themselves, setTime, Node's errors, and real timers made before +// enable() that keep running through the loop. +import { mock } from "node:test"; + +function debounce(fn: (value: string) => void, ms: number): (value: string) => void { + let pending: ReturnType | null = null; + return (value: string) => { + if (pending !== null) clearTimeout(pending); + pending = setTimeout(() => { + pending = null; + fn(value); + }, ms); + }; +} + +const real = setTimeout(() => console.log("real timer fired"), 1); + +mock.timers.enable({ apis: ["setTimeout", "setInterval"] }); +clearTimeout(real); // the mocked clearTimeout leaves the real timer armed + +const save = debounce((value) => console.log(`saved ${value}`), 100); +save("a"); +mock.timers.tick(50); +save("ab"); +mock.timers.tick(99); +console.log("nothing saved yet"); +save("abc"); +mock.timers.tick(100); + +let attempts = 0; +function attempt(delay: number): void { + attempts++; + console.log(`attempt ${attempts} after ${delay}ms`); + if (attempts < 4) setTimeout(() => attempt(delay * 2), delay * 2); +} +setTimeout(() => attempt(10), 10); +mock.timers.runAll(); +console.log(`runAll stopped after ${attempts} attempts`); +mock.timers.runAll(); +mock.timers.runAll(); +mock.timers.runAll(); +console.log(`attempts ${attempts}`); + +const order: string[] = []; +setTimeout(() => order.push("omitted")); +setTimeout(() => order.push("zero"), 0); +setTimeout(() => order.push("negative"), -1); +setTimeout((tag: string) => order.push(tag), 0, "zero with argument"); +mock.timers.tick(0); +console.log(order.join(", ")); + +let beats = 0; +const heartbeat = setInterval(() => { + beats++; + if (beats === 5) clearInterval(heartbeat); +}, 30); +mock.timers.tick(1000); +console.log(`beats ${beats}`); + +const handle = setTimeout(() => console.log("refreshed timer fired once"), 20); +console.log(handle.hasRef(), handle.unref().hasRef(), handle.ref().hasRef()); +handle.refresh(); +mock.timers.tick(20); +mock.timers.tick(20); + +mock.timers.setTime(1_000_000); +setTimeout(() => console.log("fires after setTime"), 1); +mock.timers.tick(1); + +for (const run of [ + () => mock.timers.enable({ apis: ["setTimeout"] }), + () => mock.timers.tick(-2), + () => mock.timers.setTime(-0.5), +]) { + try { + run(); + } catch (error) { + const e = error as { name: string; code: string; message: string }; + console.log(`${e.name} ${e.code}: ${e.message}`); + } +} + +mock.timers.reset(); +try { + mock.timers.runAll(); +} catch (error) { + console.log((error as Error).message); +} +setTimeout(() => console.log("real again after reset"), 30); diff --git a/tests/corpus/5814-mock-timers-date.ts b/tests/corpus/5814-mock-timers-date.ts new file mode 100644 index 000000000..494e3b7d8 --- /dev/null +++ b/tests/corpus/5814-mock-timers-date.ts @@ -0,0 +1,83 @@ +// The module's mock.timers for Date, setImmediate and AbortSignal.timeout: +// a cache whose entries expire by Date.now(), a batch that flushes on +// setImmediate, a request deadline from AbortSignal.timeout, and +// enable() without options, which mocks every API. +import { mock } from "node:test"; + +class TtlCache { + private entries = new Map(); + private ttl: number; + constructor(ttl: number) { + this.ttl = ttl; + } + set(key: string, value: string): void { + this.entries.set(key, { value, expires: Date.now() + this.ttl }); + } + get(key: string): string | undefined { + const entry = this.entries.get(key); + if (entry === undefined) return undefined; + if (Date.now() >= entry.expires) { + this.entries.delete(key); + return undefined; + } + return entry.value; + } +} + +mock.timers.enable({ apis: ["Date"], now: new Date("2024-05-01T12:00:00Z") }); +console.log(new Date().toISOString()); +const cache = new TtlCache(60_000); +cache.set("user", "ada"); +mock.timers.tick(59_999); +console.log(cache.get("user")); +mock.timers.tick(1); +console.log(cache.get("user")); +mock.timers.setTime(0); +console.log(Date.now(), new Date().toISOString()); +mock.timers.reset(); +console.log(Date.now() > 1_700_000_000_000); + +const batches: string[][] = []; +let pending: string[] = []; +function queue(item: string): void { + if (pending.length === 0) { + setImmediate(() => { + batches.push(pending); + pending = []; + }); + } + pending.push(item); +} + +mock.timers.enable({ apis: ["setImmediate"] }); +queue("a"); +queue("b"); +mock.timers.tick(0); +console.log(JSON.stringify(batches)); +queue("c"); +queue("d"); +console.log(JSON.stringify(pending)); +mock.timers.tick(0); +console.log(JSON.stringify(batches)); +const skipped = setImmediate(() => console.log("never")); +clearImmediate(skipped); +console.log(skipped.hasRef()); +mock.timers.runAll(); +mock.timers.reset(); + +mock.timers.enable(); +const deadline = AbortSignal.timeout(5_000); +deadline.addEventListener("abort", () => { + const reason = deadline.reason as { name: string; message: string }; + console.log(`aborted at ${Date.now()}: ${reason.name}: ${reason.message}`); +}); +mock.timers.tick(4_999); +console.log(deadline.aborted); +mock.timers.tick(1); +console.log(deadline.aborted); +mock.timers.reset(); + +const real = AbortSignal.timeout(1); +real.addEventListener("abort", () => { + console.log(`real deadline: ${(real.reason as { name: string }).name}`); +}); diff --git a/tests/diagnostics/mock-timers-fences.ts b/tests/diagnostics/mock-timers-fences.ts new file mode 100644 index 000000000..8deb95e76 --- /dev/null +++ b/tests/diagnostics/mock-timers-fences.ts @@ -0,0 +1,35 @@ +// mock.timers lowers enable(), enable({ apis, now }), tick, runAll, reset, +// setTime and [Symbol.dispose](), on the module's `mock` and on t.mock, and +// timers/promises scheduler.wait(delay) lowers. The rest is refused, each +// with a named diagnostic: an apis list or options object that is not a +// literal, and the MockTimers object used as a value. +import { mock, test } from "node:test"; +import { scheduler } from "node:timers/promises"; + +mock.timers.enable({ apis: ["setTimeout", "setInterval"], now: 10 }); // lowers +mock.timers.tick(5); // lowers +mock.timers.setTime(20); // lowers +mock.timers.runAll(); // lowers +mock.timers.reset(); // lowers + +mock.timers.enable(); // lowers +mock.timers.enable({ apis: ["Date"] }); // lowers +mock.timers.enable({ apis: ["setTimeout", "setImmediate"] }); // lowers + +const apis: ("setTimeout" | "setInterval")[] = ["setTimeout"]; +mock.timers.enable({ apis }); // apis list that is not a literal + +const options = { apis: ["setTimeout"] as const }; +mock.timers.enable(options); // options object that is not a literal + +const timers = mock.timers; // the object as a value +timers.tick(1); + +test("t.mock.timers", (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); // lowers + t.mock.timers.tick(); // lowers + t.mock.timers.enable({ apis: ["setTimeout"], now: new Date(0) }); // lowers + t.mock.timers[Symbol.dispose](); // lowers +}); + +void scheduler.wait(1); // lowers diff --git a/tests/fixtures/node-test/cases/mock-timers-date.ts b/tests/fixtures/node-test/cases/mock-timers-date.ts new file mode 100644 index 000000000..ca2714e05 --- /dev/null +++ b/tests/fixtures/node-test/cases/mock-timers-date.ts @@ -0,0 +1,197 @@ +// mock.timers for Date, setImmediate, AbortSignal.timeout and +// timers/promises scheduler.wait, enable() without apis, which mocks every +// API, and [Symbol.dispose](): Date.now() and new Date() read the mock +// clock that tick and setTime move, a mocked setImmediate runs before +// timers due at the same tick, a mocked AbortSignal.timeout aborts with +// the AbortError reason, mocking Date twice throws, and once a reset gives +// scheduler.wait back, Node's restored scheduler.wait throws +// ERR_INVALID_THIS, so its suite runs first. +import { describe, it, mock } from "node:test"; +import * as assert from "node:assert"; +import * as timersNamespace from "node:timers"; +import { setImmediate as namedSetImmediate } from "node:timers"; +import { scheduler } from "node:timers/promises"; +import * as timersPromises from "node:timers/promises"; + +type Fail = { name: string; code?: string; message: string }; +const failure = (fn: () => void): string => { + try { + fn(); + return "no throw"; + } catch (error) { + const e = error as Fail; + return `${e.name} ${e.code ?? "-"} ${e.message}`; + } +}; + +describe("scheduler.wait", () => { + it("settles from the mock queue, a microtask after tick", async (t) => { + // scheduler.wait is not in the typed apis list; enable() mocks it. + t.mock.timers.enable(); + const log: string[] = []; + const named = scheduler.wait(50).then(() => { + log.push("named"); + }); + const namespace = timersPromises.scheduler.wait(50).then(() => { + log.push("namespace"); + }); + const omitted = scheduler.wait().then(() => { + log.push("omitted"); + }); + const unref = scheduler.wait(50, { ref: false }).then(() => { + log.push("unref"); + }); + t.mock.timers.tick(50); + log.push("after tick"); + queueMicrotask(() => log.push("microtask")); + await Promise.all([named, namespace, omitted, unref]); + assert.deepStrictEqual(log, [ + "after tick", + "microtask", + "named", + "namespace", + "omitted", + "unref", + ]); + }); + + it("throws once a reset gave it back", () => { + const got = failure(() => { + void scheduler.wait(1); + }); + assert.strictEqual( + got, + 'TypeError ERR_INVALID_THIS Value of "this" must be of type Scheduler', + ); + }); +}); + +describe("Date", () => { + it("reads the mock clock", (t) => { + t.mock.timers.enable({ apis: ["Date"], now: 1000 }); + assert.strictEqual(Date.now(), 1000); + assert.strictEqual(new Date().getTime(), 1000); + assert.strictEqual(new Date(5).getTime(), 5); + assert.strictEqual(new Date() instanceof Date, true); + t.mock.timers.tick(5); + assert.strictEqual(Date.now(), 1005); + t.mock.timers.setTime(50); + assert.strictEqual(new Date().toISOString(), "1970-01-01T00:00:00.050Z"); + }); + + it("is real again after reset", (t) => { + t.mock.timers.enable({ apis: ["Date"] }); + assert.strictEqual(Date.now(), 0); + t.mock.timers.reset(); + assert.strictEqual(Date.now() > 1e12, true); + }); + + it("shares the clock with mocked timers", (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 100 }); + const seen: number[] = []; + setTimeout(() => seen.push(Date.now()), 25); + t.mock.timers.tick(30); + seen.push(Date.now()); + assert.deepStrictEqual(seen, [130, 130]); + }); + + it("throws when another tracker mocks Date", (t) => { + mock.timers.enable({ apis: ["Date"], now: 7 }); + const got = failure(() => t.mock.timers.enable({ apis: ["Date"] })); + assert.strictEqual(got, "Error ERR_INVALID_STATE Invalid state: Date is already being mocked!"); + assert.strictEqual(failure(() => t.mock.timers.tick(1)).includes("enable MockTimers first"), true); + assert.strictEqual(Date.now(), 7); + mock.timers.reset(); + }); +}); + +describe("setImmediate", () => { + it("runs before timers due at the same tick", (t) => { + t.mock.timers.enable({ apis: ["setImmediate", "setTimeout"] }); + const log: string[] = []; + setTimeout(() => log.push("timeout 0"), 0); + setImmediate(() => log.push("immediate")); + setImmediate((tag: string) => log.push(`immediate ${tag}`), "x"); + const cleared = setImmediate(() => log.push("cleared")); + clearImmediate(cleared); + assert.strictEqual(cleared.hasRef(), true); + assert.strictEqual(cleared.unref().hasRef(), true); + assert.strictEqual(cleared.ref().hasRef(), true); + t.mock.timers.tick(0); + log.push("tick 0"); + assert.deepStrictEqual(log, ["immediate", "immediate x", "timeout 0", "tick 0"]); + }); + + it("an immediate queued by a due timeout runs that timeout again", (t) => { + // Node removes a fired timer only when it is still first in the queue; + // the immediate's run time (now - 1) puts it first. + t.mock.timers.enable({ apis: ["setImmediate", "setTimeout"] }); + const log: string[] = []; + setTimeout(() => { + log.push("timeout"); + if (log.length === 1) setImmediate(() => log.push("immediate")); + }, 0); + t.mock.timers.tick(0); + assert.deepStrictEqual(log, ["timeout", "immediate", "timeout"]); + }); + + it("named and namespace imports keep the loop's immediates", async (t) => { + t.mock.timers.enable({ apis: ["setImmediate"] }); + const log: string[] = []; + await new Promise((resolve) => + namedSetImmediate(() => { + log.push("named"); + resolve(); + }), + ); + await new Promise((resolve) => + timersNamespace.setImmediate(() => { + log.push("namespace"); + resolve(); + }), + ); + assert.deepStrictEqual(log, ["named", "namespace"]); + }); +}); + +describe("enable() without apis", () => { + it("mocks every API", (t) => { + t.mock.timers.enable(); + const log: string[] = []; + assert.strictEqual(Date.now(), 0); + setTimeout(() => log.push(`timeout at ${Date.now()}`), 2); + setImmediate(() => log.push("immediate")); + const beat = setInterval(() => { + log.push(`interval at ${Date.now()}`); + clearInterval(beat); + }, 1); + const signal = AbortSignal.timeout(3); + signal.addEventListener("abort", () => { + const reason = signal.reason as { name: string; message: string }; + log.push(`abort ${reason.name}: ${reason.message}`); + }); + t.mock.timers.tick(3); + assert.strictEqual(signal.aborted, true); + assert.deepStrictEqual(log, [ + "immediate", + "interval at 3", + "timeout at 3", + "abort AbortError: This operation was aborted", + ]); + }); + + it("[Symbol.dispose]() resets", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let fired = 0; + setTimeout(() => fired++, 1); + t.mock.timers[Symbol.dispose](); + assert.strictEqual(failure(() => t.mock.timers.tick(1)).includes("enable MockTimers first"), true); + assert.strictEqual(fired, 0); + }); + + it("mocks nothing with an empty apis list", (t) => { + t.mock.timers.enable({ apis: [] }); + assert.strictEqual(Date.now() > 1e12, true); + t.mock.timers.tick(1); + }); +}); diff --git a/tests/fixtures/node-test/cases/mock-timers.ts b/tests/fixtures/node-test/cases/mock-timers.ts new file mode 100644 index 000000000..1e5e5883f --- /dev/null +++ b/tests/fixtures/node-test/cases/mock-timers.ts @@ -0,0 +1,348 @@ +// mock.timers on the module's `mock` and on t.mock, for setTimeout and +// setInterval: enable({ apis, now }), tick, runAll, reset and setTime, the +// order due timers run in (equal, zero, negative, fractional and omitted +// delays), clears, handle methods, Node's errors, and the reset of +// t.mock.timers when its test ends. Node replaces the globals, so calls +// through ESM named and namespace imports of node:timers and +// node:timers/promises keep the loop's timers. +import { describe, it, mock, test } from "node:test"; +import * as assert from "node:assert"; +import * as timersNamespace from "node:timers"; +import { setTimeout as namedSetTimeout } from "node:timers"; +import { setTimeout as sleep } from "node:timers/promises"; + +type Fail = { name: string; code?: string; message: string }; +const failure = (fn: () => void): string => { + try { + fn(); + return "no throw"; + } catch (error) { + const e = error as Fail; + return `${e.name} ${e.code ?? "-"} ${e.message}`; + } +}; +const NOT_ENABLED = + "Error ERR_INVALID_STATE Invalid state: You should enable MockTimers first by calling the .enable function"; +const negative = (n: string) => + `TypeError ERR_INVALID_ARG_VALUE The argument 'time' ${n}. Received 'positive integer'`; + +describe("order", () => { + it("runs due timers by run time, then creation", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + setTimeout(() => log.push("omitted")); + setTimeout(() => log.push("zero"), 0); + setTimeout(() => log.push("negative"), -5); + setTimeout(() => log.push("fraction"), 0.5); + setTimeout(() => log.push("ten a"), 10); + setTimeout(() => log.push("five"), 5); + setTimeout(() => log.push("ten b"), 10); + t.mock.timers.tick(0); + log.push("tick 0"); + t.mock.timers.tick(1); + log.push("tick 1"); + t.mock.timers.tick(9); + assert.deepStrictEqual(log, [ + "omitted", + "negative", + "zero", + "tick 0", + "fraction", + "tick 1", + "five", + "ten a", + "ten b", + ]); + }); + + it("runs timers scheduled by a callback once they are due", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + setTimeout(() => { + log.push("outer"); + setTimeout(() => log.push("inner 0"), 0); + setTimeout(() => log.push("inner 5"), 5); + }, 10); + t.mock.timers.tick(10); + log.push("after 10"); + t.mock.timers.tick(5); + log.push("after 15"); + t.mock.timers.tick(); + assert.deepStrictEqual(log, ["outer", "inner 0", "after 10", "inner 5", "after 15"]); + }); + + it("passes the trailing arguments", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + setTimeout((a: string, b: number) => log.push(`${a} ${b}`), 1, "x", 2); + t.mock.timers.tick(1); + assert.deepStrictEqual(log, ["x 2"]); + }); +}); + +describe("intervals", () => { + it("repeats until cleared", (t) => { + t.mock.timers.enable({ apis: ["setInterval", "setTimeout"] }); + const log: string[] = []; + let n = 0; + const handle = setInterval(() => { + n++; + log.push(`interval ${n}`); + if (n === 3) clearInterval(handle); + }, 10); + setTimeout(() => log.push("timeout 25"), 25); + t.mock.timers.tick(100); + assert.deepStrictEqual(log, ["interval 1", "interval 2", "timeout 25", "interval 3"]); + }); + + it("fires once per elapsed period", (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + let n = 0; + const handle = setInterval(() => n++, 3); + t.mock.timers.tick(10); + assert.strictEqual(n, 3); + clearInterval(handle); + t.mock.timers.tick(10); + assert.strictEqual(n, 3); + }); + + it("clears from another callback", (t) => { + t.mock.timers.enable({ apis: ["setInterval", "setTimeout"] }); + let n = 0; + const handle = setInterval(() => n++, 4); + setTimeout(() => clearInterval(handle), 9); + t.mock.timers.tick(20); + assert.strictEqual(n, 2); + }); + + it("leaves intervals alone when only setTimeout is mocked", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let n = 0; + await new Promise((resolve) => { + const handle = setInterval(() => { + n++; + clearInterval(handle); + resolve(); + }, 5); + }); + assert.strictEqual(n, 1); + }); +}); + +describe("runAll", () => { + it("ticks to the last queued timer", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + setTimeout(() => { + log.push("a"); + setTimeout(() => log.push("b"), 100); + }, 10); + setTimeout(() => log.push("c"), 20); + t.mock.timers.runAll(); + log.push("first runAll"); + t.mock.timers.runAll(); + log.push("second runAll"); + t.mock.timers.runAll(); + assert.deepStrictEqual(log, ["a", "c", "first runAll", "b", "second runAll"]); + }); +}); + +describe("clock", () => { + it("starts at now and moves with setTime", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"], now: 1000 }); + const log: string[] = []; + setTimeout(() => log.push("a"), 10); + t.mock.timers.setTime(5000); + setTimeout(() => log.push("b"), 10); + t.mock.timers.tick(1); + log.push("tick 1"); + t.mock.timers.tick(9); + t.mock.timers.setTime(); + setTimeout(() => log.push("c"), 1); + t.mock.timers.tick(1); + assert.deepStrictEqual(log, ["a", "tick 1", "b", "c"]); + }); + + it("takes now from a Date", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"], now: new Date(2000) }); + const log: string[] = []; + setTimeout(() => log.push("a"), 10); + t.mock.timers.setTime(2005); + t.mock.timers.tick(4); + log.push("tick 4"); + t.mock.timers.tick(1); + assert.deepStrictEqual(log, ["tick 4", "a"]); + }); +}); + +describe("handles", () => { + it("ref, unref and refresh leave a mock timer as it is", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let fired = 0; + const handle = setTimeout(() => fired++, 5); + assert.strictEqual(handle.hasRef(), true); + assert.strictEqual(handle.unref().hasRef(), true); + assert.strictEqual(handle.ref().hasRef(), true); + handle.refresh(); + t.mock.timers.tick(5); + t.mock.timers.tick(5); + assert.strictEqual(fired, 1); + }); + + it("clearTimeout of a mock timer", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let fired = 0; + const handle = setTimeout(() => fired++, 5); + clearTimeout(handle); + t.mock.timers.tick(10); + assert.strictEqual(fired, 0); + }); + + it("a real timer made before enable keeps running", async (t) => { + let fired = 0; + const done = new Promise((resolve) => { + const real = setTimeout(() => { + fired++; + resolve(); + }, 5); + t.mock.timers.enable({ apis: ["setTimeout"] }); + // The mocked clearTimeout leaves the real timer armed. + clearTimeout(real); + assert.strictEqual(real.hasRef(), true); + }); + await done; + assert.strictEqual(fired, 1); + }); +}); + +describe("errors", () => { + it("throws Node's errors", (t) => { + const got: string[] = []; + got.push(failure(() => t.mock.timers.tick(1))); + got.push(failure(() => t.mock.timers.runAll())); + got.push(failure(() => t.mock.timers.setTime(-1))); + got.push(failure(() => t.mock.timers.setTime(5))); + got.push(failure(() => t.mock.timers.enable({ apis: ["setTimeout"], now: -1 }))); + got.push(failure(() => t.mock.timers.enable({ apis: ["setTimeout"], now: NaN }))); + got.push(failure(() => t.mock.timers.reset())); + t.mock.timers.enable({ apis: ["setTimeout"] }); + got.push(failure(() => t.mock.timers.enable({ apis: ["setTimeout"] }))); + got.push(failure(() => t.mock.timers.tick(-1.5))); + got.push(failure(() => t.mock.timers.tick(-Infinity))); + got.push(failure(() => t.mock.timers.tick(0))); + assert.deepStrictEqual(got, [ + NOT_ENABLED, + NOT_ENABLED, + negative("-1"), + NOT_ENABLED, + negative("-1"), + "TypeError ERR_INVALID_ARG_VALUE The argument 'now' epoch must be a positive integer received NaN. Received NaN", + "no throw", + "Error ERR_INVALID_STATE Invalid state: MockTimers is already enabled!", + negative("-1.5"), + negative("-Infinity"), + "no throw", + ]); + }); + + it("a throwing callback stays queued", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let runs = 0; + setTimeout(() => { + runs++; + if (runs === 1) throw new Error("boom"); + }, 5); + assert.throws(() => t.mock.timers.tick(5), { message: "boom" }); + t.mock.timers.tick(0); + t.mock.timers.tick(0); + assert.strictEqual(runs, 2); + }); +}); + +describe("imports", () => { + it("globalThis.setTimeout is mocked", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + globalThis.setTimeout(() => log.push("timeout"), 5); + t.mock.timers.tick(5); + assert.deepStrictEqual(log, ["timeout"]); + }); + + it("named and namespace imports keep the loop's timers", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + await Promise.all([ + sleep(5).then(() => { + log.push("named promise"); + }), + new Promise((resolve) => + namedSetTimeout(() => { + log.push("named"); + resolve(); + }, 2), + ), + new Promise((resolve) => + timersNamespace.setTimeout(() => { + log.push("namespace"); + resolve(); + }, 3), + ), + ]); + t.mock.timers.tick(10); + assert.deepStrictEqual(log, ["named", "namespace", "named promise"]); + }); +}); + +describe("trackers", () => { + it("leaves t.mock.timers enabled", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + setTimeout(() => { + throw new Error("never runs"); + }, 1); + }); + + it("the previous test's timers were reset", async () => { + let fired = false; + await new Promise((resolve) => + setTimeout(() => { + fired = true; + resolve(); + }, 1), + ); + assert.strictEqual(fired, true); + }); + + it("t.mock takes setTimeout over from mock and gives it back", (t) => { + const log: string[] = []; + mock.timers.enable({ apis: ["setTimeout"] }); + setTimeout(() => log.push("module"), 5); + t.mock.timers.enable({ apis: ["setTimeout"] }); + setTimeout(() => log.push("test"), 5); + mock.timers.tick(5); + log.push("module ticked"); + t.mock.timers.tick(5); + t.mock.timers.reset(); + setTimeout(() => log.push("module again"), 1); + mock.timers.tick(1); + mock.timers.reset(); + assert.deepStrictEqual(log, ["module", "module ticked", "test", "module again"]); + }); + + it("mock.reset() resets mock.timers", () => { + mock.timers.enable({ apis: ["setInterval"] }); + mock.reset(); + assert.strictEqual(failure(() => mock.timers.tick(1)), NOT_ENABLED); + }); +}); + +test("timers outside node:test bodies", () => { + // A fresh enable after reset starts the clock at 0 again. + mock.timers.enable({ apis: ["setTimeout"], now: 100 }); + mock.timers.reset(); + mock.timers.enable({ apis: ["setTimeout"] }); + const log: string[] = []; + setTimeout(() => log.push("at 1"), 1); + mock.timers.tick(1); + mock.timers.reset(); + assert.deepStrictEqual(log, ["at 1"]); +}); diff --git a/tests/harness/__snapshots__/mock-timers-fences.ts.txt b/tests/harness/__snapshots__/mock-timers-fences.ts.txt new file mode 100644 index 000000000..dba7de64c --- /dev/null +++ b/tests/harness/__snapshots__/mock-timers-fences.ts.txt @@ -0,0 +1,44 @@ +mock-timers-fences.ts:20:22 - error SC2020: 'mock.timers.enable with a non-literal apis list' is part of the standard library types but has no scriptc lowering yet + + 19 | const apis: ("setTimeout" | "setInterval")[] = ["setTimeout"]; + 20 | mock.timers.enable({ apis }); // apis list that is not a literal + | ^~~~ + 21 | + + hint: list the mocked APIs as string literals: enable({ apis: ['setTimeout', 'Date'] }) + +mock-timers-fences.ts:23:20 - error SC2020: 'mock.timers.enable with a non-literal options object' is part of the standard library types but has no scriptc lowering yet + + 22 | const options = { apis: ["setTimeout"] as const }; + 23 | mock.timers.enable(options); // options object that is not a literal + | ^~~~~~~ + 24 | + + hint: list the mocked APIs as string literals: enable({ apis: ['setTimeout', 'Date'] }) + +mock-timers-fences.ts:25:7 - error SC2020: 'MockTimers' is part of the standard library types but has no scriptc lowering yet + + 24 | + 25 | const timers = mock.timers; // the object as a value + | ^~~~~~ + 26 | timers.tick(1); + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +mock-timers-fences.ts:25:16 - error SC2020: 'MockTracker.timers' is part of the standard library types but has no scriptc lowering yet + + 24 | + 25 | const timers = mock.timers; // the object as a value + | ^~~~~~~~~~~ + 26 | timers.tick(1); + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) + +mock-timers-fences.ts:26:1 - error SC2020: 'MockTimers.tick' is part of the standard library types but has no scriptc lowering yet + + 25 | const timers = mock.timers; // the object as a value + 26 | timers.tick(1); + | ^~~~~~~~~~~ + 27 | + + hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) \ No newline at end of file From 3d2c834125fadb8e5a9e0a3e1614662a6a773d5a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 17 Oct 2026 21:55:00 -0500 Subject: [PATCH 02/17] Add corpus costs for the mock programs The corpus programs 5810 to 5814 had no measured cost, which put the measured share of the corpus below shard.test.ts's 0.9 floor. Their plain and SCRIPTC_SAN=1 costs are cold local timings scaled to the CI runner by programs with recorded costs: 1440, 1441, 1442 and 1463, and 5133 for 5814's sanitized run, which links the AbortSignal runtime. --- tests/harness/corpus-costs.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index f87883acd..dda7a63c0 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,6 +1841,11 @@ "5648-dns-lookup-forms.ts": 2992, "5649-dns-service-servers.ts": 2712, "5650-dns-lookup-errors-untyped.cjs": 2822, + "5810-mock-method-records.ts": 3976, + "5811-mock-tracker-restore.ts": 3698, + "5812-dyn-box-async-fields.ts": 3522, + "5813-mock-timers-module.ts": 3766, + "5814-mock-timers-date.ts": 3760, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4465,6 +4470,11 @@ "5648-dns-lookup-forms.ts": 3416, "5649-dns-service-servers.ts": 4916, "5650-dns-lookup-errors-untyped.cjs": 3889, + "5810-mock-method-records.ts": 6363, + "5811-mock-tracker-restore.ts": 4503, + "5812-dyn-box-async-fields.ts": 5278, + "5813-mock-timers-module.ts": 4227, + "5814-mock-timers-date.ts": 17708, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, From 51777dee63f42fa18d8d9e92578f28a2d4ffc57e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 03:43:06 -0500 Subject: [PATCH 03/17] Add markAsUncloneable and worker[Symbol.asyncDispose] markAsUncloneable(value) marks a plain object, a record, an error or a class instance (scr_json.c); structuredClone and postMessage then throw DataCloneError "Cannot clone object of unsupported type." for it, also where it is nested. As in Node, the mark has no effect on primitives, arrays, typed arrays and ArrayBuffers. Marked values are retained until the thread exits; with nothing marked, the clone paths add one branch. worker[Symbol.asyncDispose]() terminates the worker and resolves with undefined after its 'exit' event, so `await using` disposes a Worker at the end of its block. The Worker handle's symbol_get op answers the well-known symbol: the symbol unit registers Symbol.asyncDispose with the dyn core when the program names it, and scr_dyn_is_async_dispose_symbol tells the worker unit, so worker programs that never name the symbol do not link the symbol unit. static-support.json adds rows for markAsUncloneable, isInternalThread (already lowered as false and declared) and worker[Symbol.asyncDispose](). Corpus 5990-5991 match Node v24.22.0; corpus-costs.json records their costs. --- .../limitations/native-worker-threads.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 22 +++--- .../generated/node-v24-backlog.json | 46 ++++++----- .../generated/node-v24-internal.json | 27 +++++-- internal/compatibility/static-support.json | 2 + .../ambient/scriptc-node-fallback.d.ts | 5 ++ .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/builtin-calls.ts | 4 + .../src/frontend/lowering/builtins/workers.ts | 19 +++++ .../compiler/src/ir/builtin-signatures.ts | 2 + .../corpus/5990-worker-uncloneable.mjs.json | 6 ++ .../corpus/5991-worker-async-dispose.mjs.json | 6 ++ packages/runtime/src/scr_dyn.h | 11 +++ packages/runtime/src/scr_json.c | 79 +++++++++++++++++++ packages/runtime/src/scr_message.c | 4 + packages/runtime/src/scr_symbol.c | 5 ++ packages/runtime/src/scr_worker_events.c | 39 +++++++++ tests/corpus/5990-worker-uncloneable.mjs | 59 ++++++++++++++ tests/corpus/5991-worker-async-dispose.mjs | 25 ++++++ tests/harness/corpus-costs.json | 4 + 21 files changed, 329 insertions(+), 41 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-worker-uncloneable.mjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5991-worker-async-dispose.mjs.json create mode 100644 tests/corpus/5990-worker-uncloneable.mjs create mode 100644 tests/corpus/5991-worker-async-dispose.mjs diff --git a/docs/content/limitations/native-worker-threads.mdx b/docs/content/limitations/native-worker-threads.mdx index 972eadeca..0510936d6 100644 --- a/docs/content/limitations/native-worker-threads.mdx +++ b/docs/content/limitations/native-worker-threads.mdx @@ -1,6 +1,6 @@ `node:worker_threads` runs workers as native threads. Worker entry points must resolve at build time: use a source-relative `new URL("./worker.ts", import.meta.url)`, the current module URL, an absolute path, or CommonJS `__filename`. Each worker initializes its own module state, event loop, environment snapshot, and reference-counted heap. Nested workers inherit their parent's environment snapshot. Supported constructor options are `workerData`, `argv`, a `transferList` of MessagePorts, `name` (reported by `threadName` and `worker.threadName`), `stdout`, `stderr` and `stdin`, and `resourceLimits.stackSizeMb`, which sets the worker thread's stack size (at least 192 KiB; without it a worker's stack is 8 MiB, where Node's is 4 MiB). The `resourceLimits` heap limits (`maxOldGenerationSizeMb`, `maxYoungGenerationSizeMb`, and `codeRangeSizeMb`), `worker.resourceLimits` while the worker runs, and `resourceLimits` read inside a worker are refused; in the main thread `resourceLimits` is `{}`, and after a worker exits `worker.resourceLimits` is `{}`, as in Node. With the `stdout` or `stderr` option, the worker's writes to that stream reach `worker.stdout` or `worker.stderr`, a Readable that ends after the worker's `'exit'` event; its chunks arrive in order with the worker's other events. Without the option a worker writes to the process's own output, and reading `worker.stdout` or `worker.stderr` is refused. With the `stdin` option, `worker.stdin` is a Writable whose chunks the worker reads from `process.stdin`, in order with the worker's other events, and `process.stdin` ends when `worker.stdin` ends; without it `worker.stdin` is `null` and the worker's `process.stdin` ends without data, as in Node. Runtime paths, `eval`, loaders, and shared environments are unsupported. `setEnvironmentData` and `getEnvironmentData` keep a map per thread with SameValueZero keys; each new Worker starts from a clone of its parent's entries taken by the constructor, which throws `DataCloneError` when an entry cannot be cloned. Worker argument strings containing null bytes are also unsupported. -Workers support `online`, `message`, `error`, and `exit` events, `postMessage`, `terminate`, `ref`, and `unref`. A worker's uncaught exception reaches its `error` listeners as Node's error serializer sends it: an Error keeps the nearest of `Error` and its six native subclasses (an `AggregateError` arrives as an `Error` named `AggregateError`) along with its name, message, cause, and other data properties and their enumerability, and other thrown values clone as messages do. An unhandled rejection whose reason is not error-like arrives as an `UnhandledPromiseRejection` Error with code `ERR_UNHANDLED_REJECTION` quoting the reason. A thrown value that does not clone, such as a function, arrives as an `ERR_WORKER_UNSERIALIZABLE_ERROR` Error, where Node sends its `util.inspect` string. `new MessageChannel()` from `node:worker_threads` makes two entangled MessagePorts, in one thread or across threads once a port is transferred, with `receiveMessageOnPort`. Port messages arrive in Node's order: each port's queued messages as one batch before `setImmediate` callbacks, a port's own `'close'` after the poll phase, and the other port's `'close'` after its queued messages while the loop keeps running. `receiveMessageOnPort(parentPort)` in a worker takes the parent's next message. A port's `onmessage` property and `addEventListener` are unsupported. `BroadcastChannel` delivers each message to every other open channel with the same name, in any thread, as a `'message'` `MessageEvent` through `onmessage` and `addEventListener`; an open channel keeps the process alive until `close()` or `unref()`. A `messageerror` event is never dispatched. The parent port supports message listeners, `postMessage`, `close`, `start`, `ref`, `unref`, and `hasRef`. Messages clone primitives, plain object graphs, arrays, errors, ArrayBuffers, typed arrays, and DataViews, preserving cycles and aliases. Program class instances arrive as plain objects with their own data properties, without their prototype, methods, accessors, or private fields, as in Node. `markAsUntransferable` marks objects for the current thread, and a marked ArrayBuffer or MessagePort in a transfer list throws `DataCloneError`, as does the ArrayBuffer of a pooled Buffer; marked objects other than ArrayBuffers stay alive until their thread exits. A MessagePort travels with a message when the message's transfer list (an array or `{ transfer }`) names it, and the sending port is unusable afterwards; a port in a message without that listing throws `DataCloneError`. Other transferable values, functions, proxies, and other native handles are refused. SharedArrayBuffers share their backing storage while retaining separate wrappers in each thread. Graphs made immutable with `@scriptc/threads` are passed by reference instead of cloned; see [Sharing Data Between Workers](/docs/threads). +Workers support `online`, `message`, `error`, and `exit` events, `postMessage`, `terminate`, `ref`, and `unref`. `worker[Symbol.asyncDispose]()` terminates the worker and resolves with `undefined` after its `exit` event, so `await using` disposes a Worker at the end of its block. A worker's uncaught exception reaches its `error` listeners as Node's error serializer sends it: an Error keeps the nearest of `Error` and its six native subclasses (an `AggregateError` arrives as an `Error` named `AggregateError`) along with its name, message, cause, and other data properties and their enumerability, and other thrown values clone as messages do. An unhandled rejection whose reason is not error-like arrives as an `UnhandledPromiseRejection` Error with code `ERR_UNHANDLED_REJECTION` quoting the reason. A thrown value that does not clone, such as a function, arrives as an `ERR_WORKER_UNSERIALIZABLE_ERROR` Error, where Node sends its `util.inspect` string. `new MessageChannel()` from `node:worker_threads` makes two entangled MessagePorts, in one thread or across threads once a port is transferred, with `receiveMessageOnPort`. Port messages arrive in Node's order: each port's queued messages as one batch before `setImmediate` callbacks, a port's own `'close'` after the poll phase, and the other port's `'close'` after its queued messages while the loop keeps running. `receiveMessageOnPort(parentPort)` in a worker takes the parent's next message. A port's `onmessage` property and `addEventListener` are unsupported. `BroadcastChannel` delivers each message to every other open channel with the same name, in any thread, as a `'message'` `MessageEvent` through `onmessage` and `addEventListener`; an open channel keeps the process alive until `close()` or `unref()`. A `messageerror` event is never dispatched. The parent port supports message listeners, `postMessage`, `close`, `start`, `ref`, `unref`, and `hasRef`. Messages clone primitives, plain object graphs, arrays, errors, ArrayBuffers, typed arrays, and DataViews, preserving cycles and aliases. Program class instances arrive as plain objects with their own data properties, without their prototype, methods, accessors, or private fields, as in Node. `markAsUntransferable` marks objects for the current thread, and a marked ArrayBuffer or MessagePort in a transfer list throws `DataCloneError`, as does the ArrayBuffer of a pooled Buffer; marked objects other than ArrayBuffers stay alive until their thread exits. `markAsUncloneable` marks a plain object, an error, or a class instance, and `structuredClone` and `postMessage` then throw `DataCloneError` for it, also where it is nested; as in Node, the mark has no effect on primitives, arrays, typed arrays, and ArrayBuffers. A plain object that the compiler copies into a container with a fixed element type, such as an array literal whose elements all have one shape, is a separate object in the copy, which is not marked. Marked objects stay alive until their thread exits. A MessagePort travels with a message when the message's transfer list (an array or `{ transfer }`) names it, and the sending port is unusable afterwards; a port in a message without that listing throws `DataCloneError`. Other transferable values, functions, proxies, and other native handles are refused. SharedArrayBuffers share their backing storage while retaining separate wrappers in each thread. Graphs made immutable with `@scriptc/threads` are passed by reference instead of cloned; see [Sharing Data Between Workers](/docs/threads). SharedArrayBuffer storage is fixed-length. Atomics supports integer `load`, `store`, `exchange`, `compareExchange`, `add`, `sub`, `and`, `or`, and `xor` over 8-, 16-, and 32-bit integer arrays. `wait` and `notify` use Int32Array views; BigInt atomics, `waitAsync`, and growable buffers are unsupported. Atomics operations run as sequentially consistent atomic instructions and take no lock. Other reads and writes of shared storage access each element atomically; a copy, fill, or conversion that runs while another thread writes the same storage can observe some of those writes, as in Node. `terminate` unwinds at runtime checkpoints, including loops and parked atomic waits, without running JavaScript catch or finally blocks. Blocking native calls finish before the next checkpoint. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 55fdcc23a..ca824b4cc 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "f2365d9fcaf87f1ccf46", + "artifactVersion": "b67c1214a0f3f7f3c49f", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index e3da0a715..986725b5d 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -884,9 +884,9 @@ "apiEntries": 66, "static": { "supported": 0, - "partial": 43, + "partial": 46, "refused": 0, - "not-implemented": 23, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -83564,9 +83564,9 @@ "inherited": true }, "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" } }, { @@ -83955,9 +83955,9 @@ "inherited": true }, "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" } }, { @@ -84070,9 +84070,9 @@ "inherited": true }, "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" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 7c7453777..eebcf8083 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1131, - "verify-gap": 2198, + "audit-partial": 1134, + "verify-gap": 2195, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -84573,12 +84573,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:worker_threads", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:worker_threads.worker", + "tests": [ + "tests/corpus/5991-worker-async-dispose.mjs" + ] } } }, @@ -85052,12 +85054,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:worker_threads", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:worker_threads.worker_threads.markAsUncloneable", + "tests": [ + "tests/corpus/5990-worker-uncloneable.mjs" + ] } } }, @@ -85185,12 +85189,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:worker_threads", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:worker_threads.isInternalThread", + "tests": [ + "tests/corpus/5990-worker-uncloneable.mjs" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index c444c9934..2dfa8f8bd 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -941,9 +941,9 @@ "apiEntries": 66, "static": { "supported": 0, - "partial": 43, + "partial": 46, "refused": 0, - "not-implemented": 23, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -113045,6 +113045,8 @@ "tests/corpus/5874-worker-stdio-streams.ts", "tests/corpus/5875-worker-stdout-pipe.cjs", "tests/corpus/5879-worker-stdin.ts", + "tests/corpus/5990-worker-uncloneable.mjs", + "tests/corpus/5991-worker-async-dispose.mjs", "tests/corpus/native-worker-clone-errors.ts", "tests/corpus/native-worker-commonjs.cjs", "tests/corpus/native-worker-delayed-messages.ts", @@ -113936,8 +113938,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker", + "tests": [ + "tests/corpus/5991-worker-async-dispose.mjs" + ] }, "anchorSource": "exact" }, @@ -114394,8 +114399,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker_threads.markAsUncloneable", + "tests": [ + "tests/corpus/5990-worker-uncloneable.mjs" + ] }, "anchorSource": "exact" }, @@ -114519,8 +114527,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.isInternalThread", + "tests": [ + "tests/corpus/5990-worker-uncloneable.mjs" + ] }, "anchorSource": "chapter" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 04a98792b..67a9b95e0 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -211,6 +211,8 @@ {"chapter": "worker_threads", "symbols": ["worker_threads.getEnvironmentData", "worker_threads.setEnvironmentData"], "status": "partial", "evidence": ["tests/corpus/5803-worker-environment-data.ts", "tests/corpus/5804-worker-environment-data.cjs"]}, {"chapter": "worker_threads", "symbols": ["stdout", "stderr", "stdin"], "status": "partial", "evidence": ["tests/corpus/5874-worker-stdio-streams.ts", "tests/corpus/5875-worker-stdout-pipe.cjs", "tests/corpus/5879-worker-stdin.ts"]}, {"chapter": "worker_threads", "symbols": ["threadName", "worker_threads.markAsUntransferable", "worker_threads.isMarkedAsUntransferable"], "status": "partial", "evidence": ["tests/corpus/5870-worker-thread-names.ts", "tests/corpus/5871-worker-untransferable.cjs"]}, + {"chapter": "worker_threads", "symbols": ["worker_threads.markAsUncloneable", "isInternalThread"], "status": "partial", "evidence": ["tests/corpus/5990-worker-uncloneable.mjs"]}, + {"chapter": "worker_threads", "signatures": ["`worker[Symbol.asyncDispose]()`"], "status": "partial", "evidence": ["tests/corpus/5991-worker-async-dispose.mjs"]}, {"chapter": "worker_threads", "symbols": ["resourceLimits"], "status": "partial", "evidence": ["tests/corpus/5806-worker-resource-limits.ts"]}, {"chapter": "worker_threads", "symbols": ["BroadcastChannel extends EventTarget", "broadcastChannel.close", "broadcastChannel.postMessage", "broadcastChannel.ref", "broadcastChannel.unref", "onmessage"], "status": "partial", "evidence": ["tests/corpus/5800-broadcast-channel.ts", "tests/corpus/5801-broadcast-channel.cjs", "tests/corpus/5802-broadcast-channel-workers.ts"]}, {"chapter": "util", "symbols": ["util.types.isAnyArrayBuffer", "util.types.isArrayBuffer", "util.types.isArrayBufferView", "util.types.isDataView", "util.types.isTypedArray", "util.types.isUint8Array", "util.types.isUint8ClampedArray", "util.types.isUint16Array", "util.types.isUint32Array", "util.types.isInt8Array", "util.types.isInt16Array", "util.types.isInt32Array", "util.types.isFloat32Array", "util.types.isFloat64Array", "util.types.isMap", "util.types.isSet", "util.types.isWeakMap", "util.types.isWeakSet", "util.types.isRegExp", "util.types.isNativeError", "util.types.isPromise", "util.types.isProxy", "util.types.isAsyncFunction", "util.types.isGeneratorFunction", "util.types.isGeneratorObject"], "status": "partial", "evidence": ["tests/corpus/node24-util-types.ts", "tests/corpus/node24-util-types.js", "tests/corpus/node24-util-types.cjs", "tests/fixtures/node-types/util-types.ts"]}, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 9b67e50fc..d866c81f7 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -5857,6 +5857,8 @@ declare module "worker_threads" { readonly stderr: import("stream").Readable; /* with the stdin option: what the worker reads from process.stdin */ readonly stdin: import("stream").Writable | null; + /* terminate(), resolving with undefined once the worker exited */ + [Symbol.asyncDispose](): Promise; on(event: "online", listener: () => void): this; on(event: "message", listener: (value: any) => void): this; on(event: "error", listener: (error: any) => void): this; @@ -5906,6 +5908,9 @@ declare module "worker_threads" { export function getEnvironmentData(key: any): any; /* A marked object cannot be listed in a transfer list. */ export function markAsUntransferable(object: any): void; + /* structuredClone and postMessage throw DataCloneError for a marked + * object, error or class instance */ + export function markAsUncloneable(object: any): void; export function isMarkedAsUntransferable(object: any): boolean; /* Delivers each message to every other open channel with the name, in * any thread, as a 'message' MessageEvent. */ diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9b7b758e3..6516756e9 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -945,6 +945,7 @@ export const LIB_FN_SYMS: Record = { "worker.getEnvironmentData": "scr_worker_get_environment_data", "worker.markUntransferable": "scr_worker_mark_untransferable", "worker.isMarkedUntransferable": "scr_worker_is_marked_untransferable", + "dyn.markUncloneable": "scr_mark_uncloneable", "stream.workerStdio": "scr_stream_worker_stdio", "worker.broadcastChannel": "scr_worker_broadcast_channel", "event.broadcastInstall": "scr_et_broadcast_install", diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index b535b8a5d..037b3e5a7 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -2,6 +2,7 @@ import { lowerReadlineCursorCall } from "./builtins/readline.js"; import { lowerEnvironmentData, lowerReceiveMessageOnPort, + lowerUncloneable, lowerUntransferable, } from "./builtins/workers.js"; import * as ts from "../ts7/adapter.js"; @@ -117,6 +118,9 @@ export function lowerBuiltinCall( ) { return lowerUntransferable(lowerer, expr, bi.member); } + if (bi.module === "worker_threads" && bi.member === "markAsUncloneable") { + return lowerUncloneable(lowerer, expr); + } const builtinFn = builtinModuleFnOf(lowerer, bi.module, bi.member); if (!builtinFn) { lowerer.noLowering( diff --git a/packages/compiler/src/frontend/lowering/builtins/workers.ts b/packages/compiler/src/frontend/lowering/builtins/workers.ts index 1061f1389..2311e0f96 100644 --- a/packages/compiler/src/frontend/lowering/builtins/workers.ts +++ b/packages/compiler/src/frontend/lowering/builtins/workers.ts @@ -328,6 +328,25 @@ export function lowerEnvironmentData( }; } +/** markAsUncloneable(value): structuredClone and postMessage then throw + * DataCloneError for the value (scr_json.c). It needs no worker runtime. */ +export function lowerUncloneable(lowerer: Lowerer, call: ts.CallExpression): IrExpr { + const args = call.arguments; + if (args.length > 1 || args.some(ts.isSpreadElement)) { + lowerer.noLowering(`markAsUncloneable with ${args.length} arguments`, call); + } + const loc = locOf(call); + const value: IrExpr = args[0] + ? lowerer.lowerExprExpecting(args[0], DYN) + : { + kind: "dynFrom", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: DYN, + loc, + }; + return { kind: "libCall", fn: "dyn.markUncloneable", args: [value], type: VOID, loc }; +} + /** markAsUntransferable(value) and isMarkedAsUntransferable(value): marks * kept by this thread (an ArrayBuffer's on its storage). */ export function lowerUntransferable( diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 40fb951c3..6c09eeb01 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1132,6 +1132,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * (scr_worker_events.c; the value borrowed). */ "worker.markUntransferable": { argTypes: [DYN], result: VOID }, "worker.isMarkedUntransferable": { argTypes: [DYN], result: BOOL }, + /** markAsUncloneable(value) (scr_json.c; the value borrowed). */ + "dyn.markUncloneable": { argTypes: [DYN], result: VOID }, /** before new Worker() with the stdout or stderr option: links the stream * unit that worker.stdout and worker.stderr read through (scr_stream.c). */ "stream.workerStdio": { argTypes: [], result: VOID }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-worker-uncloneable.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-worker-uncloneable.mjs.json new file mode 100644 index 000000000..93337e4a0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5990-worker-uncloneable.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5990-worker-uncloneable.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5991-worker-async-dispose.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5991-worker-async-dispose.mjs.json new file mode 100644 index 000000000..bed1b18a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5991-worker-async-dispose.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5991-worker-async-dispose.mjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 08a4d79b1..84482201a 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -669,6 +669,10 @@ ScrDyn *scr_dyn_from_iterator(const ScrDyn *value, int *kind); ScrDyn *scr_dyn_iterator_result_get(const ScrDyn *result, const char *key, size_t len); void scr_dyn_install_iterator_symbol(ScrDyn *key); void scr_dyn_install_async_iterator_symbol(ScrDyn *key); +void scr_dyn_install_async_dispose_symbol(ScrDyn *key); +/* True when key is Symbol.asyncDispose (registered by the symbol unit when + * the program names it); handle symbol_get ops outside that unit use it. */ +bool scr_dyn_is_async_dispose_symbol(const ScrDyn *key); ScrDyn *scr_dyn_array_values_function(void); ScrDyn *scr_dyn_array_from_iterator(const ScrDyn *value); ScrDyn *scr_dyn_iterator_result(ScrDyn *value); @@ -956,6 +960,13 @@ ScrDyn *scr_structured_clone_transfer_fail_with(const ScrDyn *first); /* Validates a structuredClone options dyn value (throws on failure — * callers must check scr_exc_pending). Borrowed, NULL-tolerant. */ void scr_sc_validate_options(const ScrDyn *options); +/* markAsUncloneable(value) and the check structuredClone and postMessage + * make (scr_json.c); both borrow the value. scr_uncloneable_count is the + * number of marked values, so the clone paths test it inline and call + * scr_uncloneable_marked only once something is marked. */ +void scr_mark_uncloneable(ScrDyn *value); +bool scr_uncloneable_marked(const ScrDyn *value); +extern SCR_TL size_t scr_uncloneable_count; /* An %Error as the boundary's dyn shape ({name, message[, code]}). * Borrows; +1. */ ScrDyn *scr_dyn_from_error(const ScrError *e); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 6633f4840..9e443c61f 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -108,6 +108,26 @@ static void scr_async_iterator_symbol_cleanup(void) { scr_async_iterator_symbol = NULL; } +static SCR_TL ScrDyn *scr_async_dispose_symbol; + +static void scr_async_dispose_symbol_cleanup(void) { + scr_dyn_release(scr_async_dispose_symbol); + scr_async_dispose_symbol = NULL; +} + +/* Symbol.asyncDispose, registered by the symbol unit when the program + * first names it, for handle classes outside that unit (a Worker). */ +void scr_dyn_install_async_dispose_symbol(ScrDyn *key) { + if (scr_async_dispose_symbol) return; + scr_async_dispose_symbol = scr_dyn_retain(key); + scr_atexit(scr_async_dispose_symbol_cleanup); +} + +bool scr_dyn_is_async_dispose_symbol(const ScrDyn *key) { + return scr_async_dispose_symbol && key->kind == SCR_DYN_SYMBOL && + key->v.symbol.value == scr_async_dispose_symbol->v.symbol.value; +} + static void scr_to_primitive_symbol_cleanup(void) { scr_dyn_release(scr_to_primitive_symbol); scr_to_primitive_symbol = NULL; @@ -9489,6 +9509,61 @@ void scr_sc_validate_options(const ScrDyn *options) { scr_dyn_release(transferView); } +/* ── markAsUncloneable ─────────────────────────────────────────────── */ + +/* The plain objects, errors and class instances marked with + * markAsUncloneable (node:worker_threads): structuredClone and + * postMessage throw DataCloneError for them. Node's mark has no effect on + * primitives, arrays, Maps, Sets, Dates, typed arrays and ArrayBuffers. + * The marked values are retained until the thread exits, so an identity + * is never reused while marked. */ +static SCR_TL ScrDyn **scr_uncloneable; +SCR_TL size_t scr_uncloneable_count; +static SCR_TL size_t scr_uncloneable_capacity; + +static void scr_uncloneable_cleanup(void) { + for (size_t i = 0; i < scr_uncloneable_count; i++) scr_dyn_release(scr_uncloneable[i]); + free(scr_uncloneable); + scr_uncloneable = NULL; + scr_uncloneable_count = scr_uncloneable_capacity = 0; +} + +/* The identity a mark is kept under: a class instance's or a record's + * object, or a plain object's source (its own box otherwise); NULL for + * values Node does not mark. */ +static const void *scr_uncloneable_identity(const ScrDyn *value) { + if (value->kind == SCR_DYN_TYPED_REF && + ((value->v.typed_ref.type_key_len > 7 && !memcmp(value->v.typed_ref.type_key, "object:", 7)) || + (value->v.typed_ref.type_key_len > 7 && !memcmp(value->v.typed_ref.type_key, "record:", 7)))) + return value->v.typed_ref.ptr; + if (value->kind == SCR_DYN_OBJ) + return value->v.obj.source_identity ? value->v.obj.source_identity : (const void *)value; + return NULL; +} + +void scr_mark_uncloneable(ScrDyn *value) { + const void *identity = scr_uncloneable_identity(value); + if (!identity || scr_uncloneable_marked(value)) return; + if (scr_uncloneable_count == scr_uncloneable_capacity) { + if (!scr_uncloneable_capacity) scr_atexit(scr_uncloneable_cleanup); + size_t capacity = scr_uncloneable_capacity ? scr_uncloneable_capacity * 2 : 8; + ScrDyn **values = realloc(scr_uncloneable, capacity * sizeof *values); + if (!values) scr_trap("scriptc: out of memory\n"); + scr_uncloneable = values; + scr_uncloneable_capacity = capacity; + } + scr_uncloneable[scr_uncloneable_count++] = scr_dyn_retain(value); +} + +bool scr_uncloneable_marked(const ScrDyn *value) { + if (scr_uncloneable_count == 0) return false; + const void *identity = scr_uncloneable_identity(value); + if (!identity) return false; + for (size_t i = 0; i < scr_uncloneable_count; i++) + if (scr_uncloneable_identity(scr_uncloneable[i]) == identity) return true; + return false; +} + /* The parent chain rides the C stack: a revisit is a cycle. */ typedef struct ScrScParent { const ScrDyn *node; @@ -9499,6 +9574,10 @@ typedef struct ScrScParent { * throw), so it is tagged like a parse result (ScrDynRegion); a leaf the * region may not hold clears it. */ static ScrDyn *scr_sc_clone(const ScrDyn *v, const ScrScParent *up, ScrDynRegion *region) { + if (scr_uncloneable_count && scr_uncloneable_marked(v)) { + scr_throw_domex("DataCloneError", "Cannot clone object of unsupported type."); + return NULL; + } if (v->kind == SCR_DYN_PROXY) { scr_throw_domex("DataCloneError", "# could not be cloned."); return NULL; diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 31cbc7f8e..82428a6fe 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -265,6 +265,10 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) case SCR_DYN_HANDLE: identity = input->v.handle.ptr; domain = 4; break; default: break; } + if (scr_uncloneable_count && scr_uncloneable_marked(input)) { + scr_throw_domex("DataCloneError", "Cannot clone object of unsupported type."); + return SIZE_MAX; + } size_t found = scr_message_find(encoder, identity, domain); if (found != SIZE_MAX) return found; size_t id = scr_message_node(encoder); diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index ebfb56291..383b95986 100644 --- a/packages/runtime/src/scr_symbol.c +++ b/packages/runtime/src/scr_symbol.c @@ -90,6 +90,11 @@ ScrSym *scr_sym_well_known(ScrStr *name) { scr_dyn_install_async_iterator_symbol(key); scr_dyn_release(key); } + if (name->len == 12 && !memcmp(name->data, "asyncDispose", 12)) { + ScrDyn *key = scr_dyn_new_symbol(symbol); + scr_dyn_install_async_dispose_symbol(key); + scr_dyn_release(key); + } if (name->len == 11 && !memcmp(name->data, "toPrimitive", 11)) { ScrDyn *key = scr_dyn_new_symbol(symbol); scr_dyn_install_to_primitive_symbol(key); diff --git a/packages/runtime/src/scr_worker_events.c b/packages/runtime/src/scr_worker_events.c index a4a10b3b8..53041f3d5 100644 --- a/packages/runtime/src/scr_worker_events.c +++ b/packages/runtime/src/scr_worker_events.c @@ -10,6 +10,7 @@ typedef struct ScrWorkerView { ScrEmitter *events; ScrWorkerThread *thread; ScrPromise *termination; + ScrPromise *disposal; /* worker[Symbol.asyncDispose]()'s promise */ bool port; bool closed; bool referenced; @@ -130,6 +131,7 @@ static void scr_worker_view_trace(void *ptr, ScrTraceVisit visit, void *ctx) { ScrWorkerView *view = ptr; visit(view->events, ctx); visit(view->termination, ctx); + visit(view->disposal, ctx); for (int i = 0; i < 2; i++) { visit(view->stdio[i], ctx); visit(view->stdio_box[i], ctx); @@ -158,6 +160,7 @@ static void scr_worker_view_destroy(void *ptr) { ScrWorkerView *view = ptr; scr_emitter_release(view->events); scr_promise_release(view->termination); + scr_promise_release(view->disposal); for (int i = 0; i < 2; i++) { if (view->stdio[i]) scr_worker_stdio_ops->release(view->stdio[i]); scr_dyn_release(view->stdio_box[i]); @@ -457,6 +460,39 @@ static ScrDyn *scr_worker_method_call(ScrClosure *closure, ScrDyn *const *args, return result; } +/* worker[Symbol.asyncDispose](): Node's `await this.terminate()`, a + * promise of undefined once the worker exited. */ +static ScrDyn *scr_worker_async_dispose_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)closure; + (void)args; + (void)argc; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind != SCR_DYN_HANDLE || self->v.handle.tag != SCR_DYNH_WORKER) { + static const char message[] = "Illegal invocation"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_THIS"); + } else { + ScrWorkerView *view = self->v.handle.ptr; + if (!view->disposal) { + view->disposal = scr_promise_new(); + if (view->closed) scr_promise_fulfill_ref(view->disposal, + scr_dyn_retain(scr_dyn_undefined()), scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + else if (!view->termination) scr_worker_thread_terminate(view->thread); + } + result = scr_dyn_new_promise(view->disposal); + } + scr_dyn_release(self); + return result; +} + +/* worker[Symbol.asyncDispose] */ +static ScrDyn *scr_worker_symbol_get(void *ptr, const ScrDyn *key) { + (void)ptr; + if (!scr_dyn_is_async_dispose_symbol(key)) return NULL; + ScrClosure *closure = scr_closure_new((void *)&scr_worker_async_dispose_call, 0); + return scr_dyn_new_func(closure, &scr_worker_async_dispose_call, 0, "()", "[Symbol.asyncDispose]"); +} + static ScrDyn *scr_worker_view_get(void *ptr, const char *key, size_t length) { ScrWorkerView *view = ptr; if (!view->port && length == 8 && !memcmp(key, "threadId", 8)) @@ -735,6 +771,8 @@ static void scr_worker_dispatch(void) { view->closed = true; if (view->termination) scr_promise_fulfill_ref(view->termination, scr_dyn_new_num(event->code), scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + if (view->disposal) scr_promise_fulfill_ref(view->disposal, + scr_dyn_retain(scr_dyn_undefined()), scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); ScrWorkerView **slot = &scr_worker_views; while (*slot != view) slot = &(*slot)->next; *slot = view->next; @@ -819,6 +857,7 @@ static bool scr_worker_events_install(void) { static const ScrDynHandleOps worker_ops = { .cls = "Worker", .retain = scr_worker_view_retain, .release = scr_worker_view_release, .invoke = scr_worker_view_invoke, .get = scr_worker_view_get, + .symbol_get = scr_worker_symbol_get, }; static const ScrDynHandleOps port_ops = { .cls = "MessagePort", .retain = scr_worker_view_retain, .release = scr_worker_view_release, diff --git a/tests/corpus/5990-worker-uncloneable.mjs b/tests/corpus/5990-worker-uncloneable.mjs new file mode 100644 index 000000000..5960a18bb --- /dev/null +++ b/tests/corpus/5990-worker-uncloneable.mjs @@ -0,0 +1,59 @@ +// markAsUncloneable: structuredClone and postMessage throw DataCloneError +// for a marked object, error or class instance, also nested in an object; +// the mark has no effect on primitives, arrays and typed arrays. +import { markAsUncloneable, MessageChannel, isInternalThread } from "node:worker_threads"; + +console.log("isInternalThread", isInternalThread); +const { port1, port2 } = new MessageChannel(); +port2.on("message", (m) => console.log("received", JSON.stringify(m))); + +class Point { + constructor(x) { + this.x = x; + } +} +const cases = { + object: { a: 1 }, + error: new Error("e"), + instance: new Point(1), + array: [1, 2], + bytes: new Uint8Array(2), +}; +for (const [name, value] of Object.entries(cases)) { + markAsUncloneable(value); + let cloned; + let posted; + try { + structuredClone(value); + cloned = "cloned"; + } catch (e) { + cloned = `${e.name} ${e.message} ${e.code}`; + } + try { + port1.postMessage(value); + posted = "posted"; + } catch (e) { + posted = `${e.name} ${e.message}`; + } + console.log(name, "|", cloned, "|", posted); +} +markAsUncloneable(5); +markAsUncloneable("text"); +markAsUncloneable(undefined); +console.log("primitives", structuredClone(5), structuredClone("text")); +const marked = { inner: true }; +markAsUncloneable(marked); +markAsUncloneable(marked); +try { + structuredClone({ nested: marked }); +} catch (e) { + console.log("nested", e.name, e.message); +} +try { + port1.postMessage({ nested: marked }); +} catch (e) { + console.log("nested post", e.name, e.message); +} +const copy = { ...marked }; +console.log("spread copy", JSON.stringify(structuredClone(copy))); +setTimeout(() => port1.close(), 50); diff --git a/tests/corpus/5991-worker-async-dispose.mjs b/tests/corpus/5991-worker-async-dispose.mjs new file mode 100644 index 000000000..94ee22036 --- /dev/null +++ b/tests/corpus/5991-worker-async-dispose.mjs @@ -0,0 +1,25 @@ +// worker[Symbol.asyncDispose]() terminates the worker and resolves with +// undefined after its 'exit' event; `await using` disposes a Worker at the +// end of its block. +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +if (isMainThread) { + const worker = new Worker(new URL(import.meta.url)); + const dispose = worker[Symbol.asyncDispose]; + console.log(typeof dispose, dispose.name, dispose.length); + worker.on("exit", (code) => console.log("exit", code)); + await new Promise((resolve) => worker.once("message", resolve)); + const result = await worker[Symbol.asyncDispose](); + console.log("disposed", result); + console.log("again", await worker[Symbol.asyncDispose]()); + { + await using scoped = new Worker(new URL(import.meta.url)); + scoped.on("exit", (code) => console.log("scoped exit", code)); + await new Promise((resolve) => scoped.once("message", resolve)); + console.log("leaving block"); + } + console.log("after block"); +} else { + parentPort.postMessage("ready"); + setInterval(() => {}, 1000); +} diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index c74808ab3..addb2062c 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1899,6 +1899,8 @@ "5970-net-connect-host-name.ts": 2400, "5971-buffer-chunks-to-string.ts": 2700, "5972-net-set-encoding-listeners.ts": 2550, + "5990-worker-uncloneable.mjs": 3283, + "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, @@ -4583,6 +4585,8 @@ "5970-net-connect-host-name.ts": 2800, "5971-buffer-chunks-to-string.ts": 3100, "5972-net-set-encoding-listeners.ts": 3000, + "5990-worker-uncloneable.mjs": 22432, + "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, From 6d80207e4c0225518a28735ee41a5e6de427ed92 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 04:07:51 -0500 Subject: [PATCH 04/17] Convert errors held as any with Error.prototype.toString Errors that reach checked-dynamic code are objects carrying the reserved "%error" marker, with name and message either own or on their prototype. Several string conversions handled them differently from Node: - `"" + error`, `error + ""` and `error == "TypeError: ..."` converted through the plain-object path and answered "[object Object]". - Template literals and Array join read name and message as own members only, so a builtin error whose name is inherited printed its message alone. - String(error) used the native error, which did not see name and message assigned through the dynamic view. scr_dyn_error_string implements Error.prototype.toString over the encoding, reading both members through the prototype, and the template renderer, Array join, ToPrimitive, ToString and the numeric conversion use it. Assigning a string name or message through the dynamic view now also updates the native error, as assigning stack and cause already did. --- packages/compiler/src/backend/llvm/dyn.ts | 89 +++---------------- .../5915-dyn-error-string-conversions.ts.json | 6 ++ packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_dyn_invoke.c | 11 +-- packages/runtime/src/scr_json.c | 74 ++++++++++++++- .../5915-dyn-error-string-conversions.ts | 48 ++++++++++ tests/harness/corpus-costs.json | 2 + 7 files changed, 147 insertions(+), 84 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5915-dyn-error-string-conversions.ts.json create mode 100644 tests/corpus/5915-dyn-error-string-conversions.ts diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 86a8a945e..d16d8244c 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -3209,82 +3209,19 @@ export class LlDyn { this.puts(B, "%b", "[object Object]"); B.br(done); B.startBlock(lErr); - const en = this.objGetLit(B, "%d", "name"); - const em = this.objGetLit(B, "%d", "message"); - // ens/ems: the member's ScrStr when it is a string, else null. - const strOf = (m: string, hint: string): string => { - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); - B.line(`store ptr null, ptr ${slot}`); - const nn = B.tmp(); - B.line(`${nn} = icmp ne ptr ${m}, null`); - const lt = B.newLabel(`${hint}.t`); - const lj = B.newLabel(`${hint}.j`); - B.condBr(nn, lt, lj); - B.startBlock(lt); - const k = this.kindOf(B, m); - const isStr = B.tmp(); - B.line(`${isStr} = icmp eq i32 ${k}, ${DYN_KIND.STR}`); - const ls = B.newLabel(`${hint}.s`); - B.condBr(isStr, ls, lj); - B.startBlock(ls); - const sv = this.payloadOf(B, m, "ptr"); - B.line(`store ptr ${sv}, ptr ${slot}`); - B.br(lj); - B.startBlock(lj); - const out = B.tmp(); - B.line(`${out} = load ptr, ptr ${slot}`); - return out; - }; - const ens = strOf(en, "ds.en"); - const ems = strOf(em, "ds.em"); - const putIf = (s: string, hint: string): void => { - const nn = B.tmp(); - B.line(`${nn} = icmp ne ptr ${s}, null`); - const lt = B.newLabel(`${hint}.t`); - const lj = B.newLabel(`${hint}.j`); - B.condBr(nn, lt, lj); - B.startBlock(lt); - this.putScrStr(B, "%b", s); - B.br(lj); - B.startBlock(lj); - }; - putIf(ens, "ds.pn"); - { - // if (ens && ens->len && ems && ems->len) ": " - const lenNz = (s: string, hint: string): string => { - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca i1`); - B.line(`store i1 false, ptr ${slot}`); - const nn = B.tmp(); - B.line(`${nn} = icmp ne ptr ${s}, null`); - const lt = B.newLabel(`${hint}.t`); - const lj = B.newLabel(`${hint}.j`); - B.condBr(nn, lt, lj); - B.startBlock(lt); - const { len } = this.strParts(B, s); - const nz = B.tmp(); - B.line(`${nz} = icmp ne ${this.S} ${len}, 0`); - B.line(`store i1 ${nz}, ptr ${slot}`); - B.br(lj); - B.startBlock(lj); - const out = B.tmp(); - B.line(`${out} = load i1, ptr ${slot}`); - return out; - }; - const a = lenNz(ens, "ds.ln"); - const b2 = lenNz(ems, "ds.lm"); - const both = B.tmp(); - B.line(`${both} = and i1 ${a}, ${b2}`); - const lt = B.newLabel("ds.cl"); - const lj = B.newLabel("ds.cj"); - B.condBr(both, lt, lj); - B.startBlock(lt); - this.puts(B, "%b", ": "); - B.br(lj); - B.startBlock(lj); - } - putIf(ems, "ds.pm"); + // Error.prototype.toString, reading name and message through the + // prototype. Null leaves the exception its conversion threw pending. + host.declare(`declare ptr @scr_dyn_error_string(ptr)`); + host.declare(`declare void @scr_str_release(ptr)`); + const text = B.tmp(); + B.line(`${text} = call ptr @scr_dyn_error_string(ptr %d)`); + const written = B.tmp(); + B.line(`${written} = icmp ne ptr ${text}, null`); + const lPut = B.newLabel("ds.ep"); + B.condBr(written, lPut, done); + B.startBlock(lPut); + this.putScrStr(B, "%b", text); + B.line(`call void @scr_str_release(ptr ${text})`); B.br(done); } B.startBlock(labels.get(DYN_KIND.BYTES)!); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5915-dyn-error-string-conversions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5915-dyn-error-string-conversions.ts.json new file mode 100644 index 000000000..032b5c5ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5915-dyn-error-string-conversions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5915-dyn-error-string-conversions.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 08a4d79b1..d47ed66a3 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -890,6 +890,7 @@ bool scr_dyn_same_value(const ScrDyn *left, const ScrDyn *right); ScrDyn *scr_dyn_builtin_method(const ScrStr *prototype, const ScrStr *method); ScrStr *scr_dyn_to_string_argument(const ScrDyn *d, const ScrDyn *argument, const ScrStr *what); ScrDyn *scr_dyn_value_of(const ScrDyn *d, const ScrStr *what); +ScrStr *scr_dyn_error_string(const ScrDyn *d); ScrStr *scr_num_to_string_radix(double value, const ScrDyn *radix); /* JS String() over the dyn kind (units render "null"/"undefined" where * scr_dyn_to_string throws) — the web globals' WebIDL ToString. +1. */ diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 59cf34a10..a3085399f 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -82,13 +82,10 @@ static void scr_dyn_display_buf(ScrJsonBuf *b, const ScrDyn *d) { case SCR_DYN_OBJ: { const ScrDyn *marker = scr_dyn_obj_get(d, "%error", 6); if (marker) { - const ScrDyn *en = scr_dyn_obj_get(d, "name", 4); - const ScrDyn *em = scr_dyn_obj_get(d, "message", 7); - const ScrStr *ens = (en && en->kind == SCR_DYN_STR) ? en->v.str : NULL; - const ScrStr *ems = (em && em->kind == SCR_DYN_STR) ? em->v.str : NULL; - if (ens) scr_jb_write(b, ens->data, ens->len); - if (ens && ens->len && ems && ems->len) scr_jb_puts(b, ": "); - if (ems) scr_jb_write(b, ems->data, ems->len); + ScrStr *text = scr_dyn_error_string(d); + if (!text) return; + scr_jb_write(b, text->data, text->len); + scr_str_release(text); return; } scr_jb_puts(b, "[object Object]"); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 87a2776a3..e82485124 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -5947,11 +5947,28 @@ void scr_errdyn_put(ScrError *e, ScrDyn *d) { scr_errdyn_append(e, d); } static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key) { bool cause = key->len == 5 && memcmp(key->data, "cause", 5) == 0; bool stack = key->len == 5 && memcmp(key->data, "stack", 5) == 0; - if (!cause && !stack) return; + bool name = key->len == 4 && memcmp(key->data, "name", 4) == 0; + bool message = key->len == 7 && memcmp(key->data, "message", 7) == 0; + if (!cause && !stack && !name && !message) return; size_t hit = scr_errdyn_find(view, false); if (hit == SIZE_MAX) return; ScrDynEntry *entry = scr_dyn_entry(view, key); ScrError *e = scr_errdyn_cache[hit].err; + if (name || message) { + /* The native error's toString and inspect read these fields. */ + if (!entry || entry->accessor || entry->value->kind != SCR_DYN_STR) return; + ScrStr **field = name ? &e->name : &e->message; + scr_str_release(*field); + *field = scr_str_retain(entry->value->v.str); + if (name) { + e->name_present = true; + e->name_enumerable = entry->enumerable; + } else { + e->message_present = true; + e->message_enumerable = entry->enumerable; + } + return; + } if (stack) { if (entry && entry->value->kind == SCR_DYN_STR) { scr_str_release(e->stack); @@ -5967,6 +5984,46 @@ static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key) { e->cause_enumerable = entry && entry->enumerable; } +/* Error.prototype.toString over the checked-dynamic error encoding (an + * object carrying the reserved "%error" marker): `name` defaults to "Error" + * and `message` to "", both read through the prototype; either empty leaves + * the other alone, otherwise "name: message". NULL with a pending exception + * when a member's string conversion throws. */ +ScrStr *scr_dyn_error_string(const ScrDyn *d) { + ScrStr *parts[2] = { NULL, NULL }; + static const char *const keys[2] = { "name", "message" }; + for (int i = 0; i < 2; i++) { + ScrDyn *member = scr_dyn_obj_read(d, keys[i], strlen(keys[i])); + if (!member || member->kind == SCR_DYN_UNDEF) { + parts[i] = i == 0 ? scr_str_new("Error", 5) : scr_str_new("", 0); + } else { + parts[i] = scr_dyn_string_coerce_js(member); + } + scr_dyn_release(member); + if (!parts[i]) { + scr_str_release(parts[0]); + return NULL; + } + } + if (parts[0]->len == 0 || parts[1]->len == 0) { + ScrStr *out = parts[0]->len == 0 ? parts[1] : parts[0]; + scr_str_release(out == parts[0] ? parts[1] : parts[0]); + return out; + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_str(&b, parts[0]); + scr_jb_puts(&b, ": "); + scr_jb_put_str(&b, parts[1]); + scr_str_release(parts[0]); + scr_str_release(parts[1]); + return scr_jb_finish(&b); +} + +static bool scr_dyn_error_encoding(const ScrDyn *d) { + return d->kind == SCR_DYN_OBJ && scr_dyn_obj_get(d, "%error", 6) != NULL; +} + /* Receiver-kind-dispatched toString() on a checked-dynamic value (the * dyn method surface — a stream's 'data'/for-await chunk is the common * receiver): bytes decode per the encoding (Node's Buffer.toString, @@ -6002,6 +6059,7 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { case SCR_DYN_BOOL: return d->v.b ? scr_str_new("true", 4) : scr_str_new("false", 5); case SCR_DYN_OBJ: + if (scr_dyn_error_encoding(d) && !scr_dyn_property_owner(d, "toString", 8)) return scr_dyn_error_string(d); return scr_str_new("[object Object]", 15); case SCR_DYN_HANDLE: if (d->v.handle.tag == SCR_DYNH_REGEXP || d->v.handle.tag == SCR_DYNH_URL) { @@ -6396,6 +6454,13 @@ bool scr_dyn_number_coerce_js(const ScrDyn *d, double *out) { * number-hint protocol advances to toString. */ continue; } + if (i == 1 && scr_dyn_error_encoding(d)) { + ScrStr *text = scr_dyn_error_string(d); + if (!text) return false; + *out = scr_string_to_number(text); + scr_str_release(text); + return true; + } if (scr_dyn_has_object_prototype(d) && i == 1) { *out = NAN; /* Number("[object Object]") */ return true; @@ -6511,6 +6576,13 @@ static ScrDyn *scr_dyn_add_primitive(const ScrDyn *d) { const ScrDyn *owner = scr_dyn_property_owner(d, methods[i], strlen(methods[i])); ScrDyn *method = owner ? scr_dyn_obj_read(d, methods[i], strlen(methods[i])) : NULL; if (!method) { + if (i == 1 && scr_dyn_error_encoding(d)) { + ScrStr *str = scr_dyn_error_string(d); + if (!str) return NULL; + ScrDyn *out = scr_dyn_new_str(str); + scr_str_release(str); + return out; + } if (i == 1 && scr_dyn_has_object_prototype(d)) { ScrStr *str = scr_str_new("[object Object]", 15); ScrDyn *out = scr_dyn_new_str(str); diff --git a/tests/corpus/5915-dyn-error-string-conversions.ts b/tests/corpus/5915-dyn-error-string-conversions.ts new file mode 100644 index 000000000..4e56b5c01 --- /dev/null +++ b/tests/corpus/5915-dyn-error-string-conversions.ts @@ -0,0 +1,48 @@ +// Errors held as `any` convert to strings through Error.prototype.toString +// in every dynamic conversion: template literals, `+` with a string on +// either side, String(), Array join, loose equality, Number() and property +// keys. The name is read through the prototype ("Error", "TypeError", a +// subclass's own name), an empty name or message leaves the other alone, +// and a subclass without its own name inherits "Error". + +class NamedError extends Error { + constructor(message: string) { + super(message); + this.name = "NamedError"; + } +} + +class PlainSubclass extends Error {} + +function show(label: string, error: any): void { + console.log(label, "|", `${error}`, "|", "" + error, "|", error + "!", "|", String(error), "|", [error, 1].join(" ")); +} + +show("error", new Error("plain")); +show("type", new TypeError("bad thing")); +show("range", new RangeError("too far")); +show("syntax", new SyntaxError("unexpected")); +show("named", new NamedError("mine")); +show("subclass", new PlainSubclass("inherited name")); +show("empty message", new TypeError("")); +show("no message", new Error()); + +const renamed: any = new Error("message only"); +renamed.name = ""; +show("empty name", renamed); + +const changed: any = new RangeError("before"); +changed.message = "after"; +show("changed message", changed); + +const numeric: any = new Error("42"); +numeric.name = ""; +const unnumbered: any = new TypeError("x"); +console.log("Number", Number(numeric), Number(unnumbered), +numeric); + +const type: any = new TypeError("bad thing"); +console.log("loose equality", type == "TypeError: bad thing", type == "bad thing"); + +const keyed: any = {}; +keyed[type] = 1; +console.log("key", Object.keys(keyed).join(",")); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 7f89295df..ddd89cbe8 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1886,6 +1886,7 @@ "5912-dyn-class-conversion-methods.ts": 3251, "5913-prototype-type-error-messages.ts": 2484, "5914-dyn-in-class-prototype-members.ts": 3169, + "5915-dyn-error-string-conversions.ts": 3200, "5922-matchall-groups-callbacks.ts": 3650, "5923-matchall-groups-js.js": 3970, "5939-js-let-object-reassigned-wider.js": 1945, @@ -4570,6 +4571,7 @@ "5912-dyn-class-conversion-methods.ts": 4424, "5913-prototype-type-error-messages.ts": 4068, "5914-dyn-in-class-prototype-members.ts": 4327, + "5915-dyn-error-string-conversions.ts": 4247, "5922-matchall-groups-callbacks.ts": 5960, "5923-matchall-groups-js.js": 6650, "5939-js-let-object-reassigned-wider.js": 4436, From 58096a768c9be27a95f671b4b223ad4ac5911b73 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 04:19:12 -0500 Subject: [PATCH 05/17] Define scr_uncloneable_count with an initializer packages/runtime/test/common-symbols.test.ts requires runtime globals to be definitions, not tentative definitions that some toolchains emit as common symbols; scr_uncloneable_count now starts at 0 explicitly. --- packages/runtime/src/scr_json.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 9e443c61f..c3aab4dfb 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -9518,7 +9518,7 @@ void scr_sc_validate_options(const ScrDyn *options) { * The marked values are retained until the thread exits, so an identity * is never reused while marked. */ static SCR_TL ScrDyn **scr_uncloneable; -SCR_TL size_t scr_uncloneable_count; +SCR_TL size_t scr_uncloneable_count = 0; static SCR_TL size_t scr_uncloneable_capacity; static void scr_uncloneable_cleanup(void) { From c88981a46689e59a28f885ef8089a081a033cfa6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 04:32:24 -0500 Subject: [PATCH 06/17] Describe why mock.timers' tick leaves a fired timer queued scr_mt_tick removes a fired entry only while it is still the queue's first entry, as Node's MockTimers.tick does. A callback that queues an entry sorting before it, such as an immediate due at now - 1, leaves the fired timer queued, so it runs again, as it does in Node. The comment now says so and names the fixture that checks the order against Node. --- packages/runtime/src/scr_test.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 6388857a6..f8bca0cfa 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -1305,7 +1305,11 @@ static void scr_mt_tick(ScrMockTimers *m, double time) { scr_mt_unref(timer); return; } - /* A callback that cleared its own timer already removed it. */ + /* Node's tick() removes the fired entry only while it is still first + * in the queue: a callback that cleared its own timer already removed + * it, and one that queued an entry sorting before it (an immediate, + * due at now - 1) leaves it queued, so it runs again, as in Node. The + * mock-timers-date fixture checks that order against Node. */ ScrMockTimer *after = scr_mt_peek(m); if (after && after->id == timer->id) { timer->pos = 0; From 4fe392cd2507fa898098c4137581939312b644d6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:15:17 -0500 Subject: [PATCH 07/17] Return a worker's memory once it and the other workers are done On Linux, a worker thread that exits without having made any object it allocated reachable from another thread gives back all of its memory: the pages it carved, including ProgramLifetime instances and other objects it never freed, and the system blocks it still holds. The memory goes back once no other worker thread is running, so that returning it does not slow the workers that are still running. A worker carves past each slice's frontier while the arena's free lists and returned regions are set aside, takes from those free lists only once a slice has no bump room left, and records the system blocks it allocates. Messages and intern tables that share published graphs by pointer, and the process-wide key arrays of typed records, mark the thread as sharing; so does a remote free of one of its blocks. A worker that did not share parks with its arena claimed and its discard queued: returning pages makes the kernel interrupt every running thread of the process to flush its TLB, and glibc's trim locks the arenas other threads allocate from. The last worker to park starts one helper that returns each queued worker's pages, frees its system blocks, and restores its arena to its state at the claim, with the free blocks the worker took free again. A thread that finds every arena claimed runs the queued discards itself. A worker that shared keeps its memory as before. SCR_SA_DISCARD=0 keeps every worker's memory; SCR_SA_DISCARD=poison makes the discarded pages inaccessible instead, for tests. --- docs/content/docs/contracts.mdx | 2 +- docs/content/docs/threads.mdx | 2 + .../native-worker-discarded-heaps.ts.json | 6 + packages/runtime/src/scr_alloc.c | 487 +++++++++++++++++- packages/runtime/src/scr_alloc.test.c | 327 ++++++++++++ packages/runtime/src/scr_itable.c | 3 + packages/runtime/src/scr_itable.test.c | 10 +- packages/runtime/src/scr_json.c | 1 + packages/runtime/src/scr_message.c | 3 + packages/runtime/src/scr_rc.h | 9 +- tests/corpus/native-worker-discarded-heaps.ts | 102 ++++ tests/harness/corpus-costs.json | 2 + 12 files changed, 936 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-discarded-heaps.ts.json create mode 100644 tests/corpus/native-worker-discarded-heaps.ts diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index 11737da59..d1bfb8cad 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -331,7 +331,7 @@ $ scriptc build graph.ts ## ProgramLifetime -`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are never freed, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed. +`ProgramLifetime` marks a class whose instances a program keeps until it exits, such as the nodes of a syntax tree, the symbols of a symbol table or interned types. scriptc then never reference counts, collects or frees instances of the class and of its subclasses: they are never freed, every retain and release on them compiles to nothing, and the cycle collector neither walks them nor needs to trace the objects that point to them. Node.js behavior is unchanged; only memory use differs, because an instance the program drops is never reclaimed. On Linux, the instances a worker thread creates are returned with the rest of its memory after it exits, unless the worker shared objects with other threads (see [cost](/docs/threads#cost)). ```ts:tree.ts import type { ProgramLifetime } from "@scriptc/types"; diff --git a/docs/content/docs/threads.mdx b/docs/content/docs/threads.mdx index 7b394e09f..6f9b5f062 100644 --- a/docs/content/docs/threads.mdx +++ b/docs/content/docs/threads.mdx @@ -234,6 +234,8 @@ Without `@scriptc/threads`, a scriptc executable also shares a message of untype Starting a worker thread takes tens of microseconds plus the worker's own module initialization, since every worker initializes the program's modules again, as in Node.js. A worker that exits frees its heap first, which takes time proportional to what it still holds. `SCRIPTC_WORKER_STATS=1` prints, as each worker exits, how long it took to start, to run its script and to exit. +On Linux, a worker that exits without having shared an object it allocated with another thread returns all of its memory, including objects it never freed, such as `ProgramLifetime` instances, once no other worker thread is running. A worker shares objects when, for example, it sends a published value or frozen plain data, which other threads read by reference, or stores a value in an `InternTable`. The memory of a worker that shared stays allocated, as on other platforms. Set `SCR_SA_DISCARD=0` in the environment to keep the memory of every worker. + `publish` visits each object of the graph once and marks it immortal, so publication takes time proportional to the graph. Published objects then cost nothing on later sends, and reference counting and the cycle collector skip them. Stores into the types a program publishes carry one extra load and branch. Threads that add to one shared collection of published values while they run can use an [`InternTable`](/docs/experimental#interntable) from `@scriptc/experimental`. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-discarded-heaps.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-discarded-heaps.ts.json new file mode 100644 index 000000000..b7dd8fd17 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/native-worker-discarded-heaps.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/native-worker-discarded-heaps.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 76d056d7c..2212a1a36 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -630,6 +630,14 @@ static bool scr_sa_reserve_region(void) { * scr_sa_trim). */ static size_t scr_sa_claim_carved[SCR_SA_ARENAS]; static size_t scr_sa_carved(void); +/* A worker thread's memory, discarded when it parks (see "Discarding a + * worker's memory" below). */ +typedef struct ScrSaKeep ScrSaKeep; +static _Thread_local bool scr_sa_worker; /* set by scr_sa_thread_starting */ +static _Thread_local ScrSaKeep *scr_sa_keep; /* while the worker's memory is kept apart */ +static void scr_sa_keep_begin(unsigned i); +static void scr_sa_keep_drained(ScrSaKeep *k, ScrSaBlock *b, unsigned c); +static bool scr_sa_drain(void); #endif /* Moves every block other threads freed into this arena's free lists. */ @@ -638,6 +646,13 @@ static void scr_sa_drain_remote(void) { while (b) { ScrSaBlock *next = b->next; unsigned c = (unsigned)(((uintptr_t)b - scr_sa.base) >> scr_sa.shift); +#ifdef SCR_SA_TRIM + if (scr_sa_keep) { + scr_sa_keep_drained(scr_sa_keep, b, c); + b = next; + continue; + } +#endif b->next = scr_sa.free[c]; scr_sa.free[c] = b; b = next; @@ -655,6 +670,15 @@ static bool scr_sa_reserve(void) { uint32_t claimed = atomic_load_explicit(&scr_sa_claimed, memory_order_relaxed); for (;;) { if (claimed == UINT32_MAX) { +#ifdef SCR_SA_TRIM + /* Arenas held by discards that wait (see scr_sa_thread_park): this + * thread discards them and claims one. */ + if (scr_sa_drain()) { + scr_sa_refused = false; + claimed = atomic_load_explicit(&scr_sa_claimed, memory_order_relaxed); + continue; + } +#endif scr_sa_refused = true; return false; } @@ -672,6 +696,7 @@ static bool scr_sa_reserve(void) { scr_sa_drain_remote(); #ifdef SCR_SA_TRIM scr_sa_claim_carved[i] = scr_sa_carved(); + if (scr_sa_worker) scr_sa_keep_begin(i); #endif return true; } @@ -884,6 +909,312 @@ static void scr_sa_trim(void) { scr_sa_arena, (int)trim, scr_sa_trim_stats[0] >> 20, scr_sa_trim_stats[1] >> 20, scr_sa_trim_stats[2] >> 20); } + +/* Discarding a worker's memory (Linux). Script objects stay on the thread + * that allocated them: other threads reach one only through a message or an + * intern table that shares a published (immortal) graph by pointer, or a + * process-wide immortal cache, and each of those calls + * scr_sa_thread_shares. Runtime state that outlives a thread (messages, + * mailboxes, shared bytes, intern table entries) comes from the system + * allocator directly. So when a worker thread parks without having shared, + * nothing it allocated through scr_mem_* can be reachable from another + * thread, and what it never freed (ProgramLifetime instances, published + * module constants) is garbage. Such a thread's memory goes back whole: + * - when a worker claims an arena, the arena's free lists and returned + * regions are set aside, and the worker carves only past each slice's + * frontier, so its blocks of class c are [start[c], bump[c]), plus the + * blocks it takes from the free lists set aside once a slice has no bump + * room left, which it records; + * - the system blocks it allocates (large blocks, an exhausted slice) are + * recorded until it frees them; + * - a remote free into its blocks means another thread held one: the same + * as sharing; + * - when it parks without having shared, its arena stays claimed until no + * other worker thread is running (scr_sa_thread_park): returning pages + * interrupts every running thread of the process to flush its TLB, and + * glibc's trim locks the arenas other threads allocate from, so discards + * wait for the last worker of a group, which hands them to one helper; + * - the helper then returns the pages + * it carved (MADV_DONTNEED; the start of the first page, which it may + * share with older blocks, is cleared), frees the recorded system blocks, + * and parks the arena as the worker found it, with the blocks it took + * free again. A worker that shared parks + * its arena with the blocks set aside added to its free lists, and the + * ordinary trim applies. + * SCR_SA_DISCARD=0 keeps every worker's memory. SCR_SA_DISCARD=poison makes + * the carved pages inaccessible instead, and keeps the system blocks, so + * that a reference to discarded memory faults (a check for tests). */ +#define SCR_SA_SYS_GONE ((void *)1) /* a removed entry of ScrSaKeep.sys */ + +struct ScrSaKeep { + /* The arena as the worker claimed it: free lists (to which remote frees + * of older blocks are added), carve cursors and returned regions. */ + ScrSaBlock *free[SCR_SA_NCLASS]; + char *bump[SCR_SA_NCLASS], *lim[SCR_SA_NCLASS]; + ScrSaHoles holes[SCR_SA_NCLASS]; + /* Each slice's frontier then (bump and limit, NULL limits for a slice not + * opened yet), and the first address the worker carves in it. */ + char *front_bump[SCR_SA_NCLASS], *front_lim[SCR_SA_NCLASS]; + char *start[SCR_SA_NCLASS]; + /* The worker's system blocks: an open-addressing set of `cap` slots (a + * power of two, or 0), NULL for an empty slot and SCR_SA_SYS_GONE for a + * removed entry; `used` counts both kinds of filled slots. */ + void **sys; + size_t cap, used, live; + /* The blocks of the free lists set aside that the worker took once a slice + * had no bump room left, so that older free blocks are used before the + * slice grows; they go back to those lists with the worker's memory. */ + ScrSaBlock **taken; + size_t ntaken, cap_taken; + bool shared; + bool poison; +}; + +static _Thread_local bool scr_sa_discarded; /* this thread's memory went back */ +static ScrSaKeep *scr_sa_parked_keep[SCR_SA_ARENAS]; /* of discards that wait */ + +/* SCR_SA_DISCARD: 0 off, 1 on (the default), 2 poison. */ +static int scr_sa_discard_mode(void) { + const char *mode = getenv("SCR_SA_DISCARD"); + if (!mode) return 1; + if (strcmp(mode, "0") == 0) return 0; + return strcmp(mode, "poison") == 0 ? 2 : 1; +} + +static void scr_sa_keep_begin(unsigned i) { + int mode = scr_sa_discard_mode(); + if (!mode) return; + ScrSaKeep *k = calloc(1, sizeof *k); + if (!k) return; + k->poison = mode == 2; + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + ScrSaHoles *h = &scr_sa_holes[i][c]; + k->free[c] = scr_sa.free[c]; + k->bump[c] = scr_sa.bump[c]; + k->lim[c] = scr_sa.lim[c]; + k->holes[c] = *h; + memset(h, 0, sizeof *h); + scr_sa.free[c] = NULL; + /* While the slice carves a returned region, its frontier is kept apart. */ + if (k->holes[c].lim) { + scr_sa.bump[c] = k->holes[c].bump; + scr_sa.lim[c] = k->holes[c].lim; + } + k->front_bump[c] = scr_sa.bump[c]; + k->front_lim[c] = scr_sa.lim[c]; + k->start[c] = scr_sa.lim[c] ? scr_sa.bump[c] : (char *)scr_sa.base + ((size_t)c << scr_sa.shift); + } + scr_sa_keep = k; +} + +/* A remote free of class c, drained while the worker keeps its memory + * apart: one of its own blocks makes it share; an older block goes to the + * free lists set aside. Once the worker has taken blocks from those lists, + * any older block might be one of them, so it shares as well. */ +static void scr_sa_keep_drained(ScrSaKeep *k, ScrSaBlock *b, unsigned c) { + if ((char *)b >= k->start[c] && (char *)b < scr_sa.bump[c]) { + k->shared = true; + b->next = scr_sa.free[c]; + scr_sa.free[c] = b; + } else { + if (k->ntaken) k->shared = true; + b->next = k->free[c]; + k->free[c] = b; + } +} + +/* A block of class c from the free lists set aside, for a worker whose + * slice has no bump room left; NULL when there is none or it cannot be + * recorded. */ +static ScrSaBlock *scr_sa_keep_take(ScrSaKeep *k, unsigned c) { + ScrSaBlock *b = k->free[c]; + if (!b) return NULL; + if (k->ntaken == k->cap_taken) { + size_t cap = k->cap_taken ? 2 * k->cap_taken : 256; + ScrSaBlock **taken = realloc(k->taken, cap * sizeof *taken); + if (!taken) return NULL; + k->taken = taken; + k->cap_taken = cap; + } + k->free[c] = b->next; + k->taken[k->ntaken++] = b; + return b; +} + +static size_t scr_sa_sys_slot(const void *p, size_t cap) { + return (size_t)((((uint64_t)(uintptr_t)p >> 4) * UINT64_C(0x9E3779B97F4A7C15)) >> 32) & (cap - 1); +} + +/* Records system block p. A worker whose set cannot grow keeps its memory. */ +static void scr_sa_keep_add(ScrSaKeep *k, void *p) { + if ((k->used + 1) * 2 > k->cap) { + size_t cap = 64; + while (cap < (k->live + 1) * 4) cap *= 2; + void **sys = calloc(cap, sizeof *sys); + if (!sys) { + k->shared = true; + return; + } + for (size_t s = 0; s < k->cap; s++) { + void *q = k->sys[s]; + if (!q || q == SCR_SA_SYS_GONE) continue; + size_t j = scr_sa_sys_slot(q, cap); + while (sys[j]) j = (j + 1) & (cap - 1); + sys[j] = q; + } + free(k->sys); + k->sys = sys; + k->cap = cap; + k->used = k->live; + } + size_t j = scr_sa_sys_slot(p, k->cap); + while (k->sys[j] && k->sys[j] != SCR_SA_SYS_GONE) j = (j + 1) & (k->cap - 1); + if (!k->sys[j]) k->used++; + k->sys[j] = p; + k->live++; +} + +/* Forgets system block p; whether it was recorded. */ +static bool scr_sa_keep_remove(ScrSaKeep *k, const void *p) { + if (!k->live) return false; + for (size_t j = scr_sa_sys_slot(p, k->cap); k->sys[j]; j = (j + 1) & (k->cap - 1)) { + if (k->sys[j] != p) continue; + k->sys[j] = SCR_SA_SYS_GONE; + k->live--; + return true; + } + return false; +} + +/* The calling thread's arena keeps the worker's memory (it shared): the + * free lists set aside go back, followed by the worker's own free blocks, + * and so do the returned regions, past which the worker carved. */ +static void scr_sa_keep_merge(ScrSaKeep *k) { + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + ScrSaBlock *kept = k->free[c]; + if (kept) { + ScrSaBlock *tail = kept; + while (tail->next) tail = tail->next; + tail->next = scr_sa.free[c]; + scr_sa.free[c] = kept; + } + ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + *h = k->holes[c]; + if (h->lim) { + h->bump = scr_sa.bump[c]; + h->lim = scr_sa.lim[c]; + scr_sa.bump[c] = k->bump[c]; + scr_sa.lim[c] = k->lim[c]; + } + } + free(k->sys); + free(k->taken); + free(k); +} + +/* Returns the memory of the worker whose arena the calling thread (the + * trim helper) holds, and restores the arena to its state at the worker's + * claim; with poison, makes the pages inaccessible and moves the frontier + * past them instead. */ +static void scr_sa_discard(ScrSaKeep *k) { + /* Remote frees since the worker's last drain. */ + scr_sa_keep = k; + scr_sa_drain_remote(); + scr_sa_keep = NULL; + if (k->shared) { + scr_sa_keep_merge(k); + scr_sa_trim(); + return; + } + uintptr_t page = (uintptr_t)sysconf(_SC_PAGESIZE); + if (page == 0 || page == (uintptr_t)-1) page = 4096; + size_t returned = 0, sys_bytes = 0, sys_blocks = 0; + for (unsigned c = 0; c < SCR_SA_NCLASS; c++) { + char *lo = k->start[c], *top = scr_sa.bump[c], *lim = scr_sa.lim[c]; + char *bump = k->front_bump[c], *limit = k->front_lim[c]; + if (lim && top > lo) { + char *first = (char *)(((uintptr_t)lo + page - 1) & ~(page - 1)); + char *used = (char *)(((uintptr_t)top + page - 1) & ~(page - 1)); + /* The part of a page that may also hold older blocks. */ + if (first > lo) memset(lo, 0, (size_t)((first < top ? first : top) - lo)); + if (k->poison) { + if (used > first) (void)mprotect(first, (size_t)(used - first), PROT_NONE); + bump = used > first ? used : top; + limit = lim > bump ? lim : bump; + } else { + char *end = (char *)(((uintptr_t)lim + page - 1) & ~(page - 1)); + if (end > first) { + (void)madvise(first, (size_t)(end - first), MADV_DONTNEED); + returned += (size_t)(end - first); + } + } + } else { + bump = top; + limit = lim; + } + scr_sa.free[c] = k->free[c]; + ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c]; + *h = k->holes[c]; + if (h->lim) { + h->bump = bump; + h->lim = limit; + scr_sa.bump[c] = k->bump[c]; + scr_sa.lim[c] = k->lim[c]; + } else { + scr_sa.bump[c] = bump; + scr_sa.lim[c] = limit; + } + } + /* The older free blocks the worker took are free again. */ + if (!k->poison) + for (size_t t = 0; t < k->ntaken; t++) { + ScrSaBlock *b = k->taken[t]; + unsigned c = (unsigned)(((uintptr_t)b - scr_sa.base) >> scr_sa.shift); + b->next = scr_sa.free[c]; + scr_sa.free[c] = b; + } + if (!k->poison) + for (size_t s = 0; s < k->cap; s++) { + void *p = k->sys[s]; + if (!p || p == SCR_SA_SYS_GONE) continue; +#if defined(__GLIBC__) + sys_bytes += malloc_usable_size(p); +#endif + sys_blocks++; + free(p); + } +#if defined(__GLIBC__) + if (sys_blocks) (void)malloc_trim(0); +#endif + const char *stats = getenv("SCR_SA_STATS"); + if (stats && stats[0] == '1') + fprintf(stderr, "scr_sa: discard arena %d returned %zu MB, %zu system blocks (%zu MB)\n", scr_sa_arena, + returned >> 20, sys_blocks, sys_bytes >> 20); + free(k->sys); + free(k->taken); + free(k); +} + +/* Hooks of the allocator's system paths and of sharing (scr_rc.h). */ +static void scr_sa_keep_note_system(void *p) { + if (p && scr_sa_keep) scr_sa_keep_add(scr_sa_keep, p); +} + +void scr_sa_thread_shares(void) { + if (scr_sa_keep) scr_sa_keep->shared = true; +} + +/* Worker threads that started and have not parked. */ +static _Atomic(unsigned) scr_sa_workers_live; + +/* Library archives run on their embedder's threads and keep their memory. */ +static void scr_sa_worker_starting(void) { +#ifndef SCR_LIB + scr_sa_worker = true; + atomic_fetch_add_explicit(&scr_sa_workers_live, 1, memory_order_relaxed); +#endif +} +#define SCR_SA_WORKER_STARTING() scr_sa_worker_starting() #endif #ifdef SCR_SA_TRIM @@ -912,6 +1243,85 @@ static void *scr_sa_trim_main(void *arg) { return NULL; } +/* Hands the calling thread's arena to a trim helper; false if no helper + * could start. */ +static bool scr_sa_start_helper(unsigned i) { + scr_sa_parked[i] = scr_sa; + atomic_fetch_add_explicit(&scr_sa_trimming, 1, memory_order_relaxed); + pthread_attr_t attr; + pthread_t helper; + bool started = pthread_attr_init(&attr) == 0; + if (started) { + started = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 && + pthread_create(&helper, &attr, scr_sa_trim_main, (void *)(uintptr_t)i) == 0; + pthread_attr_destroy(&attr); + } + if (!started) atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_relaxed); + return started; +} + +/* Discards that wait for the last running worker: one bit per arena, whose + * state and ScrSaKeep are in scr_sa_parked and scr_sa_parked_keep. */ +static pthread_mutex_t scr_sa_pending_lock = PTHREAD_MUTEX_INITIALIZER; +static uint32_t scr_sa_pending; + +/* Queues the discard of the calling worker's arena i; the arena stays + * claimed until it is done. */ +static void scr_sa_defer_discard(unsigned i, ScrSaKeep *k) { + atomic_fetch_add_explicit(&scr_sa_trimming, 1, memory_order_relaxed); + pthread_mutex_lock(&scr_sa_pending_lock); + scr_sa_parked[i] = scr_sa; + scr_sa_parked_keep[i] = k; + scr_sa_pending |= 1u << i; + pthread_mutex_unlock(&scr_sa_pending_lock); +} + +/* Runs the waiting discards on the calling thread, which ends with no arena; + * returns whether there were any. */ +static bool scr_sa_drain(void) { + bool any = false; + for (;;) { + pthread_mutex_lock(&scr_sa_pending_lock); + uint32_t pending = scr_sa_pending; + unsigned i = pending ? (unsigned)__builtin_ctz(pending) : 0; + scr_sa_pending &= ~(1u << i); + pthread_mutex_unlock(&scr_sa_pending_lock); + if (!pending) return any; + any = true; + scr_sa_arena = (int)i; + scr_sa = scr_sa_parked[i]; + ScrSaKeep *k = scr_sa_parked_keep[i]; + scr_sa_parked_keep[i] = NULL; + scr_sa_discard(k); + scr_sa_release(i); + atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_release); + } +} + +static void *scr_sa_drain_main(void *arg) { + (void)arg; + scr_sa_drain(); + return NULL; +} + +/* A worker parked: the last one running starts the waiting discards. */ +static void scr_sa_worker_parked(void) { + if (atomic_fetch_sub_explicit(&scr_sa_workers_live, 1, memory_order_acq_rel) != 1) return; + pthread_mutex_lock(&scr_sa_pending_lock); + bool pending = scr_sa_pending != 0; + pthread_mutex_unlock(&scr_sa_pending_lock); + if (!pending) return; + pthread_attr_t attr; + pthread_t helper; + bool started = pthread_attr_init(&attr) == 0; + if (started) { + started = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 && + pthread_create(&helper, &attr, scr_sa_drain_main, NULL) == 0; + pthread_attr_destroy(&attr); + } + if (!started) scr_sa_drain(); +} + void scr_sa_wait_trims(void) { while (atomic_load_explicit(&scr_sa_trimming, memory_order_acquire)) { struct timespec pause = {0, 1000000}; @@ -920,27 +1330,45 @@ void scr_sa_wait_trims(void) { } #else void scr_sa_wait_trims(void) {} +void scr_sa_thread_shares(void) {} #endif +static void scr_sa_park_arena(void); + void scr_sa_thread_park(void) { +#ifdef SCR_SA_TRIM + bool worker = scr_sa_worker; + scr_sa_worker = false; + scr_sa_park_arena(); + if (worker) scr_sa_worker_parked(); +#else + scr_sa_park_arena(); +#endif +} + +static void scr_sa_park_arena(void) { if (scr_sa_arena < 0) return; unsigned i = (unsigned)scr_sa_arena; #if defined(SCR_SA_PREFAULT) && defined(SCR_WORKERS) scr_sa_pf_park(); #endif #ifdef SCR_SA_TRIM + ScrSaKeep *k = scr_sa_keep; + scr_sa_keep = NULL; + if (k && !k->shared) { + /* Nothing this thread allocated is reachable from another one: its + * memory goes back once no other worker runs (scr_sa_worker_parked), + * and later frees on this thread are dropped. */ + scr_sa_discarded = true; + scr_sa_defer_discard(i, k); + memset(&scr_sa, 0, sizeof scr_sa); + scr_sa_arena = -1; + scr_sa_refused = true; + return; + } + if (k) scr_sa_keep_merge(k); if (scr_sa_trim_worth()) { - scr_sa_parked[i] = scr_sa; - atomic_fetch_add_explicit(&scr_sa_trimming, 1, memory_order_relaxed); - pthread_attr_t attr; - pthread_t helper; - bool started = pthread_attr_init(&attr) == 0; - if (started) { - started = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 && - pthread_create(&helper, &attr, scr_sa_trim_main, (void *)(uintptr_t)i) == 0; - pthread_attr_destroy(&attr); - } - if (started) { + if (scr_sa_start_helper(i)) { /* Later frees on this thread take the foreign path into the arena's * remote list; the helper releases the claim. */ memset(&scr_sa, 0, sizeof scr_sa); @@ -948,7 +1376,6 @@ void scr_sa_thread_park(void) { scr_sa_refused = true; return; } - atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_relaxed); scr_sa_trim(); } #endif @@ -971,6 +1398,9 @@ static size_t scr_sa_foreign_size(const void *p) { } void scr_sa_free_foreign(void *p) { +#ifdef SCR_SA_TRIM + if (scr_sa_discarded) return; /* the block went back with the thread's memory */ +#endif uintptr_t region = atomic_load_explicit(&scr_sa_region, memory_order_acquire); uintptr_t off = (uintptr_t)p - region; if (region && off < scr_sa_arena_span * SCR_SA_ARENAS) { @@ -982,6 +1412,9 @@ void scr_sa_free_foreign(void *p) { memory_order_relaxed)); return; } +#ifdef SCR_SA_TRIM + if (scr_sa_keep) (void)scr_sa_keep_remove(scr_sa_keep, p); +#endif free(p); } #else @@ -1020,6 +1453,15 @@ __attribute__((cold, noinline)) void *scr_sa_slow(size_t n, bool zero) { if (zero) memset(b, 0, n); return b; } +#ifdef SCR_SA_TRIM + /* A worker that keeps its memory apart uses the arena's older free + * blocks before its slice grows. */ + if (scr_sa_keep && (size_t)(scr_sa.lim[c] - scr_sa.bump[c]) < scr_sa_class_size(c) && + (b = scr_sa_keep_take(scr_sa_keep, c)) != NULL) { + if (zero) memset(b, 0, n); + return b; + } +#endif #endif /* Carve from the slice, opening it or raising its limit as needed; * fresh pages are zero. */ @@ -1051,6 +1493,9 @@ __attribute__((cold, noinline)) void *scr_sa_slow(size_t n, bool zero) { SCR_SA_LARGE_NOTE(q, n); SCR_SA_THREAD_LARGE_NOTE(); } +#ifdef SCR_SA_TRIM + scr_sa_keep_note_system(q); +#endif return q; } @@ -1065,8 +1510,14 @@ void *scr_mem_realloc(void *p, size_t n) { } #endif else { +#ifdef SCR_SA_TRIM + bool kept = scr_sa_keep && scr_sa_keep_remove(scr_sa_keep, p); +#endif void *q = realloc(p, n); if (n > SCR_SA_MAX) SCR_SA_LARGE_NOTE(q, n); +#ifdef SCR_SA_TRIM + if (kept) scr_sa_keep_note_system(q ? q : p); /* a failed realloc leaves p */ +#endif return q; } /* Still fits its block, which is aligned for n (see the contract). */ @@ -1082,10 +1533,15 @@ void *scr_mem_realloc(void *p, size_t n) { return realloc(p, n); } #ifdef SCR_WORKERS void scr_sa_thread_park(void) {} void scr_sa_wait_trims(void) {} +void scr_sa_thread_shares(void) {} void scr_sa_free_foreign(void *p) { free(p); } #endif #endif +#ifndef SCR_SA_WORKER_STARTING +#define SCR_SA_WORKER_STARTING() ((void)0) +#endif + #ifdef SCR_WORKERS /* Blocks above SCR_SA_MAX come from glibc, which gives each thread that * allocates its own arena: a heap reserved up front and made usable with @@ -1116,7 +1572,10 @@ void scr_sa_free_foreign(void *p) { free(p); } * that are not workers (they never reserve). */ static _Thread_local unsigned scr_sa_thread_large; -void scr_sa_thread_starting(void) { scr_sa_thread_large = 1; } +void scr_sa_thread_starting(void) { + scr_sa_thread_large = 1; + SCR_SA_WORKER_STARTING(); +} static void scr_sa_thread_reserve(void) { void *blocks[SCR_SA_THREAD_HEAP_RESERVE / SCR_SA_THREAD_HEAP_STEP + 1]; @@ -1136,7 +1595,7 @@ static void scr_sa_thread_large_note(void) { if (count == SCR_SA_THREAD_RESERVE_AFTER) scr_sa_thread_reserve(); } #else -void scr_sa_thread_starting(void) {} +void scr_sa_thread_starting(void) { SCR_SA_WORKER_STARTING(); } #endif #endif diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index b3228ce89..f0efdc935 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -429,6 +429,332 @@ static void check_trim(void) { } #endif +#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC && defined(__linux__) +#include +#include +#include +#include +#include + +/* A worker thread (scr_sa_thread_starting) that exits without sharing + * gives its memory back: the pages it carved, and the system blocks it + * still holds. Older blocks of the arena, and the memory of a worker that + * shared (or got a remote free of its own blocks), stay. */ +/* DISCARD_SIZE and POISON_SIZE are classes the earlier checks leave unused + * in the workers' arena, so their state there is known. */ +enum { DISCARD_BLOCKS = 1 << 20, DISCARD_SIZE = 40, POISON_SIZE = 56, DISCARD_OLD = 64, DISCARD_LARGE = 64 }; +enum { DISCARD_LARGE_SIZE = 64 << 10 }; +static unsigned char *old_blocks[DISCARD_OLD]; +static unsigned char **discard_blocks; +static unsigned char *discard_large[DISCARD_LARGE]; +static uintptr_t discard_base, discard_lo, discard_hi; +static size_t block_size = DISCARD_SIZE; /* of the workers' and reusing threads' blocks */ +/* Whether discard workers claim the arena of discard_old_owner (no other worker holds it). */ +static bool discard_in_old_arena = true; +/* The end of the highest block of class DISCARD_SIZE so far, and how many blocks of a run lay past the mark + * at its start: those the slice grew for. */ +static uintptr_t class_hi; +static size_t discard_beyond, reuse_beyond; +static bool discard_shares; +static atomic_int discard_step; + +static void wait_step(int step) { + while (atomic_load(&discard_step) < step) { + struct timespec pause = {0, 100000}; + nanosleep(&pause, NULL); + } +} + +/* A plain script thread whose blocks stay live in its parked arena. */ +static void *discard_old_owner(void *arg) { + (void)arg; + for (unsigned i = 0; i < DISCARD_OLD; i++) { + old_blocks[i] = scr_mem_alloc(DISCARD_SIZE); + assert(owned(old_blocks[i])); + fill(old_blocks[i], DISCARD_SIZE, (unsigned char)i); + } + discard_base = scr_sa.base; + scr_sa_thread_park(); + return NULL; +} + +static void allocate_discardable(void) { + discard_lo = UINTPTR_MAX; + discard_hi = 0; + discard_beyond = 0; + for (size_t i = 0; i < DISCARD_BLOCKS; i++) { + unsigned char *p = scr_mem_alloc(block_size); + assert(p && owned(p)); + fill(p, block_size, (unsigned char)i); + discard_blocks[i] = p; + if ((uintptr_t)p >= class_hi) discard_beyond++; + if ((uintptr_t)p < discard_lo) discard_lo = (uintptr_t)p; + if ((uintptr_t)p + block_size > discard_hi) discard_hi = (uintptr_t)p + block_size; + } + if (block_size == DISCARD_SIZE && discard_hi > class_hi) class_hi = discard_hi; + /* System blocks: some freed, some moved by realloc, the rest kept. */ + for (unsigned i = 0; i < DISCARD_LARGE; i++) { + discard_large[i] = scr_mem_alloc(DISCARD_LARGE_SIZE); + assert(discard_large[i] && !owned(discard_large[i])); + fill(discard_large[i], 64, (unsigned char)i); + } + for (unsigned i = 0; i < DISCARD_LARGE; i += 4) { + scr_mem_free(discard_large[i]); + discard_large[i] = NULL; + } + for (unsigned i = 1; i < DISCARD_LARGE; i += 4) discard_large[i] = scr_mem_realloc(discard_large[i], 2 * DISCARD_LARGE_SIZE); +} + +static void *discard_worker(void *arg) { + (void)arg; + scr_sa_thread_starting(); + allocate_discardable(); + /* None of the arena's older blocks or free blocks is reused. */ + if (scr_sa.base == discard_base) + for (unsigned i = 0; i < DISCARD_OLD; i++) assert(discard_lo > (uintptr_t)old_blocks[i]); + else assert(!discard_in_old_arena); + if (discard_shares) scr_sa_thread_shares(); + scr_sa_thread_park(); + return NULL; +} + +/* A worker one of whose blocks the main thread frees while it runs. */ +static void *remote_freed_worker(void *arg) { + (void)arg; + scr_sa_thread_starting(); + allocate_discardable(); + atomic_store(&discard_step, 1); + wait_step(2); + /* A class this thread has not used: its first block takes the slow path, + * which drains the remote free. */ + void *p = scr_mem_alloc(200); + assert(p); + scr_sa_thread_park(); + return NULL; +} + +/* A plain thread that takes as many blocks again from the arena: carved + * ones must be zero (the fast path does not clear them). */ +static size_t reuse_outside; +static uintptr_t reuse_hi; +static void *discard_reuse(void *arg) { + (void)arg; + reuse_outside = 0; + reuse_hi = 0; + reuse_beyond = 0; + for (size_t i = 0; i < DISCARD_BLOCKS; i++) { + unsigned char *p = scr_mem_calloc(block_size); + assert(p && owned(p)); + assert(scr_sa.base == discard_base); + for (size_t k = 0; k < block_size; k++) assert(p[k] == 0); + for (unsigned j = 0; j < DISCARD_OLD; j++) assert(p != old_blocks[j]); + if ((uintptr_t)p < discard_lo || (uintptr_t)p >= discard_hi) reuse_outside++; + if ((uintptr_t)p + block_size > reuse_hi) reuse_hi = (uintptr_t)p + block_size; + if ((uintptr_t)p >= class_hi) reuse_beyond++; + discard_blocks[i] = p; + } + if (block_size == DISCARD_SIZE && reuse_hi > class_hi) class_hi = reuse_hi; + for (size_t i = 0; i < DISCARD_BLOCKS; i++) scr_mem_free(discard_blocks[i]); + scr_sa_thread_park(); + return NULL; +} + +static void run(void *(*fn)(void *)) { + pthread_t t; + assert(pthread_create(&t, NULL, fn, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + scr_sa_wait_trims(); +} + +static void verify_old_blocks(void) { + for (unsigned i = 0; i < DISCARD_OLD; i++) verify(old_blocks[i], DISCARD_SIZE, (unsigned char)i); +} + +/* The worker's blocks stayed as it left them, and the next thread to claim + * the arena carves past them; then they are freed (remote frees). */ +static void verify_kept(void) { + for (size_t i = 0; i < DISCARD_BLOCKS; i++) verify(discard_blocks[i], block_size, (unsigned char)i); + for (unsigned i = 0; i < DISCARD_LARGE; i++) { + if (!discard_large[i]) continue; + verify(discard_large[i], 64, (unsigned char)i); + free(discard_large[i]); + } + for (size_t i = 0; i < DISCARD_BLOCKS; i++) scr_mem_free(discard_blocks[i]); +} + +/* The protection of the mapping that holds address a, from /proc/self/maps. */ +static bool readable(uintptr_t a) { + FILE *maps = fopen("/proc/self/maps", "r"); + assert(maps); + char line[512]; + bool found = false, result = false; + while (!found && fgets(line, sizeof line, maps)) { + unsigned long lo, hi; + char perms[8]; + if (sscanf(line, "%lx-%lx %7s", &lo, &hi, perms) != 3) continue; + if (a >= lo && a < hi) { + found = true; + result = perms[0] == 'r'; + } + } + fclose(maps); + assert(found); + return result; +} + +/* A worker that runs until discard_step reaches 3. */ +static void *holding_worker(void *arg) { + (void)arg; + scr_sa_thread_starting(); + void *p = scr_mem_alloc(DISCARD_SIZE); + assert(p && owned(p)); + atomic_store(&discard_step, 1); + wait_step(3); + scr_sa_thread_park(); + return NULL; +} + +/* A worker that allocates one block of a class the other checks leave + * alone and parks without sharing. */ +static void *small_worker(void *arg) { + (void)arg; + scr_sa_thread_starting(); + void *p = scr_mem_alloc(200); + assert(p && owned(p)); + scr_sa_thread_park(); + return NULL; +} + +/* Discards wait while another worker runs, and the last worker to park + * starts them. When every arena is held by a discard that waits, a worker + * that needs one runs them itself. */ +static void check_deferred_discard(void) { + size_t page = (size_t)sysconf(_SC_PAGESIZE); + atomic_store(&discard_step, 0); + pthread_t holder; + assert(pthread_create(&holder, NULL, holding_worker, NULL) == 0); + wait_step(1); + discard_shares = false; + discard_in_old_arena = false; + pthread_t t; + assert(pthread_create(&t, NULL, discard_worker, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + uintptr_t first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + size_t total = (discard_hi - first) / page; + /* Still resident (the arena is not reused meanwhile, so the blocks are intact). */ + assert(resident_pages(first, discard_hi) > total / 2); + for (size_t i = 0; i < DISCARD_BLOCKS; i += 4099) verify(discard_blocks[i], block_size, (unsigned char)i); + /* More arenas held by waiting discards than there are free ones. */ + for (unsigned n = 0; n < 40; n++) { + assert(pthread_create(&t, NULL, small_worker, NULL) == 0); + assert(pthread_join(t, NULL) == 0); + } + atomic_store(&discard_step, 3); + assert(pthread_join(holder, NULL) == 0); + scr_sa_wait_trims(); + assert(resident_pages(first, discard_hi) < total / 2); + discard_in_old_arena = true; +} + +static void check_discard(void) { + discard_blocks = malloc(DISCARD_BLOCKS * sizeof *discard_blocks); + assert(discard_blocks); + run(discard_old_owner); + size_t page = (size_t)sysconf(_SC_PAGESIZE); + + /* Discarded: its pages are returned, and carved again (zeroed) by the next + * thread, which does not grow the slice. The arena's older blocks stay. */ + discard_shares = false; + run(discard_worker); + verify_old_blocks(); + uintptr_t first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + /* Pre-fault requests still in flight may fault in a few megabytes again. */ + size_t total = (discard_hi - first) / page; + assert(resident_pages(first, discard_hi) < total / 2); + run(discard_reuse); + assert(reuse_outside < 256); + verify_old_blocks(); + + /* SCR_SA_DISCARD=poison makes the pages inaccessible; the next thread + * carves past them. */ + block_size = POISON_SIZE; + setenv("SCR_SA_DISCARD", "poison", 1); + run(discard_worker); + unsetenv("SCR_SA_DISCARD"); + verify_old_blocks(); + first = (discard_lo + page - 1) & ~(uintptr_t)(page - 1); + assert(!readable(first)); + assert(!readable((discard_hi - 1) & ~(uintptr_t)(page - 1))); + run(discard_reuse); + assert(reuse_outside == DISCARD_BLOCKS); + verify_old_blocks(); + block_size = DISCARD_SIZE; + + /* A worker that shared keeps its memory. */ + discard_shares = true; + run(discard_worker); + verify_old_blocks(); + verify_kept(); + + /* So does one that got a remote free of one of its blocks. */ + atomic_store(&discard_step, 0); + pthread_t t; + assert(pthread_create(&t, NULL, remote_freed_worker, NULL) == 0); + wait_step(1); + scr_mem_free(discard_blocks[0]); + discard_blocks[0] = scr_mem_alloc(DISCARD_SIZE); /* main's own block, for verify_kept */ + fill(discard_blocks[0], DISCARD_SIZE, 0); + atomic_store(&discard_step, 2); + assert(pthread_join(t, NULL) == 0); + scr_sa_wait_trims(); + verify_old_blocks(); + verify_kept(); + + /* SCR_SA_DISCARD=0 keeps every worker's memory. */ + discard_shares = false; + setenv("SCR_SA_DISCARD", "0", 1); + run(discard_worker); + unsetenv("SCR_SA_DISCARD"); + verify_old_blocks(); + verify_kept(); + + /* The kept workers' blocks are free now (remote frees): a worker takes + * them once its slice has no bump room left, and they are free again + * after its memory goes back, so the next thread does not grow the slice + * either. Past the highest block so far, each carves at most the bump room + * the slice had left (a few megabytes). */ + size_t room = ((size_t)4 << 20) / DISCARD_SIZE; + run(discard_worker); + verify_old_blocks(); + assert(discard_beyond < room); + run(discard_reuse); + assert(reuse_beyond < room); + verify_old_blocks(); + + /* Returned regions (holes) of an earlier trim are kept apart while a + * worker runs, and carved again afterwards. */ + trim_blocks = malloc(TRIM_BLOCKS * sizeof *trim_blocks); /* check_trim freed its own */ + assert(trim_blocks); + run(trim_allocate); + uintptr_t hole_lo = trim_lo, hole_hi = trim_hi; + discard_shares = false; + run(discard_worker); + verify_old_blocks(); + for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++) + verify(trim_kept[k], TRIM_SIZE, (unsigned char)(k * TRIM_KEEP)); + trim_lo = hole_lo; + trim_hi = hole_hi; + run(trim_reuse); + assert(trim_outside < 4 * TRIM_BLOCKS / TRIM_KEEP + 256); + for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++) scr_mem_free(trim_kept[k]); + free(trim_blocks); + + check_deferred_discard(); + for (unsigned i = 0; i < DISCARD_OLD; i++) scr_mem_free(old_blocks[i]); + free(discard_blocks); +} +#endif + int main(void) { #if SCR_SMALL_ALLOC check_slice_growth(); /* first: its class must be untouched */ @@ -446,6 +772,7 @@ int main(void) { check_threads(); #ifdef __linux__ check_trim(); + check_discard(); #endif printf("allocator checks passed: size-class workers\n"); #else diff --git a/packages/runtime/src/scr_itable.c b/packages/runtime/src/scr_itable.c index 08db948b5..4842bf79c 100644 --- a/packages/runtime/src/scr_itable.c +++ b/packages/runtime/src/scr_itable.c @@ -428,11 +428,13 @@ static const ScrITableEntry *scr_itable_insert_bytes(ScrITable *t, uint64_t hash const ScrITableEntry *scr_itable_insert_str(ScrITable *t, const ScrStr *key, uint64_t value) { scr_itable_enter(); + scr_sa_thread_shares(); /* the stored value graph is read by every thread */ return scr_itable_insert_bytes(t, scr_itable_str_hash(key), key->data, key->len, value); } const ScrITableEntry *scr_itable_insert_f64(ScrITable *t, double key, uint64_t value) { scr_itable_enter(); + scr_sa_thread_shares(); /* the stored value graph is read by every thread */ uint64_t stored = scr_itable_f64_bits(key) ^ SCR_ITABLE_F64_FLIP; uint64_t hash = scr_itable_f64_hash(stored); ScrITableSegment *seg = scr_itable_segment(t, hash); @@ -588,6 +590,7 @@ const ScrITableEntry *scr_itable_find_arr(ScrITable *t, const ScrArr *key) { const ScrITableEntry *scr_itable_insert_arr(ScrITable *t, const ScrArr *key, uint64_t value) { scr_itable_enter(); + scr_sa_thread_shares(); /* the stored value graph is read by every thread */ char stack[SCR_ITABLE_STACK_KEY]; size_t len; char *bytes = scr_itable_encoded(t, key, stack, sizeof stack, &len); diff --git a/packages/runtime/src/scr_itable.test.c b/packages/runtime/src/scr_itable.test.c index 0f218806b..5e5f22bc9 100644 --- a/packages/runtime/src/scr_itable.test.c +++ b/packages/runtime/src/scr_itable.test.c @@ -8,13 +8,18 @@ #include #include -/* The table unit needs only scr_trap and scr_throw_error_msg from the - * rest of the runtime; refusals are recorded here. */ +/* The table unit needs only scr_trap, scr_throw_error_msg and + * scr_sa_thread_shares from the rest of the runtime; refusals and the + * inserting threads' shares are recorded here. */ _Noreturn void scr_trap(const char *msg) { fputs(msg, stderr); abort(); } +/* Inserts share the stored values with every thread. */ +static atomic_int shares; +void scr_sa_thread_shares(void) { atomic_fetch_add(&shares, 1); } + static char refused[128]; void scr_throw_error_msg(int kind, const char *msg, size_t len) { assert(kind == SCR_ERR_TYPE && len < sizeof refused); @@ -222,6 +227,7 @@ int main(void) { for (unsigned i = 1; i < THREADS; i++) assert(seen_str[i][k] == seen_str[0][k]); assert(scr_itable_size(strings) == KEYS); assert(scr_itable_size(numbers) == KEYS); + assert(atomic_load(&shares) >= 2 * KEYS); printf("intern tables share one entry per key across racing inserts and lookups\n"); return 0; } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 90b8f822e..57e0a1fbd 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -10709,6 +10709,7 @@ static ScrArr *scr_record_shared_keys(const ScrDynTypedRecordOps *record) { #ifdef SCR_RC_AUDIT scr_arr_live_forget(); /* immortal: outside the exit-time RC audit */ #endif + scr_sa_thread_shares(); /* every thread reads the array */ return built; } built->rc = 1; diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 684f6c0ca..82428a6fe 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -278,6 +278,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) * capsule over the same object, a dyn graph as itself. */ if (input->kind == SCR_DYN_TYPED_REF && input->v.typed_ref.ptr && *(const size_t *)input->v.typed_ref.ptr == SIZE_MAX) { + scr_sa_thread_shares(); encoder->visits[id].view = (ScrDyn *)input; ScrMessageNode *node = &encoder->message->nodes[id]; node->kind = SCR_MESSAGE_TYPED_REF; @@ -295,6 +296,7 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) return id; } if ((input->kind == SCR_DYN_OBJ || input->kind == SCR_DYN_ARR) && input->rc == SIZE_MAX) { + scr_sa_thread_shares(); encoder->visits[id].view = (ScrDyn *)input; ScrMessageNode *node = &encoder->message->nodes[id]; node->kind = SCR_MESSAGE_PUBLISHED; @@ -558,6 +560,7 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) { scr_lsan_immortal(d); } } + scr_sa_thread_shares(); message = scr_message_alloc(1, sizeof(*message)); message->nodes = scr_message_alloc(1, sizeof(*message->nodes)); message->count = message->capacity = 1; diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 7e24ff569..cdc03c966 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -341,11 +341,18 @@ void scr_sa_thread_park(void); /* Waits until the arenas of exited threads are trimmed (tests). */ void scr_sa_wait_trims(void); /* Called first on a new worker thread: prepares the thread's system- - * allocator heap (scr_alloc.c). */ + * allocator heap, and lets the thread's memory go back when it exits + * (scr_alloc.c). */ void scr_sa_thread_starting(void); +/* The calling thread made objects it allocated reachable from other + * threads: a message or an intern table shares a published graph by + * pointer, or a process-wide cache keeps an immortal object. Its memory + * then stays when it exits (scr_alloc.c). */ +void scr_sa_thread_shares(void); #define SCR_SA_FREE_FOREIGN(p) scr_sa_free_foreign(p) #else #define SCR_SA_FREE_FOREIGN(p) free(p) +#define scr_sa_thread_shares() ((void)0) #endif #if SCR_SMALL_ALLOC diff --git a/tests/corpus/native-worker-discarded-heaps.ts b/tests/corpus/native-worker-discarded-heaps.ts new file mode 100644 index 000000000..bf7a76449 --- /dev/null +++ b/tests/corpus/native-worker-discarded-heaps.ts @@ -0,0 +1,102 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; +import { publish } from "@scriptc/threads"; +import type { ProgramLifetime } from "@scriptc/types"; + +// Workers whose memory goes back when they exit, and workers whose memory +// stays because other threads hold what they allocated. Builder workers +// create ProgramLifetime instances (which scriptc never frees), ordinary +// objects, strings and large typed arrays, post a checksum and exit. A +// publisher worker posts published graphs (a class graph and a plain +// object) and exits. The main thread reads those graphs again after each +// wave of builders, which in scriptc on Linux reuse the memory of the +// builders before them. + +class Node implements ProgramLifetime { + readonly kind: number; + readonly label: string; + readonly children: Node[] = []; + parent: Node | undefined = undefined; + constructor(kind: number, label: string) { + this.kind = kind; + this.label = label; + } +} + +type Summary = { readonly name: string; readonly values: readonly number[]; readonly nested: { readonly total: number } }; + +type Job = { readonly role: string; readonly seed: number }; + +function build(seed: number, count: number): Node { + const root = new Node(seed, "root" + seed); + let parent = root; + for (let i = 0; i < count; i++) { + const node = new Node((i * 7 + seed) % 31, "n" + (i % 101)); + node.parent = parent; + parent.children.push(node); + if (i % 64 === 63) parent = node; + } + return root; +} + +function checksum(root: Node): number { + let sum = 0; + const stack: Node[] = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + sum = (sum * 31 + node.kind + node.label.length + node.children.length) % 1_000_003; + for (const child of node.children) stack.push(child); + } + return sum; +} + +function startWorker(job: Job, onMessage: (value: unknown) => void): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker(new URL(import.meta.url), { workerData: job }); + worker.on("message", onMessage); + worker.once("messageerror", reject); + worker.once("error", reject); + worker.once("exit", () => resolve()); + }); +} + +if (isMainThread) { + let tree: Node | undefined = undefined; + let summary: Summary | undefined = undefined; + // The publisher posts the tree, then the summary. + await startWorker({ role: "publisher", seed: 3 }, (value) => { + if (tree === undefined) tree = value as Node; + else summary = value as Summary; + }); + const describe = (): string => + `tree ${checksum(tree!)} summary ${summary!.name} ${summary!.values.join(",")} ${summary!.nested.total}`; + console.log("published", describe()); + for (let wave = 0; wave < 3; wave++) { + const sums: number[] = []; + await Promise.all( + [1, 2, 3, 4].map((k) => startWorker({ role: "builder", seed: wave * 10 + k }, (value) => sums.push(value as number))), + ); + sums.sort((a, b) => a - b); + console.log("wave", wave, sums.join(" "), describe()); + } +} else { + const job = workerData as Job; + if (job.role === "publisher") { + parentPort!.postMessage(publish(build(job.seed, 20_000))); + const values: number[] = []; + for (let i = 0; i < 50; i++) values.push(i * job.seed); + parentPort!.postMessage(publish({ name: "summary" + job.seed, values, nested: { total: values.length } })); + } else { + const root = build(job.seed, 150_000); + const samples: Float64Array[] = []; + for (let i = 0; i < 40; i++) { + const sample = new Float64Array(8192); + for (let j = 0; j < sample.length; j += 97) sample[j] = i + j + job.seed; + samples.push(sample); + } + let extra = 0; + for (const sample of samples) extra = (extra + sample[97]! + sample[8148]!) % 1_000_003; + const words: string[] = []; + for (let i = 0; i < 20_000; i++) words.push(`word-${i % 977}-${job.seed}`); + parentPort!.postMessage((checksum(root) + extra + words.join("").length) % 1_000_003); + } +} diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 24eca7d43..4263cb627 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -2416,6 +2416,7 @@ "native-worker-clone-errors.ts": 2347, "native-worker-commonjs.cjs": 2188, "native-worker-delayed-messages.ts": 3114, + "native-worker-discarded-heaps.ts": 4430, "native-worker-environment.ts": 3538, "native-worker-error.ts": 2810, "native-worker-exception-state.ts": 3938, @@ -5103,6 +5104,7 @@ "native-worker-clone-errors.ts": 3629, "native-worker-commonjs.cjs": 3085, "native-worker-delayed-messages.ts": 28752, + "native-worker-discarded-heaps.ts": 39440, "native-worker-environment.ts": 27133, "native-worker-error.ts": 2958, "native-worker-exception-state.ts": 3945, From 89b5baedd7bf9be293c949e34f7743bc534f375c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:31:40 -0500 Subject: [PATCH 08/17] Add t.assert.snapshot and t.assert.fileSnapshot node:test changes, matching Node 24.22: - t.assert.snapshot(value[, options]) serializes the value with Node's default serializer, JSON.stringify(value, null, 2), or with an options.serializers array literal applied in order, and compares it with assert.strictEqual against the snapshot stored under " " in ".snapshot". - t.assert.fileSnapshot(value, path[, options]) compares the serialized value with the contents of path. - Snapshot files are read in the form Node writes them, exports[`key`] = `value`; statements, with template escapes and CRLF line endings cooked as in a template literal. - Missing snapshots, unreadable snapshot files and throwing serializers throw Node's ERR_INVALID_STATE errors, with Node's messages. - Running the binary with Node's --test-update-snapshots flag as its first argument records the values and writes the snapshot files after the last test, keys sorted as Node sorts them. The flag is kept out of process.argv and appears in process.execArgv, which is otherwise []. - Snapshot assertions count toward the test's plan. The snapshot fixture compares the reporter output with Node, plain and with SCRIPTC_SAN=1, against a snapshot file Node wrote. The update-mode harness test runs Node and the binary with --test-update-snapshots and compares the files they write. The node:test normalization also drops the "... N lines matching cause stack trace ..." line Node prints for an error cause, with the stack frames it summarizes. --- .../limitations/builtin-apis/test-runner.mdx | 1 + .../process-arguments-and-errors.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 16 +- .../generated/node-v24-backlog.json | 34 +- .../generated/node-v24-internal.json | 21 +- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 5 + packages/compiler/src/backend/llvm/emitter.ts | 7 +- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../compiler/src/backend/llvm/lib-test.ts | 8 + .../src/backend/llvm/reference-writes.ts | 1 + .../src/frontend/lowering/builtins/process.ts | 6 +- .../src/frontend/lowering/lower-test.ts | 145 ++++- packages/compiler/src/ir/builtin-effects.ts | 3 + .../compiler/src/ir/builtin-signatures.ts | 15 + packages/compiler/src/library/ir-policy.ts | 1 + .../tests/diagnostics/snapshot-fences.ts.json | 6 + packages/runtime/src/scr_lib.c | 33 + packages/runtime/src/scr_lib.h | 7 + packages/runtime/src/scr_test.c | 572 ++++++++++++++++++ packages/runtime/src/scr_test.h | 14 + tests/diagnostics/snapshot-fences.ts | 19 + .../node-test/cases/snapshot-files/value.json | 6 + tests/fixtures/node-test/cases/snapshot.ts | 72 +++ .../node-test/cases/snapshot.ts.snapshot | 82 +++ .../node-test/update/snapshot-update.ts | 27 + .../__snapshots__/snapshot-fences.ts.txt | 26 + tests/harness/node-test-normalize.ts | 10 +- tests/harness/node-test.test.ts | 67 +- 30 files changed, 1160 insertions(+), 50 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json create mode 100644 tests/diagnostics/snapshot-fences.ts create mode 100644 tests/fixtures/node-test/cases/snapshot-files/value.json create mode 100644 tests/fixtures/node-test/cases/snapshot.ts create mode 100644 tests/fixtures/node-test/cases/snapshot.ts.snapshot create mode 100644 tests/fixtures/node-test/update/snapshot-update.ts create mode 100644 tests/harness/__snapshots__/snapshot-fences.ts.txt diff --git a/docs/content/limitations/builtin-apis/test-runner.mdx b/docs/content/limitations/builtin-apis/test-runner.mdx index be138b3ca..7a50901db 100644 --- a/docs/content/limitations/builtin-apis/test-runner.mdx +++ b/docs/content/limitations/builtin-apis/test-runner.mdx @@ -1 +1,2 @@ - **Concurrent tests:** with `{ concurrency }` above 1, subtests run on their own fibers, and results are reported in declaration order, as in Node. Concurrent bodies resume in Node's order around `beforeEach` hooks and nested suites. Other runner steps, such as `afterEach` and `after` hooks, take no microtask turns between bodies, so code that observes the interleaving of concurrent bodies across those steps can see a different order than Node. +- **Snapshots:** `t.assert.snapshot` and `t.assert.fileSnapshot` compile, with Node's default serializer, `JSON.stringify(value, null, 2)`, or an `options.serializers` array literal. A compiled test reads the snapshot file Node writes beside the entry file, `.snapshot`, at the entry file's path when the program was compiled. Running the binary with Node's flag `--test-update-snapshots` as its first argument writes the snapshot files instead; as in Node, the flag is kept out of `process.argv` and appears in `process.execArgv`. Snapshot files are read in the form Node writes them, `` exports[`key`] = `value`; `` statements. The `snapshot.setDefaultSnapshotSerializers` and `snapshot.setResolveSnapshotPath` module functions throw `SC2020`. diff --git a/docs/content/limitations/process-arguments-and-errors.mdx b/docs/content/limitations/process-arguments-and-errors.mdx index 4cca7a808..d048b4154 100644 --- a/docs/content/limitations/process-arguments-and-errors.mdx +++ b/docs/content/limitations/process-arguments-and-errors.mdx @@ -1,4 +1,4 @@ -`process.argv[0]` is `"scriptc"`, `process.argv[1]` is the binary's path, and program arguments begin at index 2. `process.version` and `process.versions.node` report the Node.js release the runtime is verified against. +`process.argv[0]` is `"scriptc"`, `process.argv[1]` is the binary's path, and program arguments begin at index 2. In programs that use `node:test`, a leading `--test-update-snapshots` argument is Node's snapshot update flag: it is kept out of `process.argv` and appears in `process.execArgv`, which is otherwise `[]`. `process.version` and `process.versions.node` report the Node.js release the runtime is verified against. Uncaught exceptions print `Uncaught ` to stderr instead of Node.js's source excerpt and stack-trace block; thrown strings and numbers print their value. Exit codes and output written before the exception are preserved. Runtime errors expose `message` and Node's `code`, but not `errno`, `syscall`, or `path`. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 1c1969968..500876593 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "823524db8f12948bdc73", + "artifactVersion": "56f660f5e2eb7391a048", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 3a986ebf7..568cdb4c9 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -689,9 +689,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 51, + "partial": 53, "refused": 0, - "not-implemented": 66, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -66401,9 +66401,9 @@ "inherited": true }, "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" } }, { @@ -66424,9 +66424,9 @@ "inherited": true }, "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" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 565aadbb3..683d477a5 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1140, - "verify-gap": 2189, + "audit-partial": 1142, + "verify-gap": 2187, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -67667,12 +67667,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.context.assert.fileSnapshot", + "tests": [ + "tests/fixtures/node-test/cases/snapshot.ts", + "tests/harness/node-test.test.ts" + ] } } }, @@ -67692,12 +67695,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:test", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:test.context.assert.snapshot", + "tests": [ + "tests/fixtures/node-test/cases/snapshot.ts", + "tests/harness/node-test.test.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index d3d1addfe..393955bc0 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -746,9 +746,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 51, + "partial": 53, "refused": 0, - "not-implemented": 66, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -90951,6 +90951,7 @@ "tests/fixtures/node-test/cases/mock-timers-date.ts", "tests/fixtures/node-test/cases/mock-timers.ts", "tests/fixtures/node-test/cases/only-nested.ts", + "tests/fixtures/node-test/cases/snapshot.ts", "tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts" @@ -93602,8 +93603,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.context.assert.fileSnapshot", + "tests": [ + "tests/fixtures/node-test/cases/snapshot.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, @@ -93625,8 +93630,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:test" + "status": "partial", + "evidence": "compiler-feature:test.context.assert.snapshot", + "tests": [ + "tests/fixtures/node-test/cases/snapshot.ts", + "tests/harness/node-test.test.ts" + ] }, "anchorSource": "exact" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index b86e033f9..cdd025b72 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -354,6 +354,7 @@ { "chapter": "url", "parent": "Class: `URL`", "signatures": ["`href` Type: {string}", "`pathname` Type: {string}", "`search` Type: {string}", "`hash` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/node24-url-mutation.ts", "tests/corpus/node24-url-mutation-coercion.js", "tests/fixtures/node-types/url-mutation.ts"] }, { "chapter": "url", "signatures": ["`port` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/2946-url-port-hash.ts"] }, { "chapter": "url", "signatures": ["Legacy `urlObject`", "`urlObject.auth`", "`urlObject.hash`", "`urlObject.host`", "`urlObject.hostname`", "`urlObject.href`", "`urlObject.path`", "`urlObject.pathname`", "`urlObject.port`", "`urlObject.protocol`", "`urlObject.query`", "`urlObject.search`", "`urlObject.slashes`"], "status": "partial", "evidence": ["tests/corpus/5221-url-parse-shapes.ts", "tests/corpus/5222-url-parse-hosts.ts", "tests/corpus/5223-url-parse-query.ts", "tests/corpus/5224-url-format-objects.ts", "tests/fixtures/node-types/url-legacy.ts"] }, + { "chapter": "test", "parent": "`context.assert`", "symbols": ["context.assert.snapshot", "context.assert.fileSnapshot"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/snapshot.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `TestContext`", "symbols": ["context.diagnostic", "context.name"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.fn"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-fn.ts", "tests/harness/node-test.test.ts"] }, { "chapter": "test", "parent": "Class: `MockTracker`", "symbols": ["mock.method", "mock.reset", "mock.restoreAll"], "status": "partial", "evidence": ["tests/fixtures/node-test/cases/mock-method.ts", "tests/corpus/5810-mock-method-records.ts", "tests/corpus/5811-mock-tracker-restore.ts", "tests/harness/node-test.test.ts"] }, diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b9475d51f..8cb8c972f 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -3338,7 +3338,12 @@ declare module "node:test" { beforeEach(fn: HookFn): void; afterEach(fn: HookFn): void; } + interface AssertSnapshotOptions { + readonly serializers?: ReadonlyArray<(value: any) => any> | undefined; + } interface TestContextAssert { + snapshot(value: any, options?: AssertSnapshotOptions): void; + fileSnapshot(value: any, path: string, options?: AssertSnapshotOptions): void; ok(value: unknown, message?: string): void; strictEqual(actual: unknown, expected: unknown, message?: string): void; notStrictEqual(actual: unknown, expected: unknown, message?: string): void; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5b2561962..8bae1abaf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2066,7 +2066,10 @@ export class LlEmitter { if (usesHttp) this.declare(`declare void @scr_http_dyn_install()`); if (usesFetch) this.declare(`declare void @scr_fetch_install()`); - if (usesNodeTest) this.declare(`declare i32 @scr_test_exit_code()`); + if (usesNodeTest) { + this.declare(`declare i32 @scr_test_exit_code()`); + this.declare(`declare void @scr_test_init()`); + } if (snapshotsTlsCa) { this.declare(`declare void @scr_tls_ca_install()`); } @@ -2666,6 +2669,8 @@ export class LlEmitter { ...(usesHttp2 ? [` call void @scr_http2_dyn_install()`] : []), ...(usesStream ? [` call void @scr_stream_install()`] : []), ` call void @scr_lib_init(i32 %argc, ptr %argv)`, + // node:test takes Node's --test-update-snapshots out of argv first. + ...(usesNodeTest ? [` call void @scr_test_init()`] : []), ...(asyncEntry ? [` %top = call ptr @${mangleAsyncSpawn(this.mod.entry)}()`] : [` call void @${mangleFunction(this.mod.entry)}()`]), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c0b3725b7..6a85a8eb3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -200,6 +200,7 @@ export const LIB_FN_SYMS: Record = { "os.userHomedir": "scr_os_user_homedir", "os.tmpdir": "scr_os_tmpdir", "process.argv": "scr_process_argv", + "process.execArgv": "scr_process_exec_argv", "process.platform": "scr_process_platform", "process.cwd": "scr_process_cwd", "process.pid": "scr_process_pid", diff --git a/packages/compiler/src/backend/llvm/lib-test.ts b/packages/compiler/src/backend/llvm/lib-test.ts index 8ceadae2f..d0d9a9bfc 100644 --- a/packages/compiler/src/backend/llvm/lib-test.ts +++ b/packages/compiler/src/backend/llvm/lib-test.ts @@ -66,6 +66,14 @@ export function emitTestLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVal return finishRuntimeCall(host, e, "scr_test_ctx_mock_restore_all", a); case "test.ctxMockReset": return finishRuntimeCall(host, e, "scr_test_ctx_mock_reset", a); + case "test.snapshotValue": + return finishRuntimeCall(host, e, "scr_test_snapshot_value", a); + case "test.ctxSnapshot": + return finishRuntimeCall(host, e, "scr_test_ctx_snapshot", a); + case "test.fileSnapshot": + return finishRuntimeCall(host, e, "scr_test_file_snapshot", a); + case "test.snapshotSerialize": + return finishRuntimeCall(host, e, "scr_test_snapshot_serialize", a); case "test.mockTimersEnable": return finishRuntimeCall(host, e, "scr_test_mock_timers_enable", a); case "test.mockTimersTick": diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 33110eaa4..2371147a9 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -106,6 +106,7 @@ const INERT_LIB_CALLS = new Set([ "regex.groupParseInt", "process.cwd", "process.argv", + "process.execArgv", "process.platform", "process.execPath", "process.isTTY", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 2aa1a47ce..97bf1e963 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -362,10 +362,10 @@ export function lowerProcessProperty( if (member === "connected") { return { kind: "libCall", fn: "process.connected", args: [], type: BOOL, loc }; } - // process.execArgv: the extra CLI arguments Node itself consumed — a - // compiled binary consumed none, so the honest answer is a fresh []. + // process.execArgv: the Node runtime flags kept out of process.argv — + // a leading --test-update-snapshots in node:test programs, else []. if (member === "execArgv") { - return { kind: "arrayLit", elems: [], type: arrayOf(STRING), loc }; + return { kind: "libCall", fn: "process.execArgv", args: [], type: arrayOf(STRING), loc }; } // process._exiting — the runtime's exit-sequence flag (true while // 'exit' listeners run), Node's own undocumented member. diff --git a/packages/compiler/src/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index c616f3e73..d18aec305 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -21,12 +21,14 @@ import { type IrStmt, type IrType, type SrcLoc, + BOOL, + DYN, F64, STRING, VOID, isUnitType, } from "../../ir/ir.js"; -import { numLit, strLit } from "../../ir/build.js"; +import { dynUndefinedExpr, numLit, strLit, varRef } from "../../ir/build.js"; import { lowerMockContextCall, lowerMockContextProperty, @@ -518,7 +520,11 @@ export function lowerNodeTestModuleCall( ); break; case "snapshot": - lowerer.noLowering("test.snapshot", expr, "snapshot testing has no lowering"); + lowerer.noLowering( + "test.snapshot", + expr, + "t.assert.snapshot and t.assert.fileSnapshot are the supported snapshot surface", + ); break; default: lowerer.noLowering( @@ -557,6 +563,136 @@ export function lowerTestDirectCall( return lowerRegistration(lowerer, expr, "test(...)", loc, 0, null); } +/** t.assert.snapshot(value[, options]) and t.assert.fileSnapshot(value, + * path[, options]), Node's SnapshotManager assertions: the value runs + * through options.serializers, by default Node's JSON.stringify(value, + * null, 2), and String() of the result is compared with + * assert.strictEqual against the snapshot that scr_test.c reads from + * ".snapshot" or `path`, or records in update mode. A throw + * while serializing becomes Node's ERR_INVALID_STATE. */ +function lowerSnapshotAssert( + lowerer: Lowerer, + call: ts.CallExpression, + member: "snapshot" | "fileSnapshot", + testCtx: ts.Expression, +): IrExpr { + const loc = locOf(call); + const what = `t.assert.${member}`; + const t = lowerer.lowerExpr(testCtx); + const stmts: IrStmt[] = []; + const slot = (name: string, type: IrType, init: IrExpr): IrExpr => { + const local = lowerer.declareHiddenLocal(name, type); + stmts.push({ kind: "varDecl", localId: local.id, init, loc }); + return varRef(local.id, type, loc); + }; + // Arguments evaluate in order before the assertion serializes, as in Node. + const valueNode = call.arguments[0]; + const input = slot( + "%snapshotInput", + DYN, + valueNode ? lowerer.lowerExprExpecting(valueNode, DYN) : dynUndefinedExpr(loc), + ); + let path: IrExpr | null = null; + if (member === "fileSnapshot") { + const pathNode = call.arguments[1]; + path = slot( + "%snapshotPath", + STRING, + pathNode ? lowerer.lowerExprExpecting(pathNode, STRING) : strLit("", loc), + ); + } + const serializers = + snapshotSerializers(lowerer, call.arguments[member === "snapshot" ? 1 : 2], what)?.map((fn) => + slot("%snapshotSerializer", DYN, fn), + ) ?? null; + if (call.arguments.length > (member === "snapshot" ? 2 : 3)) { + lowerer.noLowering(`${what} with ${call.arguments.length} arguments`, call); + } + const text: IrExpr = { + kind: "toString", + operand: { + kind: "libCall", + fn: "test.snapshotSerialize", + args: [ + input, + serializers === null + ? dynUndefinedExpr(loc) + : { kind: "dynArrLit", elems: serializers, type: DYN, loc }, + ], + type: DYN, + loc, + }, + type: STRING, + loc, + }; + const actual = slot( + "%snapshotActual", + STRING, + member === "snapshot" + ? { kind: "libCall", fn: "test.snapshotValue", args: [text], type: STRING, loc } + : text, + ); + const expected: IrExpr = + member === "snapshot" + ? { + kind: "libCall", + fn: "test.ctxSnapshot", + args: [t, actual, strLit(`${lowerer.entry.fileName}.snapshot`, loc)], + type: STRING, + loc, + } + : { kind: "libCall", fn: "test.fileSnapshot", args: [actual, path!], type: STRING, loc }; + const no: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + return { + kind: "seqExpr", + stmts, + result: { + kind: "libCall", + fn: "assert.eqStr", + args: [actual, expected, no, no, strLit("", loc), no], + type: VOID, + loc, + }, + type: VOID, + loc, + }; +} + +/** The serializer functions of a snapshot options object literal, as + * checked-dynamic values in order; null without a serializers option. + * Options that are not an object literal, a serializers list that is not + * an array literal, and other options fence. */ +function snapshotSerializers( + lowerer: Lowerer, + options: ts.Expression | undefined, + what: string, +): IrExpr[] | null { + if (options === undefined) return null; + const hint = "pass the options as an object literal: { serializers: [(value) => ...] }"; + if (!ts.isObjectLiteralExpression(options)) { + lowerer.noLowering(`${what} options that are not an object literal`, options, hint); + } + let fns: IrExpr[] | null = null; + for (const prop of options.properties) { + const name = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : null; + if ( + name !== "serializers" || + !(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) + ) { + lowerer.noLowering(`${what} options other than serializers`, prop, hint); + } + const list = ts.isPropertyAssignment(prop) ? prop.initializer : prop.name; + if (!ts.isArrayLiteralExpression(list)) { + lowerer.noLowering(`${what} with a non-literal serializers list`, list, hint); + } + fns = list.elements.map((el) => { + if (ts.isSpreadElement(el)) lowerer.noLowering(`${what} with a spread serializer`, el, hint); + return lowerer.lowerExprExpecting(el, DYN); + }); + } + return fns; +} + /** `mock.timers` (tracker null) or `t.mock.timers` on a TestContext; * null for other expressions. */ function mockTimersReceiver( @@ -712,7 +848,10 @@ export function lowerTestMethodCall( lowerer.mapTypeOf(lowerer.typeOf(access.expression.expression))?.kind === "testCtx" && lowerer.isStdlibMember(access.expression) ) { - const served = lowerer.lowerAssertModuleCall(call, { module: "assert", member }, loc); + const served = + member === "snapshot" || member === "fileSnapshot" + ? lowerSnapshotAssert(lowerer, call, member, access.expression.expression) + : lowerer.lowerAssertModuleCall(call, { module: "assert", member }, loc); // Each t.assert call counts against the test's plan (t.plan). if (served) return { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 717c87639..1bb9cbd14 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -18,6 +18,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "test.suite", // mock.timers: Node's ERR_INVALID_STATE and ERR_INVALID_ARG_VALUE, and // the throws of the timer callbacks tick and runAll run. + "test.ctxSnapshot", + "test.fileSnapshot", + "test.snapshotSerialize", "test.mockTimersEnable", "test.mockTimersTick", "test.mockTimersRunAll", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index b93490f9d..a9bc17ea6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -1475,6 +1475,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ // code per listed API ('T' setTimeout, 'I' setInterval), the `now` // value and its flags (1 given, 2 from a Date). enable, tick, runAll and // setTime throw Node's errors; tick and runAll run timer callbacks. + // t.assert.snapshot and fileSnapshot: the escaped, wrapped serialized + // value, and the stored value it is compared with (snapshot: the test, + // the value, the snapshot file; fileSnapshot: the value, the path). + // Both read or, in update mode, record; missing and unreadable + // snapshots throw Node's ERR_INVALID_STATE. + "test.snapshotValue": { argTypes: [STRING], result: STRING }, + "test.ctxSnapshot": { argTypes: [TESTCTX_T, STRING, STRING], result: STRING }, + "test.fileSnapshot": { argTypes: [STRING, STRING], result: STRING }, + // Node's serializeValue over a dyn array of serializers (undefined for + // the default JSON.stringify(value, null, 2)); a throw becomes Node's + // "The provided serializers did not generate a string." ERR_INVALID_STATE. + "test.snapshotSerialize": { argTypes: [DYN, DYN], result: DYN }, "test.mockTimersEnable": { argTypes: [STRING, F64, F64], result: VOID }, "test.mockTimersTick": { argTypes: [F64], result: VOID }, "test.mockTimersRunAll": { argTypes: [], result: VOID }, @@ -2885,6 +2897,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ }, "fileHandle.stat": { argTypes: [FILEHANDLE_T], result: { kind: "promise", inner: STATS_T } }, "process.argv": { argTypes: [], result: arrayOf(STRING) }, + // The Node runtime flag a node:test binary took out of process.argv + // (--test-update-snapshots), or [] — a fresh array per read. + "process.execArgv": { argTypes: [], result: arrayOf(STRING) }, "process.platform": { argTypes: [], result: STRING }, // The one libCall whose result type is program-dependent (union ids are // per-module): the `result` here is a placeholder — the libCall case diff --git a/packages/compiler/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index 9fe80fdf3..3c3855e5c 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -196,6 +196,7 @@ export const LIB_NONDETERMINISTIC_PREFIXES: readonly [string, string][] = [ ["blob.fileNew", "the live clock (File lastModified)"], ["process.env", "environment reads"], ["process.argv", "process.argv"], + ["process.execArgv", "process.execArgv"], ["process.cwd", "process.cwd"], ["process.chdir", "process.chdir"], ["process.pid", "process identity"], diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json new file mode 100644 index 000000000..7449cca82 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/snapshot-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/snapshot-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index baabbe31e..b71f95a32 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -166,6 +166,13 @@ static SCR_TL char **scr_lib_argv_owned = NULL; static SCR_TL char *scr_lib_argv_utf8 = NULL; #endif static SCR_TL bool scr_lib_has_fork = false; +/* A leading Node runtime flag taken out of process.argv (scr_lib_take_flag), + * which process.execArgv reports; NULL without one. */ +static SCR_TL const char *scr_lib_exec_flag = NULL; +#if defined(_WIN32) && !defined(SCR_LIB) +/* The flag's index in the original Windows command line, -1 without one. */ +static SCR_TL int scr_lib_exec_flag_index = -1; +#endif #if !defined(_WIN32) && !defined(__wasi__) && !defined(SCR_LIB) static void scr_title_init(int argc, char **argv); #endif @@ -321,6 +328,7 @@ static void scr_lib_prepare_utf8_argv(void) { // scr_lib_init already consumed this private ASCII fork marker. Do not // reintroduce it when replacing the narrow argument storage. if (i == 1 && scr_lib_has_fork) continue; + if (i == scr_lib_exec_flag_index) continue; int size = WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, NULL, 0, NULL, NULL); if (size == 0 || WideCharToMultiByte(CP_UTF8, 0, wide[i], -1, next, size, NULL, NULL) != size) { free(text); free(args); LocalFree(wide); @@ -565,6 +573,31 @@ const char *scr_lib_arg(int i) { return scr_lib_argv[i]; } +bool scr_lib_take_flag(const char *flag) { + if (scr_lib_argc < 2 || strcmp(scr_lib_argv[1], flag) != 0) return false; + char **args = malloc((size_t)scr_lib_argc * sizeof(char *)); + if (!args) scr_trap("out of memory"); + args[0] = scr_lib_argv[0]; + for (int i = 2; i < scr_lib_argc; i++) args[i - 1] = scr_lib_argv[i]; + args[scr_lib_argc - 1] = NULL; + free(scr_lib_argv_owned); + scr_lib_argv_owned = args; + scr_lib_argv = args; + scr_lib_argc--; +#if defined(_WIN32) && !defined(SCR_LIB) + scr_lib_exec_flag_index = scr_lib_has_fork ? 2 : 1; +#endif + scr_lib_exec_flag = flag; + return true; +} + +ScrArr *scr_process_exec_argv(void) { + ScrArr *arr = scr_arr_new(SCR_ELEM_STR, 1); + if (scr_lib_exec_flag) + scr_arr_push_ref(arr, scr_str_new(scr_lib_exec_flag, strlen(scr_lib_exec_flag))); + return arr; +} + bool scr_lib_fork_info(double *target, uintptr_t *read_handle, uintptr_t *write_handle) { if (!scr_lib_has_fork) return false; diff --git a/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index d240aadbd..4c973e3e5 100644 --- a/packages/runtime/src/scr_lib.h +++ b/packages/runtime/src/scr_lib.h @@ -46,6 +46,13 @@ ScrArr *scr_module_cache_keys(void); int scr_lib_arg_count(void); const char *scr_lib_arg(int i); +/* Takes a leading `flag` argument out of process.argv, as Node keeps its + * runtime flags out of it, and reports it in process.execArgv. Called + * right after scr_lib_init, before process.argv is first read. True when + * the flag was there. */ +bool scr_lib_take_flag(const char *flag); +/* process.execArgv: the flag scr_lib_take_flag took, or [] (+1, fresh). */ +ScrArr *scr_process_exec_argv(void); /* Private self-reexec marker parsed and removed from process.argv by * scr_lib_init. False in an ordinary parent invocation. */ bool scr_lib_fork_info(double *target, uintptr_t *read_handle, diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index f8bca0cfa..9e6da5939 100644 --- a/packages/runtime/src/scr_test.c +++ b/packages/runtime/src/scr_test.c @@ -35,6 +35,7 @@ #include "scr_platform.h" #include "scr_runtime.h" +#include #include #include #include @@ -812,6 +813,575 @@ static void scr_test_print_failures(ScrTestCtx *t, bool *any) { } } +/* ── t.assert.snapshot and t.assert.fileSnapshot ─────────────────────── + * Node's SnapshotManager (lib/internal/test_runner/snapshot.js). A + * snapshot is the serialized value, template-escaped and wrapped in + * newlines, stored under " " in ".snapshot". + * Compare mode reads that file on the first assertion and returns the + * stored value, which the emitted assert.strictEqual compares; update + * mode records the value and writes every file, keys sorted, after the + * last test. The file is read in the form Node writes it: + * exports[`key`] = `value`; statements of template literals. */ + +typedef struct { + ScrStr *key; /* the snapshot id (cooked, as Node's exports keys) */ + ScrStr *value; /* the escaped, wrapped value */ +} ScrSnapEntry; + +typedef struct ScrSnapFile { + ScrStr *path; + ScrSnapEntry *entries; + size_t n, cap; + ScrStr **names; /* nextId's counters: test full names ... */ + long *counts; /* ... and the next number for each */ + size_t nnames, ncap; + bool loaded; + struct ScrSnapFile *next; +} ScrSnapFile; + +static SCR_TL ScrSnapFile *scr_snap_files = NULL; +/* Node's --test-update-snapshots: a leading argument of the binary, + * which scr_test_init takes out of process.argv. */ +static SCR_TL bool scr_test_update_snapshots = false; + +void scr_test_init(void) { + scr_test_update_snapshots = scr_lib_take_flag("--test-update-snapshots"); +} + +static void scr_snap_throw_state(const char *text, size_t len) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Invalid state: "); + scr_jb_write(&b, text, len); + ScrStr *msg = scr_jb_finish(&b); + scr_throw_error_msg_code(SCR_ERR_ERROR, msg->data, msg->len, "ERR_INVALID_STATE"); + scr_str_release(msg); +} + +/* "Cannot read snapshot file '.'", with the tip when it is missing. */ +static void scr_snap_throw_file(const char *verb, const ScrStr *file, bool missing) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Cannot "); + scr_jb_puts(&b, verb); + scr_jb_puts(&b, " snapshot file '"); + scr_jb_put_str(&b, file); + scr_jb_puts(&b, ".'"); + if (missing) + scr_jb_puts(&b, " Missing snapshots can be generated by rerunning the command with the " + "--test-update-snapshots flag."); + ScrStr *msg = scr_jb_finish(&b); + scr_snap_throw_state(msg->data, msg->len); + scr_str_release(msg); +} + +/* Node's templateEscape: \ ` and ${ escaped for a template literal. */ +static void scr_snap_put_escaped(ScrJsonBuf *b, const char *s, size_t n) { + for (size_t i = 0; i < n; i++) { + if (s[i] == '\\') scr_jb_puts(b, "\\\\"); + else if (s[i] == '`') scr_jb_puts(b, "\\`"); + else if (s[i] == '$' && i + 1 < n && s[i + 1] == '{') scr_jb_puts(b, "\\$"); + else scr_jb_putc(b, s[i]); + } +} + +/* Node's serializeValue: each serializer's result feeds the next; with no + * serializers, the default JSON.stringify(value, null, 2). A throw becomes + * Node's ERR_INVALID_STATE. Returns +1, or NULL after a throw. */ +ScrDyn *scr_test_snapshot_serialize(ScrDyn *value, ScrDyn *serializers) { + ScrDyn *v; + if (serializers->kind != SCR_DYN_ARR) { + ScrDyn *undef = scr_dyn_undefined(); + ScrDyn *space = scr_dyn_new_num(2); + v = scr_json_stringify_value(value, undef, space); + scr_dyn_release(undef); + scr_dyn_release(space); + } else { + v = scr_dyn_retain(value); + for (size_t i = 0; v && i < serializers->v.arr.len; i++) { + ScrDyn *next = scr_dyn_call(serializers->v.arr.items[i], &v, 1, "serializer"); + scr_dyn_release(v); + v = next; + } + } + if (v && !scr_exc_pending()) return v; + scr_dyn_release(v); + scr_exc_clear(); + static const char msg[] = "The provided serializers did not generate a string."; + scr_snap_throw_state(msg, sizeof msg - 1); + return NULL; +} + +ScrStr *scr_test_snapshot_value(ScrStr *serialized) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_putc(&b, '\n'); + scr_snap_put_escaped(&b, serialized->data, serialized->len); + scr_jb_putc(&b, '\n'); + return scr_jb_finish(&b); +} + +static ScrSnapFile *scr_snap_file(ScrStr *path) { + for (ScrSnapFile *f = scr_snap_files; f; f = f->next) { + if (f->path->len == path->len && memcmp(f->path->data, path->data, path->len) == 0) return f; + } + ScrSnapFile *f = calloc(1, sizeof *f); + if (!f) scr_test_oom(); + f->path = scr_str_retain(path); + f->loaded = scr_test_update_snapshots; + f->next = scr_snap_files; + scr_snap_files = f; + return f; +} + +/* nextId(name): " ", counting from 1 per name. */ +static ScrStr *scr_snap_next_id(ScrSnapFile *f, ScrStr *name) { + size_t i = 0; + while (i < f->nnames && + !(f->names[i]->len == name->len && memcmp(f->names[i]->data, name->data, name->len) == 0)) + i++; + if (i == f->nnames) { + if (f->nnames == f->ncap) { + f->ncap = f->ncap ? f->ncap * 2 : 8; + f->names = realloc(f->names, f->ncap * sizeof *f->names); + f->counts = realloc(f->counts, f->ncap * sizeof *f->counts); + if (!f->names || !f->counts) scr_test_oom(); + } + f->names[i] = scr_str_retain(name); + f->counts[i] = 1; + f->nnames++; + } + char num[24]; + int len = snprintf(num, sizeof num, " %ld", f->counts[i]++); + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_str(&b, name); + scr_jb_write(&b, num, (size_t)len); + return scr_jb_finish(&b); +} + +static ScrSnapEntry *scr_snap_find(ScrSnapFile *f, const ScrStr *key) { + for (size_t i = 0; i < f->n; i++) { + const ScrStr *k = f->entries[i].key; + if (k->len == key->len && memcmp(k->data, key->data, key->len) == 0) return &f->entries[i]; + } + return NULL; +} + +/* Sets key to value (both moved), replacing an earlier entry. */ +static void scr_snap_set(ScrSnapFile *f, ScrStr *key, ScrStr *value) { + ScrSnapEntry *e = scr_snap_find(f, key); + if (e) { + scr_str_release(key); + scr_str_release(e->value); + e->value = value; + return; + } + if (f->n == f->cap) { + f->cap = f->cap ? f->cap * 2 : 8; + f->entries = realloc(f->entries, f->cap * sizeof *f->entries); + if (!f->entries) scr_test_oom(); + } + f->entries[f->n].key = key; + f->entries[f->n].value = value; + f->n++; +} + +/* Appends the UTF-8 encoding of code point `cp`. */ +static void scr_snap_put_cp(ScrJsonBuf *b, unsigned long cp) { + char u[4]; + size_t n; + if (cp < 0x80) { + u[0] = (char)cp; + n = 1; + } else if (cp < 0x800) { + u[0] = (char)(0xC0 | (cp >> 6)); + u[1] = (char)(0x80 | (cp & 0x3F)); + n = 2; + } else if (cp < 0x10000) { + u[0] = (char)(0xE0 | (cp >> 12)); + u[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + u[2] = (char)(0x80 | (cp & 0x3F)); + n = 3; + } else { + u[0] = (char)(0xF0 | (cp >> 18)); + u[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + u[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + u[3] = (char)(0x80 | (cp & 0x3F)); + n = 4; + } + scr_jb_write(b, u, n); +} + +static int scr_snap_hex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* Reads `count` hex digits at s[*i]. */ +static bool scr_snap_hex_run(const char *s, size_t n, size_t *i, int count, unsigned long *out) { + unsigned long v = 0; + for (int k = 0; k < count; k++) { + if (*i >= n) return false; + int h = scr_snap_hex(s[*i]); + if (h < 0) return false; + v = v * 16 + (unsigned long)h; + (*i)++; + } + *out = v; + return true; +} + +/* The cooked value of the template literal whose body starts at s[*i], + * past its opening backtick; *i ends past the closing one. Returns NULL + * for an unterminated literal, an invalid escape, or a substitution. */ +static ScrStr *scr_snap_template(const char *s, size_t n, size_t *i) { + ScrJsonBuf b; + scr_jb_init(&b); + while (*i < n) { + char c = s[(*i)++]; + if (c == '`') return scr_jb_finish(&b); + if (c == '$' && *i < n && s[*i] == '{') break; + if (c == '\r') { + /* Template literals cook CR and CRLF to LF. */ + if (*i < n && s[*i] == '\n') (*i)++; + scr_jb_putc(&b, '\n'); + continue; + } + if (c != '\\') { + scr_jb_putc(&b, c); + continue; + } + if (*i >= n) break; + char e = s[(*i)++]; + unsigned long cp; + switch (e) { + case 'n': + scr_jb_putc(&b, '\n'); + continue; + case 't': + scr_jb_putc(&b, '\t'); + continue; + case 'r': + scr_jb_putc(&b, '\r'); + continue; + case 'b': + scr_jb_putc(&b, '\b'); + continue; + case 'f': + scr_jb_putc(&b, '\f'); + continue; + case 'v': + scr_jb_putc(&b, '\v'); + continue; + case '0': + if (*i < n && s[*i] >= '0' && s[*i] <= '9') goto bad; + scr_jb_putc(&b, '\0'); + continue; + case '\r': + if (*i < n && s[*i] == '\n') (*i)++; + continue; /* line continuation */ + case '\n': + continue; + case 'x': + if (!scr_snap_hex_run(s, n, i, 2, &cp)) goto bad; + scr_snap_put_cp(&b, cp); + continue; + case 'u': + if (*i < n && s[*i] == '{') { + (*i)++; + cp = 0; + size_t start = *i; + while (*i < n && s[*i] != '}') { + int h = scr_snap_hex(s[*i]); + if (h < 0 || cp > 0x10FFFF) goto bad; + cp = cp * 16 + (unsigned long)h; + (*i)++; + } + if (*i >= n || *i == start || cp > 0x10FFFF) goto bad; + (*i)++; + } else if (!scr_snap_hex_run(s, n, i, 4, &cp)) { + goto bad; + } + if (cp >= 0xD800 && cp <= 0xDBFF && *i + 6 <= n && s[*i] == '\\' && s[*i + 1] == 'u') { + size_t j = *i + 2; + unsigned long lo; + if (scr_snap_hex_run(s, n, &j, 4, &lo) && lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + *i = j; + } + } + scr_snap_put_cp(&b, cp); + continue; + default: + if (e >= '1' && e <= '9') goto bad; + scr_jb_putc(&b, e); /* \\ \` \$ and identity escapes */ + continue; + } + } +bad: + scr_str_release(scr_jb_finish(&b)); + return NULL; +} + +static void scr_snap_skip_space(const char *s, size_t n, size_t *i) { + while (*i < n && (s[*i] == ' ' || s[*i] == '\t' || s[*i] == '\n' || s[*i] == '\r')) (*i)++; +} + +static bool scr_snap_expect(const char *s, size_t n, size_t *i, const char *text) { + size_t len = strlen(text); + if (*i + len > n || memcmp(s + *i, text, len) != 0) return false; + *i += len; + return true; +} + +/* Parses the file's exports[`key`] = `value`; statements. Values are + * escaped again, as Node escapes the exports it read. */ +static bool scr_snap_parse(ScrSnapFile *f, const char *s, size_t n) { + size_t i = 0; + for (;;) { + scr_snap_skip_space(s, n, &i); + if (i >= n) return true; + if (!scr_snap_expect(s, n, &i, "exports[`")) return false; + ScrStr *key = scr_snap_template(s, n, &i); + if (!key) return false; + scr_snap_skip_space(s, n, &i); + bool ok = scr_snap_expect(s, n, &i, "]"); + scr_snap_skip_space(s, n, &i); + ok = ok && scr_snap_expect(s, n, &i, "="); + scr_snap_skip_space(s, n, &i); + ok = ok && scr_snap_expect(s, n, &i, "`"); + ScrStr *raw = ok ? scr_snap_template(s, n, &i) : NULL; + if (!raw) { + scr_str_release(key); + return false; + } + scr_snap_skip_space(s, n, &i); + (void)scr_snap_expect(s, n, &i, ";"); + ScrJsonBuf b; + scr_jb_init(&b); + scr_snap_put_escaped(&b, raw->data, raw->len); + scr_str_release(raw); + scr_snap_set(f, key, scr_jb_finish(&b)); + } +} + +static bool scr_snap_read(ScrSnapFile *f) { + if (f->loaded) return true; + FILE *fp = fopen(f->path->data, "rb"); + if (!fp) { + scr_snap_throw_file("read", f->path, errno == ENOENT); + return false; + } + ScrJsonBuf b; + scr_jb_init(&b); + char chunk[8192]; + size_t got; + while ((got = fread(chunk, 1, sizeof chunk, fp)) > 0) scr_jb_write(&b, chunk, got); + bool read_ok = !ferror(fp); + fclose(fp); + ScrStr *text = scr_jb_finish(&b); + bool ok = read_ok && scr_snap_parse(f, text->data, text->len); + scr_str_release(text); + if (!ok) { + scr_snap_throw_file("read", f->path, false); + return false; + } + f->loaded = true; + return true; +} + +/* Node's escapeSnapshotKey: the JSON string body of the id, with ` and + * ${ escaped (+1). Update mode stores and sorts keys in this form. */ +static ScrStr *scr_snap_escape_key(const ScrStr *key) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_put_json_str(&b, key); + ScrStr *json = scr_jb_finish(&b); + const char *s = json->data + 1; + size_t n = json->len - 2; + ScrJsonBuf out; + scr_jb_init(&out); + for (size_t i = 0; i < n; i++) { + if (s[i] == '`') scr_jb_puts(&out, "\\`"); + else if (s[i] == '$' && i + 1 < n && s[i + 1] == '{') scr_jb_puts(&out, "\\$"); + else scr_jb_putc(&out, s[i]); + } + scr_str_release(json); + return scr_jb_finish(&out); +} + +/* t.assert.snapshot: the stored value for the test's next id (+1), or the + * recorded `value` in update mode. NULL after a throw. */ +ScrStr *scr_test_ctx_snapshot(ScrTestCtx *t, ScrStr *value, ScrStr *file) { + ScrSnapFile *f = scr_snap_file(file); + ScrStr *name = scr_test_ctx_full_name(t); + ScrStr *id = scr_snap_next_id(f, name); + scr_str_release(name); + if (scr_test_update_snapshots) { + scr_snap_set(f, scr_snap_escape_key(id), scr_str_retain(value)); + scr_str_release(id); + return scr_str_retain(value); + } + if (!scr_snap_read(f)) { + scr_str_release(id); + return NULL; + } + ScrSnapEntry *e = scr_snap_find(f, id); + if (!e) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Snapshot '"); + scr_jb_put_str(&b, id); + scr_jb_puts(&b, "' not found in '"); + scr_jb_put_str(&b, f->path); + scr_jb_puts(&b, ".' Missing snapshots can be generated by rerunning the command with the " + "--test-update-snapshots flag."); + ScrStr *msg = scr_jb_finish(&b); + scr_snap_throw_state(msg->data, msg->len); + scr_str_release(msg); + scr_str_release(id); + return NULL; + } + scr_str_release(id); + return scr_str_retain(e->value); +} + +static bool scr_snap_write_text(const ScrStr *path, const char *data, size_t len) { + FILE *fp = fopen(path->data, "wb"); + if (!fp) return false; + bool ok = fwrite(data, 1, len, fp) == len; + return fclose(fp) == 0 && ok; +} + +/* mkdirSync(dirname(path), { recursive: true }); false after an error. */ +static bool scr_snap_mkdir_parent(const ScrStr *path) { + size_t end = path->len; + while (end > 0 && path->data[end - 1] != '/' +#ifdef _WIN32 + && path->data[end - 1] != '\\' +#endif + ) + end--; + if (end <= 1) return true; + ScrStr *dir = scr_str_new(path->data, end - 1); + scr_fs_mkdir_recursive(dir); + scr_str_release(dir); + if (!scr_exc_pending()) return true; + scr_exc_clear(); + return false; +} + +/* t.assert.fileSnapshot: the file's contents (+1), or the recorded value + * in update mode, which writes the file. NULL after a throw. */ +ScrStr *scr_test_file_snapshot(ScrStr *value, ScrStr *path) { + if (scr_test_update_snapshots) { + if (!scr_snap_mkdir_parent(path) || !scr_snap_write_text(path, value->data, value->len)) { + scr_snap_throw_file("write", path, false); + return NULL; + } + return scr_str_retain(value); + } + FILE *fp = fopen(path->data, "rb"); + if (!fp) { + scr_snap_throw_file("read", path, errno == ENOENT); + return NULL; + } + ScrJsonBuf b; + scr_jb_init(&b); + char chunk[8192]; + size_t got; + while ((got = fread(chunk, 1, sizeof chunk, fp)) > 0) scr_jb_write(&b, chunk, got); + bool ok = !ferror(fp); + fclose(fp); + ScrStr *text = scr_jb_finish(&b); + if (!ok) { + scr_str_release(text); + scr_snap_throw_file("read", path, false); + return NULL; + } + return text; +} + +/* The next UTF-16 code unit of a UTF-8 key: *low carries the low + * surrogate of an astral character to the following call. */ +static unsigned long scr_snap_unit(const ScrStr *s, size_t *i, unsigned long *low) { + if (*low) { + unsigned long u = *low; + *low = 0; + return u; + } + const unsigned char *d = (const unsigned char *)s->data; + unsigned char c = d[*i]; + size_t len = c < 0x80 ? 1 : c < 0xE0 ? 2 : c < 0xF0 ? 3 : 4; + if (*i + len > s->len) len = s->len - *i; + unsigned long cp = len == 1 ? c : len == 2 ? (c & 0x1Fu) : len == 3 ? (c & 0x0Fu) : (c & 0x07u); + for (size_t k = 1; k < len; k++) cp = (cp << 6) | (d[*i + k] & 0x3Fu); + *i += len; + if (cp < 0x10000) return cp; + *low = 0xDC00 + ((cp - 0x10000) & 0x3FF); + return 0xD800 + ((cp - 0x10000) >> 10); +} + +/* Array.prototype.sort's default order: UTF-16 code units. */ +static int scr_snap_key_cmp(const void *a, const void *b) { + const ScrStr *x = ((const ScrSnapEntry *)a)->key, *y = ((const ScrSnapEntry *)b)->key; + size_t i = 0, j = 0; + unsigned long xl = 0, yl = 0; + while ((i < x->len || xl) && (j < y->len || yl)) { + unsigned long u = scr_snap_unit(x, &i, &xl), v = scr_snap_unit(y, &j, &yl); + if (u != v) return u < v ? -1 : 1; + } + bool xmore = i < x->len || xl, ymore = j < y->len || yl; + return xmore == ymore ? 0 : xmore ? 1 : -1; +} + + +/* writeSnapshotFiles(): every snapshot file of the run, in update mode. */ +static void scr_snap_write_all(void) { + if (!scr_test_update_snapshots) return; + for (ScrSnapFile *f = scr_snap_files; f; f = f->next) { + qsort(f->entries, f->n, sizeof *f->entries, scr_snap_key_cmp); + ScrJsonBuf b; + scr_jb_init(&b); + for (size_t i = 0; i < f->n; i++) { + if (i > 0) scr_jb_putc(&b, '\n'); + scr_jb_puts(&b, "exports[`"); + scr_jb_put_str(&b, f->entries[i].key); + scr_jb_puts(&b, "`] = `"); + scr_jb_put_str(&b, f->entries[i].value); + scr_jb_puts(&b, "`;\n"); + } + ScrStr *text = scr_jb_finish(&b); + if (!scr_snap_mkdir_parent(f->path) || !scr_snap_write_text(f->path, text->data, text->len)) { + fflush(stdout); + fprintf(stderr, "Error [ERR_INVALID_STATE]: Invalid state: Cannot write snapshot file '%.*s.'\n", + (int)f->path->len, f->path->data); + scr_test_suite_hook_failed = true; + } + scr_str_release(text); + } +} + +static void scr_snap_free_all(void) { + while (scr_snap_files) { + ScrSnapFile *f = scr_snap_files; + scr_snap_files = f->next; + for (size_t i = 0; i < f->n; i++) { + scr_str_release(f->entries[i].key); + scr_str_release(f->entries[i].value); + } + for (size_t i = 0; i < f->nnames; i++) scr_str_release(f->names[i]); + free(f->entries); + free(f->names); + free(f->counts); + scr_str_release(f->path); + free(f); + } +} + /* ── the runner fiber ────────────────────────────────────────────────── */ static void scr_test_runner_entry(ScrFiber *self, void *argpack) { @@ -823,6 +1393,7 @@ static void scr_test_runner_entry(ScrFiber *self, void *argpack) { ScrTestCtx *root = scr_test_root; scr_test_run_children(root); scr_test_run_done = true; + scr_snap_write_all(); scr_test_count(root); scr_test_info_line("tests", (double)scr_test_ct_tests); scr_test_info_line("suites", (double)scr_test_ct_suites); @@ -1618,6 +2189,7 @@ int scr_test_exit_code(void) { if (scr_exit_frees()) { scr_test_hooks_free(&scr_test_mocks); scr_mt_free_all(); + scr_snap_free_all(); } /* The runner never finished: a test awaited a promise nobody settles * and the loop exhausted (Node cancels the test and exits 1 — the diff --git a/packages/runtime/src/scr_test.h b/packages/runtime/src/scr_test.h index 3e60f6abd..b1078cccd 100644 --- a/packages/runtime/src/scr_test.h +++ b/packages/runtime/src/scr_test.h @@ -57,6 +57,20 @@ void scr_test_mock_reset(void); void scr_test_ctx_mock_track(ScrTestCtx *t, ScrClosure *restore); void scr_test_ctx_mock_restore_all(ScrTestCtx *t); void scr_test_ctx_mock_reset(ScrTestCtx *t); +/* Called by node:test programs right after scr_lib_init: a leading + * --test-update-snapshots argument, Node's flag, turns on snapshot update + * mode and moves from process.argv to process.execArgv. */ +void scr_test_init(void); +/* t.assert.snapshot and t.assert.fileSnapshot. snapshot_value escapes + * and wraps a serialized value (+1); ctx_snapshot returns the stored value + * for the test's next snapshot id in `file` and file_snapshot the + * contents of `path` (+1), or the recorded value in update mode. Both + * throw Node's ERR_INVALID_STATE for missing or unreadable snapshots and + * return NULL then. */ +ScrStr *scr_test_snapshot_value(ScrStr *serialized); +ScrDyn *scr_test_snapshot_serialize(ScrDyn *value, ScrDyn *serializers); +ScrStr *scr_test_ctx_snapshot(ScrTestCtx *t, ScrStr *value, ScrStr *file); +ScrStr *scr_test_file_snapshot(ScrStr *value, ScrStr *path); /* mock.timers on the module's `mock` and on t.mock: enable({ apis, now }) * (`apis` one code per listed API: 'T' setTimeout, 'I' setInterval, 'M' * setImmediate, 'D' Date, 'W' scheduler.wait, 'A' AbortSignal.timeout; diff --git a/tests/diagnostics/snapshot-fences.ts b/tests/diagnostics/snapshot-fences.ts new file mode 100644 index 000000000..afdf35b70 --- /dev/null +++ b/tests/diagnostics/snapshot-fences.ts @@ -0,0 +1,19 @@ +// t.assert.snapshot and t.assert.fileSnapshot lower with no options and +// with an options object literal whose serializers is an array literal. +// The rest is refused, each with a named diagnostic: options that are not +// an object literal, a serializers list that is not an array literal, and +// a spread serializer. +import { test } from "node:test"; + +test("snapshots", (t) => { + t.assert.snapshot({ a: 1 }); // lowers + t.assert.snapshot(1, { serializers: [(v: number) => `${v}`] }); // lowers + t.assert.fileSnapshot("text", "out.txt"); // lowers + + const options = { serializers: [(v: unknown) => String(v)] }; + t.assert.snapshot(1, options); // options that are not an object literal + + const serializers = [(v: unknown) => String(v)]; + t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal + t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer +}); diff --git a/tests/fixtures/node-test/cases/snapshot-files/value.json b/tests/fixtures/node-test/cases/snapshot-files/value.json new file mode 100644 index 000000000..bb44c02ab --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot-files/value.json @@ -0,0 +1,6 @@ +{ + "f": 1, + "list": [ + "a" + ] +} \ No newline at end of file diff --git a/tests/fixtures/node-test/cases/snapshot.ts b/tests/fixtures/node-test/cases/snapshot.ts new file mode 100644 index 000000000..eb5d97ce4 --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot.ts @@ -0,0 +1,72 @@ +// @exit: 1 +// t.assert.snapshot and t.assert.fileSnapshot in compare mode, against the +// snapshot.ts.snapshot file `node --test-update-snapshots` wrote (then +// edited so that one snapshot differs and one is missing). Values serialize +// with JSON.stringify(value, null, 2) unless options.serializers replace +// it; each test numbers its snapshots under its full name; a differing +// snapshot fails as assert.strictEqual does, and missing snapshots and +// files and throwing serializers throw ERR_INVALID_STATE. +import { describe, test } from "node:test"; + +describe("values", () => { + test("records and arrays", (t) => { + t.assert.snapshot({ zeta: 1, alpha: [1, "two", { b: true, a: null }] }); + t.assert.snapshot([{ id: 3, name: "n" }]); + }); + test("strings", (t) => { + t.assert.snapshot("plain"); + t.assert.snapshot("tick ` dollar ${x} backslash \\ newline \n tab \t"); + t.assert.snapshot("unicode é 漢 😀"); + }); + test("scalars", (t) => { + t.assert.snapshot(42); + t.assert.snapshot(-0.5); + t.assert.snapshot(true); + t.assert.snapshot(null); + t.assert.snapshot(undefined); + }); +}); + +test("name with `backticks` and ${braces}", (t) => { + t.assert.snapshot({ ok: true }); +}); + +describe("serializers", () => { + test("run in order", (t) => { + t.assert.snapshot({ n: 5 }, { serializers: [(v: { n: number }) => v.n * 2, (v: number) => `n is ${v}`] }); + t.assert.snapshot("x", { serializers: [(v: string) => v.toUpperCase()] }); + t.assert.snapshot(7, { serializers: [(v: number) => v + 1] }); + }); + test("a throwing serializer", (t) => { + t.assert.snapshot(1, { + serializers: [ + () => { + throw new Error("no"); + }, + ], + }); + }); + test("a circular value", (t) => { + const loop: { self?: unknown } = {}; + loop.self = loop; + t.assert.snapshot(loop); + }); +}); + +test("counts toward the plan", { plan: 2 }, (t) => { + t.assert.snapshot(1); + t.assert.ok(true); +}); + +test("differs from the stored snapshot", (t) => { + t.assert.snapshot({ count: 2 }); +}); + +test("missing from the snapshot file", (t) => { + t.assert.snapshot("new"); +}); + +test("file snapshots", (t) => { + t.assert.fileSnapshot({ f: 1, list: ["a"] }, "tests/fixtures/node-test/cases/snapshot-files/value.json"); + t.assert.fileSnapshot("text", "tests/fixtures/node-test/cases/snapshot-files/missing.txt"); +}); diff --git a/tests/fixtures/node-test/cases/snapshot.ts.snapshot b/tests/fixtures/node-test/cases/snapshot.ts.snapshot new file mode 100644 index 000000000..ba201eb68 --- /dev/null +++ b/tests/fixtures/node-test/cases/snapshot.ts.snapshot @@ -0,0 +1,82 @@ +exports[`counts toward the plan 1`] = ` +1 +`; + +exports[`differs from the stored snapshot 1`] = ` +{ + "count": 1 +} +`; + +exports[`name with \`backticks\` and \${braces} 1`] = ` +{ + "ok": true +} +`; + +exports[`serializers > run in order 1`] = ` +n is 10 +`; + +exports[`serializers > run in order 2`] = ` +X +`; + +exports[`serializers > run in order 3`] = ` +8 +`; + +exports[`values > records and arrays 1`] = ` +{ + "zeta": 1, + "alpha": [ + 1, + "two", + { + "b": true, + "a": null + } + ] +} +`; + +exports[`values > records and arrays 2`] = ` +[ + { + "id": 3, + "name": "n" + } +] +`; + +exports[`values > scalars 1`] = ` +42 +`; + +exports[`values > scalars 2`] = ` +-0.5 +`; + +exports[`values > scalars 3`] = ` +true +`; + +exports[`values > scalars 4`] = ` +null +`; + +exports[`values > scalars 5`] = ` +undefined +`; + +exports[`values > strings 1`] = ` +"plain" +`; + +exports[`values > strings 2`] = ` +"tick \` dollar \${x} backslash \\\\ newline \\n tab \\t" +`; + +exports[`values > strings 3`] = ` +"unicode é 漢 😀" +`; diff --git a/tests/fixtures/node-test/update/snapshot-update.ts b/tests/fixtures/node-test/update/snapshot-update.ts new file mode 100644 index 000000000..7d14509d1 --- /dev/null +++ b/tests/fixtures/node-test/update/snapshot-update.ts @@ -0,0 +1,27 @@ +// Run with --test-update-snapshots (before the program arguments): the +// snapshot assertions record their values, the run writes +// snapshot-update.ts.snapshot with sorted keys and out/value.json, and the +// flag is in process.execArgv instead of process.argv. +import { describe, test } from "node:test"; + +console.log(JSON.stringify(process.argv.slice(2)), JSON.stringify(process.execArgv)); + +describe("suite", () => { + test("values", (t) => { + t.assert.snapshot({ b: 1, a: "x`${y}\\z\n" }); + t.assert.snapshot([1, 2]); + t.assert.snapshot("é 😀 \u0001"); + }); + test("serializers", (t) => { + t.assert.snapshot(3, { serializers: [(v: number) => v * 2] }); + }); +}); + +test("zz last", (t) => { + t.assert.snapshot(undefined); +}); + +test("aa `first` ${x}", (t) => { + t.assert.snapshot(null); + t.assert.fileSnapshot({ f: [1] }, "out/value.json"); +}); diff --git a/tests/harness/__snapshots__/snapshot-fences.ts.txt b/tests/harness/__snapshots__/snapshot-fences.ts.txt new file mode 100644 index 000000000..3f71fd710 --- /dev/null +++ b/tests/harness/__snapshots__/snapshot-fences.ts.txt @@ -0,0 +1,26 @@ +snapshot-fences.ts:14:24 - error SC2020: 't.assert.snapshot options that are not an object literal' is part of the standard library types but has no scriptc lowering yet + + 13 | const options = { serializers: [(v: unknown) => String(v)] }; + 14 | t.assert.snapshot(1, options); // options that are not an object literal + | ^~~~~~~ + 15 | + + hint: pass the options as an object literal: { serializers: [(value) => ...] } + +snapshot-fences.ts:17:26 - error SC2020: 't.assert.snapshot with a non-literal serializers list' is part of the standard library types but has no scriptc lowering yet + + 16 | const serializers = [(v: unknown) => String(v)]; + 17 | t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal + | ^~~~~~~~~~~ + 18 | t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer + + hint: pass the options as an object literal: { serializers: [(value) => ...] } + +snapshot-fences.ts:18:55 - error SC2020: 't.assert.fileSnapshot with a spread serializer' is part of the standard library types but has no scriptc lowering yet + + 17 | t.assert.snapshot(1, { serializers }); // a serializers list that is not an array literal + 18 | t.assert.fileSnapshot(1, "out.txt", { serializers: [...serializers] }); // a spread serializer + | ^~~~~~~~~~~~~~ + 19 | }); + + hint: pass the options as an object literal: { serializers: [(value) => ...] } \ No newline at end of file diff --git a/tests/harness/node-test-normalize.ts b/tests/harness/node-test-normalize.ts index c150bb707..e02b5169b 100644 --- a/tests/harness/node-test-normalize.ts +++ b/tests/harness/node-test-normalize.ts @@ -2,10 +2,11 @@ * node-test.test.ts's function, extracted so the Linux lane applies the * IDENTICAL scrub to both of its in-container lanes. Node's spec reporter * embeds real durations in every line (raw byte-compare is impossible for - * ANY node:test program); stack frames and the inspect property block a - * frame opens are runner-internal output the compiled runtime - * deliberately omits (SEMANTICS.md). Everything else must match - * byte-exactly. */ + * ANY node:test program); stack frames, the "... N lines matching cause + * stack trace ..." line inspect folds an error cause's shared frames into, + * and the inspect property block a frame opens are runner-internal output + * the compiled runtime deliberately omits (SEMANTICS.md). Everything else + * must match byte-exactly. */ /** The documented normalization — durations, frames, the property block. */ export function normalizeNodeTestOutput(stdout: string): string { @@ -16,6 +17,7 @@ export function normalizeNodeTestOutput(stdout: string): string { if (/^\s*\}\s*$/.test(line)) inPropBlock = false; continue; } + if (/^\s+\.\.\. \d+ lines? matching cause stack trace \.\.\.$/.test(line)) continue; if (/^\s+at /.test(line)) { if (line.trimEnd().endsWith("{")) inPropBlock = true; continue; diff --git a/tests/harness/node-test.test.ts b/tests/harness/node-test.test.ts index 7cf10ac50..054a77aaa 100644 --- a/tests/harness/node-test.test.ts +++ b/tests/harness/node-test.test.ts @@ -11,10 +11,12 @@ * NORMALIZATIONS (both lanes): * - "(1.234ms)" duration parentheticals → "(Xms)" * - the "ℹ duration_ms N" summary line → "ℹ duration_ms X" - * - stack frames (" at ...") and the inspect property block a frame - * opens ("... {" through its closing "}") are dropped — Node prints - * its runner-internal frames and an error-property dump the compiled - * runtime deliberately omits (SEMANTICS.md). + * - stack frames (" at ..."), the "... N lines matching cause stack + * trace ..." line that folds an error cause's shared frames, and the + * inspect property block a frame opens ("... {" through its closing + * "}") are dropped — Node prints its runner-internal frames and an + * error-property dump the compiled runtime deliberately omits + * (SEMANTICS.md). * * Fixtures never console.log inside test bodies or hooks: Node's * reporter stream lags console output RACILY (its ordering varies run to @@ -25,7 +27,8 @@ * the RC audit over every case. */ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { globSync, mkdirSync, readFileSync } from "node:fs"; +import { copyFileSync, globSync, mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { compile } from "@scriptc/compiler"; @@ -41,11 +44,12 @@ interface ProgramRun { exitCode: number; } -function runLane(cmd: string, args: string[]): Promise { +function runLane(cmd: string, args: string[], cwd = repoRoot): Promise { return new Promise((resolve, reject) => { - // cwd pinned to the repo root: both reporters render "test at" paths - // relative to the working directory, so the lanes must share one. - const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd: repoRoot }); + // cwd pinned to the repo root by default: both reporters render "test + // at" paths relative to the working directory, so the lanes must share + // one. + const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd }); const out: Buffer[] = []; let errText = ""; const timer = setTimeout(() => { @@ -125,3 +129,48 @@ describe(`node:test differential (${cases.length} programs${sanitize ? ", saniti 120_000, ); }); + +/* Snapshot update mode: Node's --test-update-snapshots flag, which the + * compiled binary takes as its leading argument. Each lane runs the + * fixture from its own temporary copy (the snapshot file sits beside the + * entry file and fileSnapshot paths resolve against the working + * directory), writes the snapshot files, and then passes in compare mode + * against them. The written files and the printed argv/execArgv match. */ +describe(`node:test snapshot update mode${sanitize ? " (sanitized)" : ""}`, () => { + test("--test-update-snapshots writes the files Node writes", async () => { + const fixture = join(fixturesRoot, "update/snapshot-update.ts"); + const lanes = await Promise.all( + (["node", "native"] as const).map(async (lane) => { + const dir = mkdtempSync(join(tmpdir(), `scriptc-snapshot-${lane}-`)); + const entry = join(dir, "snapshot-update.ts"); + copyFileSync(fixture, entry); + const binary = lane === "native" ? await build(entry) : null; + const run = (update: boolean) => + binary + ? runLane(binary, [...(update ? ["--test-update-snapshots"] : []), "one", "two"], dir) + : runLane( + "node", + [...(update ? ["--test-update-snapshots"] : []), entry, "one", "two"], + dir, + ); + const update = await run(true); + const compare = await run(false); + return { + update, + compare, + snapshot: readFileSync(`${entry}.snapshot`, "utf8"), + file: readFileSync(join(dir, "out/value.json"), "utf8"), + }; + }), + ); + const [node, native] = lanes as [(typeof lanes)[number], (typeof lanes)[number]]; + expect(native.snapshot).toBe(node.snapshot); + expect(native.file).toBe(node.file); + expect(normalize(native.update.stdout)).toBe(normalize(node.update.stdout)); + expect(normalize(native.compare.stdout)).toBe(normalize(node.compare.stdout)); + for (const lane of lanes) { + expect(lane.update.exitCode).toBe(0); + expect(lane.compare.exitCode).toBe(0); + } + }, 120_000); +}); From d09b380098cf505db8db55696eeb08d3b57d06ca Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:35:23 -0500 Subject: [PATCH 09/17] Serialize checked-dynamic members of typed records like JSON.stringify The undeclared properties a checked record keeps from its source, and the entries of an `unknown` index signature, now follow SerializeJSONProperty: toJSON runs with the member's key, getters are read, a Buffer becomes its { type, data } form, undefined, function and symbol results are dropped, and a circular structure throws the circular-structure TypeError instead of overflowing the stack. Members whose writing cannot run program code are written directly; the others go through the JSON.stringify protocol walk. Index-signature entries are written from the overflow map in place, without building a key array. --- packages/compiler/src/backend/llvm/walkers.ts | 43 +- .../5841-json-typed-dyn-members.ts.json | 6 + packages/runtime/src/scr_json.c | 430 +++++++++++++----- packages/runtime/src/scr_json.h | 11 +- tests/corpus/5841-json-typed-dyn-members.ts | 76 ++++ tests/harness/corpus-costs.json | 2 + 6 files changed, 418 insertions(+), 150 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5841-json-typed-dyn-members.ts.json create mode 100644 tests/corpus/5841-json-typed-dyn-members.ts diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index d3c4f2990..9b8d80b87 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -64,9 +64,6 @@ export class LlWalkers { private get S(): "i32" | "i64" { return this.host.sizeType; } - private abiOffset(native64: number, wasm32: number): number { - return this.S === "i32" ? wasm32 : native64; - } /** The value-parameter LLVM type of a writer for `t`. */ private valTy(t: IrType): string { @@ -220,10 +217,10 @@ export class LlWalkers { B.line(`call void @scr_jb_put_json_str(ptr %b, ptr %v)`); break; case "dyn": - // Overflow values under an `unknown` index signature: the checked-dynamic tree - // serializes itself (runtime walker). Bare stringify of dyn stays - // frontend-fenced; this writer is reachable only through overflow - // entries. + // A checked-dynamic value serializes itself (runtime walker). + // Bare stringify of dyn stays frontend-fenced, and overflow + // entries under an `unknown` index signature go through + // scr_jb_put_dyn_entry, which also runs the value's toJSON. this.host.declare(`declare void @scr_jb_put_dyn(ptr, ptr)`); B.line(`call void @scr_jb_put_dyn(ptr %b, ptr %v)`); break; @@ -494,10 +491,13 @@ export class LlWalkers { } /** Whether the walker for `t` can leave an exception pending: cyclic - * values, and records serialized through a kept checked source. */ + * values, records serialized through a kept checked source, and + * checked-dynamic values (toJSON methods, getters, BigInts). */ jsonWriteMayThrow(t: IrType, seen = new Set()): boolean { if (traceAdapter(this.host, t) !== null) return true; switch (t.kind) { + case "dyn": + return true; case "array": return this.jsonWriteMayThrow(t.elem, seen); case "union": @@ -537,6 +537,15 @@ export class LlWalkers { `${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + 1}`, ); B.line(`${ovf} = load ptr, ptr ${ovfp} ; overflow map`); + if (iv.kind === "dyn") { + // The runtime writes every entry with JSON.stringify's member rules: + // each value's toJSON runs with its key before the entry is dropped + // or written, and containers below it join the circular-structure + // stack. It may leave an exception pending. + host.declare(`declare void @scr_jb_put_dyn_entries(ptr, ptr, ptr, ptr)`); + B.line(`call void @scr_jb_put_dyn_entries(ptr %b, ptr %v, ptr ${ovf}, ptr ${first})`); + return; + } host.declare(`declare ptr @scr_map_keys_js_order(ptr)`); host.declare(`declare double @scr_arr_len(ptr)`); host.declare(`declare ptr @scr_arr_get_ref(ptr, double)`); @@ -576,23 +585,9 @@ export class LlWalkers { B.line(`${val} = call ptr @scr_map_get_str_ref(ptr ${ovf}, ptr ${k}) ; value (+1)`); } // Undefined-valued entries drop, exactly the optional-field rule: a - // dyn value whose dyn kind follows its size_t RC word (enum - // member 6 — scr_runtime.h's ScrDynKind), or a union holding its - // undefined arm. + // union holding its undefined arm. let skipUndef: string | null = null; - if (iv.kind === "dyn") { - const kp = B.tmp(); - const kd = B.tmp(); - const isu = B.tmp(); - B.line( - `${kp} = getelementptr inbounds i8, ptr ${val}, i64 ${this.abiOffset(8, 4)} ; ->kind`, - ); - B.line(`${kd} = load i32, ptr ${kp}`); - B.line( - `${isu} = icmp eq i32 ${kd}, 6 ; SCR_DYN_UNDEF (NULL is 0 — null members DO serialize)`, - ); - skipUndef = isu; - } else if (iv.kind === "union" && undefinedArmTag(iv, this.host.unionsById) >= 0) { + if (iv.kind === "union" && undefinedArmTag(iv, this.host.unionsById) >= 0) { const tag = this.unionTag(B, val, iv.unionId); const isu = B.tmp(); B.line(`${isu} = icmp eq i32 ${tag}, ${undefinedArmTag(iv, this.host.unionsById)}`); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5841-json-typed-dyn-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5841-json-typed-dyn-members.ts.json new file mode 100644 index 000000000..08d5b62db --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5841-json-typed-dyn-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5841-json-typed-dyn-members.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 90b8f822e..0c5e98dc6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -502,6 +502,7 @@ typedef struct ScrJsonSeenEnt { const void *ptr; bool is_array; const char *prop; /* static property edge (emitted C literal) */ + size_t prop_len; /* its length, or SIZE_MAX for a NUL-terminated name */ const ScrStr *prop_str; /* overflow-key edge (borrowed for the write) */ size_t index; /* array/tuple index edge */ } ScrJsonSeenEnt; @@ -509,7 +510,7 @@ typedef struct ScrJsonSeenEnt { static void scr_jb_put_edge(ScrJsonBuf *m, const ScrJsonSeenEnt *e) { if (e->prop || e->prop_str) { scr_jb_puts(m, "property '"); - if (e->prop) scr_jb_puts(m, e->prop); + if (e->prop) scr_jb_write(m, e->prop, e->prop_len == SIZE_MAX ? strlen(e->prop) : e->prop_len); else scr_jb_write(m, e->prop_str->data, e->prop_str->len); scr_jb_putc(m, '\''); return; @@ -559,6 +560,7 @@ bool scr_jb_enter(ScrJsonBuf *b, const void *v, bool is_array) { e->ptr = v; e->is_array = is_array; e->prop = NULL; + e->prop_len = SIZE_MAX; e->prop_str = NULL; e->index = 0; return true; @@ -571,6 +573,16 @@ void scr_jb_leave(ScrJsonBuf *b) { void scr_jb_edge_prop(ScrJsonBuf *b, const char *name) { ScrJsonSeenEnt *e = &b->seen[b->seen_len - 1]; e->prop = name; + e->prop_len = SIZE_MAX; + e->prop_str = NULL; +} + +/* A property edge whose name is not NUL-terminated (a declared field name + * from a record source's strided table). */ +static void scr_jb_edge_span(ScrJsonBuf *b, const char *name, size_t len) { + ScrJsonSeenEnt *e = &b->seen[b->seen_len - 1]; + e->prop = name; + e->prop_len = len; e->prop_str = NULL; } @@ -2666,54 +2678,116 @@ ScrDyn *scr_record_source_apply(ScrDyn *snapshot, const void *rec) { return merged; } +static ScrDyn *scr_json_member(const ScrDyn *holder, const ScrStr *key); +static bool scr_jb_try_pure_member(ScrJsonBuf *b, const char *key, size_t key_len, + const ScrStr *key_str, const ScrDyn *value, bool *first); +static bool scr_jb_put_member(ScrJsonBuf *b, const ScrStr *key, ScrDyn *value, bool *first, + void *rec); + /* JSON for a record that kept a source: the view's own-key order, declared * fields through the compiled writer (`field` writes the separator, key * and value of field `index` unless the record lacks it, and answers - * whether it wrote), every other enumerable property through the dyn - * writer with JSON.stringify's member rules, then declared fields the view - * had not seen. May leave an exception pending (a BigInt property). */ + * whether it wrote), every other enumerable property with JSON.stringify's + * member rules (accessors read, toJSON called with the key, undefined, + * function and symbol results dropped, circular structures rejected), then + * declared fields the view had not seen. May leave an exception pending + * (a BigInt property, a throwing toJSON or getter, a circular structure). */ void scr_jb_put_record_source(ScrJsonBuf *b, void *rec, bool (*field)(ScrJsonBuf *, void *, size_t, bool)) { if (!scr_jb_try_record_source(b, rec, field)) scr_jb_puts(b, "{}"); } +/* Writes declared field `index`; when the record is on the circular- + * structure stack, the field's name becomes its edge first. */ +static bool scr_jb_put_source_field(ScrJsonBuf *b, void *rec, const ScrRecordSource *source, + size_t index, bool first, bool on_stack, + bool (*field)(ScrJsonBuf *, void *, size_t, bool)) { + if (on_stack) { + size_t len; + memcpy(&len, source->lens + index * source->len_stride, sizeof len); + const char *name; + memcpy(&name, source->names + index * source->name_stride, sizeof name); + scr_jb_edge_span(b, name, len); + } + return field(b, rec, index, first); +} + /* The same in one lookup: writes the record and answers true when it kept * a source, otherwise writes nothing and answers false. */ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, bool (*field)(ScrJsonBuf *, void *, size_t, bool)) { - ScrRecordSource *source = scr_record_source_find(rec); - if (!source) return false; + ScrRecordSource *found = scr_record_source_find(rec); + if (!found) return false; + /* Program code run by a member can add record sources and move the + * table's entries, so the walk keeps its own copy. */ + const ScrRecordSource source = *found; + /* Walkers over cycle-capable shapes have pushed the record already. + * Others push it before the first member that needs the protocol walk, + * so a member leading back to the record closes the circle at the + * record. */ + bool on_stack = b->seen_len && b->seen[b->seen_len - 1].ptr == rec; + bool entered = false; + ScrDyn *view = scr_dyn_retain(source.view); scr_jb_putc(b, '{'); bool first = true; - { - size_t count = source->count; - bool small[64] = { false }; - bool *seen = count <= 64 ? small : calloc(count, sizeof *seen); - if (!seen) scr_json_oom(); - const ScrDyn *view = source->view; - for (size_t i = 0; i < view->v.obj.len && !scr_exc_pending(); i++) { - const ScrDynEntry *entry = &view->v.obj.entries[i]; - size_t index = scr_record_source_index(source, entry->key, entry->key_len); + size_t count = source.count; + bool small[64] = { false }; + bool *seen = count <= 64 ? small : calloc(count, sizeof *seen); + if (!seen) scr_json_oom(); + /* Members whose writing cannot run program code are written in place. + * The first member that can (an accessor, a toJSON method, a getter below + * it) ends that loop: the remaining keys are taken first, as + * SerializeJSONObject takes them before reading any value. */ + size_t i = 0; + for (; i < view->v.obj.len && !scr_exc_pending(); i++) { + const ScrDynEntry *entry = &view->v.obj.entries[i]; + size_t index = scr_record_source_index(&source, entry->key, entry->key_len); + if (index < count) { + if (!seen[index] && scr_jb_put_source_field(b, rec, &source, index, first, on_stack, field)) + first = false; + seen[index] = true; + continue; + } + if (!entry->enumerable) continue; + if (entry->accessor || + !scr_jb_try_pure_member(b, entry->key, entry->key_len, NULL, entry->value, &first)) + break; + } + if (i < view->v.obj.len && !scr_exc_pending() && !on_stack) { + entered = scr_jb_enter(b, rec, false); + on_stack = entered; + } + if (i < view->v.obj.len && !scr_exc_pending()) { + ScrDyn *rest = scr_dyn_new_arr(); + for (size_t j = i; j < view->v.obj.len; j++) { + const ScrDynEntry *entry = &view->v.obj.entries[j]; + if (!entry->enumerable && !scr_record_source_declared(&source, entry->key, entry->key_len)) continue; + ScrStr *name = scr_str_new(entry->key, entry->key_len); + scr_dyn_arr_push(rest, scr_dyn_new_str(name)); + scr_str_release(name); + } + for (size_t j = 0; j < rest->v.arr.len && !scr_exc_pending(); j++) { + const ScrStr *key = rest->v.arr.items[j]->v.str; + size_t index = scr_record_source_index(&source, key->data, key->len); if (index < count) { - if (!seen[index] && field(b, rec, index, first)) first = false; + if (!seen[index] && scr_jb_put_source_field(b, rec, &source, index, first, on_stack, field)) + first = false; seen[index] = true; continue; } - if (!entry->enumerable || entry->accessor) continue; - ScrDynKind kind = entry->value->kind; - if (kind == SCR_DYN_UNDEF || kind == SCR_DYN_FUNC || kind == SCR_DYN_SYMBOL) continue; - if (!first) scr_jb_putc(b, ','); - first = false; - scr_jb_put_json_span(b, entry->key, entry->key_len); - scr_jb_putc(b, ':'); - scr_jb_put_dyn(b, entry->value); + ScrDyn *value = scr_json_member(view, key); + if (!value || !scr_jb_put_member(b, key, value, &first, NULL)) break; } - for (size_t index = 0; index < count && !scr_exc_pending(); index++) { - if (!seen[index] && field(b, rec, index, first)) first = false; - } - if (seen != small) free(seen); + scr_dyn_release(rest); } + for (size_t index = 0; index < count && !scr_exc_pending(); index++) { + if (!seen[index] && scr_jb_put_source_field(b, rec, &source, index, first, on_stack, field)) + first = false; + } + if (seen != small) free(seen); + scr_dyn_release(view); scr_jb_putc(b, '}'); + if (entered) scr_jb_leave(b); return true; } @@ -7156,99 +7230,6 @@ void scr_dyn_key_set_computed(ScrDyn *recv, ScrDyn *key, ScrDyn *value) { scr_str_release(name); } -/* Node's JSON.stringify over a dyn: object members holding undefined DROP, - * array slots holding undefined print null. A bare undefined never arrives - * (the record serializer drops the entry first); print null defensively. */ -void scr_jb_put_dyn(ScrJsonBuf *b, const ScrDyn *d) { - if (d->kind == SCR_DYN_PROXY) { scr_dyn_proxy_unsupported("JSON.stringify"); return; } - switch (d->kind) { - case SCR_DYN_NULL: - case SCR_DYN_UNDEF: - case SCR_DYN_SYMBOL: - case SCR_DYN_FUNC: /* JSON.stringify: functions serialize like undefined */ - scr_jb_puts(b, "null"); - return; - case SCR_DYN_BOOL: - scr_jb_puts(b, d->v.b ? "true" : "false"); - return; - case SCR_DYN_BIGINT: { - static const char message[] = "Do not know how to serialize a BigInt"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - return; - } - case SCR_DYN_NUM: - scr_jb_put_f64(b, d->v.num); - return; - case SCR_DYN_STR: - scr_jb_put_json_str(b, d->v.str); - return; - case SCR_DYN_BYTES: { - /* Node's JSON.stringify over a typed array: the index-keyed object - * form — {"0":1,"1":2}, using each view's numeric element kind. */ - scr_jb_putc(b, '{'); - for (size_t i = 0; i < d->v.bytes->len; i++) { - if (i > 0) scr_jb_putc(b, ','); - char idx[32]; - snprintf(idx, sizeof idx, "\"%zu\":", i); - scr_jb_puts(b, idx); - scr_jb_put_f64(b, scr_bytes_get(d->v.bytes, (double)i)); - } - scr_jb_putc(b, '}'); - return; - } - case SCR_DYN_ARR: - scr_jb_putc(b, '['); - for (size_t i = 0; i < d->v.arr.len; i++) { - if (i > 0) scr_jb_putc(b, ','); - scr_jb_put_dyn(b, d->v.arr.items[i]); - } - scr_jb_putc(b, ']'); - return; - case SCR_DYN_PROMISE: - /* No own enumerable properties — Node stringifies a promise as {}. */ - scr_jb_puts(b, "{}"); - return; - case SCR_DYN_HANDLE: { - /* Node's JSON.stringify over these classes throws the circular- - * structure TypeError with a V8 path dump we cannot reproduce — - * throw "not supported yet" instead of a wrong shape (SEMANTICS.md). */ - ScrJsonBuf m; - scr_jb_init(&m); - scr_jb_puts(&m, "JSON.stringify of a dynamic "); - scr_jb_puts(&m, scr_dyn_handle_cls(d)); - scr_jb_puts(&m, " is not supported yet"); - scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&m)); - scr_jb_puts(b, "null"); /* the buffer never surfaces: the pending throw wins */ - return; - } - - case SCR_DYN_TYPED_REF: { - ScrDyn *materialized = scr_dyn_typed_ref_materialize(d); - scr_jb_put_dyn(b, materialized); - scr_dyn_release(materialized); - return; - } - case SCR_DYN_OBJ: { - scr_jb_putc(b, '{'); - bool first = true; - for (size_t i = 0; i < d->v.obj.len; i++) { - const ScrDynEntry *e = &d->v.obj.entries[i]; - if (!e->enumerable) continue; - if (e->value->kind == SCR_DYN_UNDEF || e->value->kind == SCR_DYN_FUNC || e->value->kind == SCR_DYN_SYMBOL) continue; /* dropped, like Node */ - /* engine functions drop too */ - if (!first) scr_jb_putc(b, ','); - first = false; - scr_jb_put_json_span(b, e->key, e->key_len); - scr_jb_putc(b, ':'); - scr_jb_put_dyn(b, e->value); - } - scr_jb_putc(b, '}'); - return; - } - } - scr_trap("scriptc: internal error: invalid dyn kind\n"); -} - /* ── dynCheck failure path ─────────────────────────────────────────────── */ static void scr_dyn_path_render(ScrJsonBuf *b, const ScrDynPath *p) { @@ -9033,9 +9014,12 @@ static bool scr_json_live_container(const ScrDyn *value, bool inspect) { return true; } -static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const ScrDyn *replacer) { - ScrDyn *value = scr_json_member(holder, key); - if (!value) return NULL; +/* SerializeJSONProperty's toJSON step over a member value (consumed): Date, + * URL and net handles convert through their toJSON, a Buffer through its + * { type, data } view, and an object with a callable toJSON through that + * method, called with the key. Returns the value to serialize (+1), or NULL + * with an exception pending. */ +static ScrDyn *scr_json_to_json(ScrDyn *value, const ScrStr *key) { /* Inspect a typed reference without replacing its live identity. Later * members must read the source again after a callback mutates it. A live * array, or a live record without a `toJSON` field, needs no conversion. */ @@ -9070,6 +9054,13 @@ static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const S } else scr_dyn_release(method); } scr_dyn_release(view); + return value; +} + +static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const ScrDyn *replacer) { + ScrDyn *value = scr_json_member(holder, key); + if (!value) return NULL; + value = scr_json_to_json(value, key); if (!value) return NULL; ScrDyn *result = scr_json_callback(replacer, holder, key, value); scr_dyn_release(value); @@ -9365,6 +9356,197 @@ ScrStr *scr_util_format_j_text(ScrStr *text) { return scr_json_circular_or_rethrow(); } +/* The gap of a compact walk; the walks read only its length. */ +static const ScrStr scr_json_compact_gap = { .rc = SIZE_MAX, .len = 0 }; + +#define SCR_JSON_PURE_DEPTH 32 + +/* Writes `value` when writing it runs no program code and meets no cycle: + * JSON primitives, and objects and dense arrays with the default prototype + * whose members are such values, where objects hold only data properties + * under names that are neither array indexes nor "toJSON" (so their own + * key order is their insertion order and no toJSON step applies). Answers + * false, having written part of the value, for anything else, including + * nesting deeper than SCR_JSON_PURE_DEPTH; the caller then truncates the + * buffer and runs the protocol walk. */ +static bool scr_jb_put_pure(ScrJsonBuf *b, const ScrDyn *value, size_t depth) { + switch (value->kind) { + case SCR_DYN_NULL: scr_jb_puts(b, "null"); return true; + case SCR_DYN_BOOL: scr_jb_puts(b, value->v.b ? "true" : "false"); return true; + case SCR_DYN_NUM: scr_jb_put_f64(b, value->v.num); return true; + case SCR_DYN_STR: scr_jb_put_json_str(b, value->v.str); return true; + case SCR_DYN_ARR: + if (value->prototype || value->v.arr.presence || value->v.arr.properties || + depth == SCR_JSON_PURE_DEPTH) + return false; + scr_jb_putc(b, '['); + for (size_t i = 0; i < value->v.arr.len; i++) { + if (i > 0) scr_jb_putc(b, ','); + const ScrDyn *item = value->v.arr.items[i]; + if (scr_json_omitted(item)) scr_jb_puts(b, "null"); + else if (!scr_jb_put_pure(b, item, depth + 1)) return false; + } + scr_jb_putc(b, ']'); + return true; + case SCR_DYN_OBJ: { + if (value->prototype || depth == SCR_JSON_PURE_DEPTH) return false; + scr_jb_putc(b, '{'); + bool first = true; + for (size_t i = 0; i < value->v.obj.len; i++) { + const ScrDynEntry *entry = &value->v.obj.entries[i]; + if (!entry->enumerable) continue; + double index; + if (entry->accessor || (entry->key_len == 6 && memcmp(entry->key, "toJSON", 6) == 0) || + (entry->key_len && (unsigned char)(entry->key[0] - '0') < 10 && + scr_dyn_key_is_index(entry->key, entry->key_len, &index))) + return false; + if (scr_json_omitted(entry->value)) continue; + if (!first) scr_jb_putc(b, ','); + first = false; + scr_jb_put_json_span(b, entry->key, entry->key_len); + scr_jb_putc(b, ':'); + if (!scr_jb_put_pure(b, entry->value, depth + 1)) return false; + } + scr_jb_putc(b, '}'); + return true; + } + default: + return false; + } +} + +/* Writes the separator, key and value of an object member whose value + * scr_jb_put_pure can write, or drops a member holding undefined, a + * function or a symbol. `key_str`, when not NULL, is the key as a string. + * Answers false, with the buffer and `first` as they were, for a value + * that needs the protocol walk. */ +static inline __attribute__((always_inline)) bool scr_jb_try_pure_member( + ScrJsonBuf *b, const char *key, size_t key_len, const ScrStr *key_str, const ScrDyn *value, + bool *first) { + if (scr_json_omitted(value)) return true; + size_t mark = b->len; + if (!*first) scr_jb_putc(b, ','); + if (key_str) scr_jb_put_json_str(b, key_str); + else scr_jb_put_json_span(b, key, key_len); + scr_jb_putc(b, ':'); + if (scr_jb_put_pure(b, value, 0)) { + *first = false; + return true; + } + b->len = mark; + return false; +} + +/* Writes one object member of a typed container: `value` (consumed) goes + * through its toJSON step, an undefined, function or symbol result drops + * the member, and any other result is written after the separator and the + * key through the protocol walk, whose circular-structure checks share the + * buffer's stack. The container is the top of that stack, or `rec` is + * pushed for the walk when it is not; the key becomes the container's + * edge. Answers false with an exception pending. */ +static bool scr_jb_put_member_walk(ScrJsonBuf *b, const ScrStr *key, ScrDyn *value, + bool *first, void *rec) { + ScrDyn *child = scr_json_to_json(value, key); + if (!child) return false; + if (scr_json_omitted(child)) { + scr_dyn_release(child); + return true; + } + bool entered = rec && !(b->seen_len && b->seen[b->seen_len - 1].ptr == rec); + if (entered && !scr_jb_enter(b, rec, false)) { + scr_dyn_release(child); + return false; + } + if (!*first) scr_jb_putc(b, ','); + *first = false; + scr_jb_put_json_str(b, key); + scr_jb_putc(b, ':'); + scr_jb_edge_key(b, key); + bool ok = scr_json_replaced_write(b, child, scr_dyn_undefined(), NULL, &scr_json_compact_gap, + b->seen_len); + if (entered) scr_jb_leave(b); + scr_dyn_release(child); + return ok; +} + +/* scr_jb_put_member_walk, writing the member directly when scr_jb_put_pure + * can write its value. */ +static bool scr_jb_put_member(ScrJsonBuf *b, const ScrStr *key, ScrDyn *value, bool *first, + void *rec) { + if (scr_jb_try_pure_member(b, key->data, key->len, key, value, first)) { + scr_dyn_release(value); + return true; + } + return scr_jb_put_member_walk(b, key, value, first, rec); +} + +/* An entry of a record's `unknown` index signature, written after the + * record's earlier members with JSON.stringify's member rules + * (scr_jb_put_member). Walkers over cycle-capable shapes have pushed the + * record already; others push it for an entry that needs the protocol + * walk. May leave an exception pending. */ +static void scr_jb_put_dyn_entry(ScrJsonBuf *b, void *rec, const ScrStr *key, + const ScrDyn *value, bool *first) { + if (scr_jb_try_pure_member(b, key->data, key->len, key, value, first)) return; + scr_jb_put_member_walk(b, key, scr_dyn_retain((ScrDyn *)value), first, rec); +} + +/* The entries of record `rec`'s `unknown` index signature (`map`: string + * keys, checked-dynamic values) in JS own-key order, each with + * scr_jb_put_dyn_entry's rules. Without array-index keys that order is the + * map's insertion order, walked in place: the walk holds the map's + * iteration count so no compaction moves entries, rereads each entry by + * position, and stops at the entries present when it started, so a toJSON + * call that adds or deletes entries sees Node's result (keys taken first, + * deleted ones read as undefined). With array-index keys the walk takes the + * ordered key list first. May leave an exception pending. */ +void scr_jb_put_dyn_entries(ScrJsonBuf *b, void *rec, ScrMap *map, bool *first) { + uint32_t count = map->nentries; + bool indexed = false; + for (uint32_t i = 0; i < count && !indexed; i++) { + if (!map->entries[i].hash) continue; + const ScrStr *key = (const ScrStr *)(uintptr_t)map->entries[i].key; + uint32_t index; + indexed = scr_key_array_index(key->data, key->len, &index); + } + if (indexed) { + ScrArr *keys = scr_map_keys_js_order(map); + for (size_t i = 0; i < keys->len && !scr_exc_pending(); i++) { + ScrStr *key = scr_arr_get_ref(keys, (double)i); + ScrDyn *value = scr_map_get_str_ref(map, key); + if (value) { + scr_jb_put_dyn_entry(b, rec, key, value, first); + scr_dyn_release(value); + } + scr_str_release(key); + } + scr_arr_release(keys); + return; + } + map->iter_depth++; + for (uint32_t i = 0; i < count && !scr_exc_pending(); i++) { + const ScrMapEntry *entry = &map->entries[i]; + if (!entry->hash) continue; + const ScrStr *key = (const ScrStr *)(uintptr_t)entry->key; + const ScrDyn *value = (const ScrDyn *)(uintptr_t)entry->val; + if (scr_jb_try_pure_member(b, key->data, key->len, key, value, first)) continue; + /* The walk can run program code, which may replace this entry. */ + ScrStr *held_key = scr_str_retain((ScrStr *)key); + scr_jb_put_member_walk(b, held_key, scr_dyn_retain((ScrDyn *)value), first, rec); + scr_str_release(held_key); + } + map->iter_depth--; +} + +/* Writes a dyn value whose toJSON step has run through the protocol walk: + * undefined, functions and symbols as null (an array slot's rule), and + * containers with toJSON, accessor and circular-structure handling. May + * leave an exception pending. */ +void scr_jb_put_dyn(ScrJsonBuf *b, const ScrDyn *d) { + if (scr_json_omitted(d)) scr_jb_puts(b, "null"); + else scr_json_replaced_write(b, d, scr_dyn_undefined(), NULL, &scr_json_compact_gap, b->seen_len); +} + /* Untyped RC adapters (box/promise/exception-cell currency). */ void *scr_dyn_retain_v(void *d) { return scr_dyn_retain((ScrDyn *)d); } void scr_dyn_release_v(void *d) { scr_dyn_release((ScrDyn *)d); } diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index 03c9fb7cf..8a353f40a 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -162,9 +162,16 @@ void scr_jb_put_number(ScrJsonBuf *b, double v); void scr_jb_put_json_str(ScrJsonBuf *b, const ScrStr *s); /* JSON-serialize a dyn value into the buffer — the sc_jw_* walker for the - * dyn leaves the compiler cannot type: object members holding undefined - * DROP, array slots holding undefined print null (exactly Node). */ + * dyn leaves the compiler cannot type: undefined prints null (an array + * slot's rule); containers follow JSON.stringify (toJSON, accessors, + * circular structures). May leave an exception pending. */ void scr_jb_put_dyn(ScrJsonBuf *b, const ScrDyn *d); +/* The entries of record `rec`'s `unknown` index signature (its overflow + * map) after the record's earlier members: separators, keys and values in + * JS own-key order with JSON.stringify's member rules (toJSON called with + * the key, undefined, function and symbol results dropped). `first` is the + * record's first-member flag. May leave an exception pending. */ +void scr_jb_put_dyn_entries(ScrJsonBuf *b, void *rec, ScrMap *map, bool *first); /* Typed JSON for a record that kept a source: the kept key order, declared * fields through the compiled per-field writer. `try` writes nothing and * answers false for a record without one. */ diff --git a/tests/corpus/5841-json-typed-dyn-members.ts b/tests/corpus/5841-json-typed-dyn-members.ts new file mode 100644 index 000000000..e8f7f02bb --- /dev/null +++ b/tests/corpus/5841-json-typed-dyn-members.ts @@ -0,0 +1,76 @@ +// JSON.stringify over typed records whose members are checked-dynamic +// values: the undeclared properties a checked record keeps from its source, +// and the entries of an `unknown` index signature. Each member follows +// SerializeJSONProperty: toJSON runs with the member's key, getters are +// read, a Buffer becomes its { type, data } form, undefined, function and +// symbol results drop, and a circular structure throws V8's TypeError. +type Point = { a: number }; +type Bag = { a: number; [key: string]: unknown }; + +const report = (label: string, run: () => string | undefined): void => { + try { + console.log(label, run()); + } catch (error) { + const e = error as Error; + console.log(label, e.name); + console.log(e.message); + } +}; + +const source: any = JSON.parse('{"a":1,"b":"two"}'); +source.stamp = { toJSON: (key: string) => `stamp:${key}` }; +source.gone = { toJSON: () => undefined }; +source.bytes = Buffer.from("hi"); +Object.defineProperty(source, "computed", { get: () => 42, enumerable: true }); +source.list = [1, { toJSON: (key: string) => `item:${key}` }]; +const point = source as Point; +report("source", () => JSON.stringify(point)); +report("source pretty", () => JSON.stringify(point, null, 2)); +report("source nested", () => JSON.stringify({ point, n: 2 })); +report("source array", () => JSON.stringify([point, point])); + +const bag: Bag = { a: 1 }; +bag.stamp = { toJSON: (key: string) => `stamp:${key}` }; +bag.gone = { toJSON: () => undefined }; +bag.data = JSON.parse('{"x":[1,2,{"y":null}]}'); +bag.nothing = undefined; +bag.order = JSON.parse('{"b":1,"2":"two","a":2,"0":"zero"}'); +const layered: any = JSON.parse('{"level":{"items":[1,{"x":null}]}}'); +layered.level.items.push({ toJSON: (key: string) => `deep:${key}` }); +bag.deep = layered; +const hidden: any = JSON.parse('{"shown":1}'); +Object.defineProperty(hidden, "hidden", { value: 2, enumerable: false }); +bag.hidden = hidden; +let nested: any = JSON.parse('{"end":true}'); +for (let i = 0; i < 40; i++) nested = { i, nested }; +bag.nested = nested; +report("bag", () => JSON.stringify(bag)); +report("bag in array", () => JSON.stringify([bag])); + +const self: any = JSON.parse('{"k":1}'); +self.self = self; +const cyclic: any = JSON.parse('{"a":1,"x":{"k":2}}'); +cyclic.x.self = cyclic.x; +report("source circular", () => JSON.stringify(cyclic as Point)); +const deep: any = JSON.parse('{"a":1,"x":{"y":{"z":{"w":{"v":{}}}}}}'); +deep.x.y.z.w.v.back = deep.x; +report("source deep circular", () => JSON.stringify(deep as Point)); +const circular: Bag = { a: 1 }; +circular.loop = self; +report("bag circular", () => JSON.stringify(circular)); +report("bag circular nested", () => JSON.stringify({ outer: circular })); + +const shared: any = JSON.parse('{"v":1}'); +const twice: Bag = { a: 1, first: shared, second: shared }; +report("bag shared", () => JSON.stringify(twice)); + +const big: any = JSON.parse('{"a":1}'); +big.n = 1n; +report("source bigint", () => JSON.stringify(big as Point)); +const throwing: Bag = { a: 1 }; +throwing.bad = { + toJSON: () => { + throw new RangeError("from toJSON"); + }, +}; +report("bag throwing", () => JSON.stringify(throwing)); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index a70416b71..a70da246a 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1909,6 +1909,7 @@ "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, + "5841-json-typed-dyn-members.ts": 5467, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4601,6 +4602,7 @@ "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, + "5841-json-typed-dyn-members.ts": 3238, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, From 85fe87ffbf1b40e89390b1dd0ad6f18730bfaec4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:40:08 -0500 Subject: [PATCH 10/17] Convert Date values into unknown slots as Dates A TypeScript Date placed in an `unknown` slot (an annotated binding, an object or array literal, a record or class field) becomes a Date holding the same time value. Before, a Date member of an `unknown` object literal became a function, and other conversions were refused. util.inspect prints these Dates in their ISO form or as Invalid Date, and their methods can be read as values through untyped references. Comparisons with a Date operand keep their fence: each conversion creates a new Date. --- .../content/limitations/builtin-apis/date.mdx | 2 +- packages/compiler/src/backend/llvm/dyn.ts | 9 +++ .../src/frontend/lowering/lower-exprs.ts | 18 +++++- .../src/frontend/lowering/lower-stmts.ts | 2 +- packages/compiler/src/ir/ir.ts | 1 + .../corpus/5844-date-unknown-values.ts.json | 6 ++ packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_inspect.c | 1 + packages/runtime/src/scr_lib.c | 62 ++++++++++++++++++- tests/corpus/5844-date-unknown-values.ts | 57 +++++++++++++++++ tests/diagnostics/utility-types.ts | 6 +- .../__snapshots__/utility-types.ts.txt | 11 +--- tests/harness/corpus-costs.json | 2 + 13 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5844-date-unknown-values.ts.json create mode 100644 tests/corpus/5844-date-unknown-values.ts diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx index 605de4511..db5278c53 100644 --- a/docs/content/limitations/builtin-apis/date.mdx +++ b/docs/content/limitations/builtin-apis/date.mdx @@ -1 +1 @@ -- **Date:** Date unions, year/month constructors, setters, statically typed TypeScript identity comparisons, thrown Date values, and locale/string formatters are unsupported. Zero-argument and single number/string constructors, storage and passing, `getTime`, `valueOf`, `toISOString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile. JavaScript Dates retain identity through checked storage and support Date-copy construction and JSON serialization. See [Dates and timezones](#dates-and-timezones) for parsing restrictions. +- **Date:** Date unions, year/month constructors, setters, statically typed TypeScript identity comparisons, thrown Date values, and locale/string formatters are unsupported. Zero-argument and single number/string constructors, storage and passing, `getTime`, `valueOf`, `toISOString`, `toJSON` (a `string | null`: null for an invalid date, as in Node), calendar getters, `getTimezoneOffset`, and single-string `Date.parse` compile. JavaScript Dates retain identity through checked storage and support Date-copy construction and JSON serialization. A TypeScript Date converted to `unknown` becomes a Date with the same time value; each conversion creates a new Date, so two `unknown` values converted from one TypeScript Date are not `===`. See [Dates and timezones](#dates-and-timezones) for parsing restrictions. diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index d16d8244c..bb35e70c2 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -2418,6 +2418,15 @@ export class LlDyn { const B = new BlockBuilder(); let sourceAccessor: { name: string; release: string } | null = null; switch (t.kind) { + case "date": { + // A Date handle holding the same time value; native Dates are + // immutable values, and so is the handle. + host.declare(`declare ptr @scr_dyn_native_date_from_ms(double)`); + const r = B.tmp(); + B.line(`${r} = call ptr @scr_dyn_native_date_from_ms(double %v)`); + B.terminate(`ret ptr ${r}`); + break; + } case "url": case "searchParams": { const native = t.kind === "url" ? "url" : "search_params"; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 391dd7c88..6774de117 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -10576,7 +10576,14 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; if (bothStr) return { kind: "strCmp", op: cmpOp, left, right, type: BOOL, loc }; - if (lowerer.dynConvertible(left.type) && lowerer.dynConvertible(right.type)) { + // A Date converts to a new checked-dynamic Date on each crossing, so + // comparisons keep their Date fence instead of comparing copies. + if ( + left.type.kind !== "date" && + right.type.kind !== "date" && + lowerer.dynConvertible(left.type) && + lowerer.dynConvertible(right.type) + ) { return { kind: "libCall", fn: "dyn.compare", @@ -10826,7 +10833,14 @@ export function lowerStrictEquality( loc, }; } - if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) { + // A Date converts to a new checked-dynamic Date on each crossing; its + // identity has no representation, so equality keeps the fence. + if ( + idLeft.type.kind !== "date" && + idRight.type.kind !== "date" && + lowerer.dynConvertible(idLeft.type) && + lowerer.dynConvertible(idRight.type) + ) { return { kind: "dynScalarEq", left: lowerer.coerceToExpected(idLeft, DYN), diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index a437a05c2..ad4fd5312 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5302,7 +5302,7 @@ function lowerDynSwitch( loc: locOf(expression), }; } - if (!lowerer.dynConvertible(test.type)) { + if (test.type.kind === "date" || !lowerer.dynConvertible(test.type)) { lowerer.unsupported( "SC1100", expression, diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 9ba7a7321..9591c684a 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -4055,6 +4055,7 @@ function canBoxDynCompositeAt( case "regex": case "url": case "searchParams": + case "date": return true; case "func": return canBoxFuncIntoDyn(t, getRecord, getUnion, visiting); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5844-date-unknown-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5844-date-unknown-values.ts.json new file mode 100644 index 000000000..7331cf7f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5844-date-unknown-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5844-date-unknown-values.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 416696717..667ceced0 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -600,6 +600,7 @@ ScrDyn *scr_dyn_native_search_params(ScrSearchParams *value); bool scr_dyn_native_search_params_is(const ScrDyn *value); bool scr_dyn_native_date_is(const ScrDyn *value); ScrDyn *scr_dyn_native_date_new(const ScrDyn *arguments); +ScrDyn *scr_dyn_native_date_from_ms(double ms); double scr_dyn_native_date_value(const ScrDyn *value); ScrDyn *scr_dyn_native_regex(ScrRegex *value); bool scr_dyn_native_regex_is(const ScrDyn *value); diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index 7479cb548..34438353f 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -1243,6 +1243,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->v.handle.tag == SCR_DYNH_WEAK_MAP || d->v.handle.tag == SCR_DYNH_WEAK_SET) return scr_insp_weak_collection(d->v.handle.tag == SCR_DYNH_WEAK_MAP ? "WeakMap" : "WeakSet", recurse, depth); + if (d->v.handle.tag == SCR_DYNH_DATE) return scr_insp_date(scr_dyn_native_date_value(d)); const ScrDynHandleOps *ops = scr_dyn_handle_ops_of(d); if (ops->inspect_view) { /* Node's custom inspectors answer the bare class name beyond the diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index baabbe31e..f261d28cb 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -10883,9 +10883,69 @@ ScrDyn *scr_dyn_native_date_new(const ScrDyn *arguments) { else if (!scr_dyn_number_coerce_js(value, &ms)) return NULL; ms = scr_date_new_ms(ms); } + return scr_dyn_native_date_from_ms(ms); +} + +/* The Date.prototype methods scr_native_date_invoke answers. */ +static const char *const scr_native_date_methods[] = { + "getTime", "valueOf", "toISOString", "toJSON", "getFullYear", "getUTCFullYear", "getMonth", + "getUTCMonth", "getDate", "getUTCDate", "getDay", "getUTCDay", "getHours", "getUTCHours", + "getMinutes", "getUTCMinutes", "getSeconds", "getUTCSeconds", "getMilliseconds", + "getUTCMilliseconds", "getTimezoneOffset", +}; +#define SCR_NATIVE_DATE_METHODS (sizeof scr_native_date_methods / sizeof scr_native_date_methods[0]) +static SCR_TL ScrDyn *scr_native_date_method_values[SCR_NATIVE_DATE_METHODS]; +static SCR_TL bool scr_native_date_methods_registered; + +static void scr_native_date_methods_cleanup(void) { + for (size_t i = 0; i < SCR_NATIVE_DATE_METHODS; i++) { + ScrDyn *function = scr_native_date_method_values[i]; + scr_native_date_method_values[i] = NULL; + scr_dyn_release(function); + } +} + +/* A Date method read as a value and called later: the closure's function + * slot holds the method's name, and the receiver must be a Date. */ +static ScrDyn *scr_native_date_method_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + const char *method = closure->fn; + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (scr_dyn_native_date_is(self)) { + result = scr_native_date_invoke(self->v.handle.ptr, self, method, args, argc, method); + } else { + static const char message[] = "this is not a Date object."; + scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + } + scr_dyn_release(self); + return result; +} + +/* Property reads of a Date: its methods as function values, one per name + * and thread, and NULL (undefined) for other names. */ +static ScrDyn *scr_native_date_get(void *ptr, const char *key, size_t key_len) { + (void)ptr; + for (size_t i = 0; i < SCR_NATIVE_DATE_METHODS; i++) { + const char *name = scr_native_date_methods[i]; + if (strlen(name) != key_len || memcmp(name, key, key_len) != 0) continue; + if (!scr_native_date_methods_registered) { + scr_native_date_methods_registered = true; + scr_atexit(scr_native_date_methods_cleanup); + } + if (!scr_native_date_method_values[i]) { + scr_native_date_method_values[i] = scr_dyn_new_func( + scr_closure_new((void *)name, 0), scr_native_date_method_call, 0, "native:Date", name); + } + return scr_dyn_retain(scr_native_date_method_values[i]); + } + return NULL; +} + +/* A Date handle holding the time value `ms` (already TimeClip'd). */ +ScrDyn *scr_dyn_native_date_from_ms(double ms) { static const ScrDynHandleOps ops = { "Date", &scr_native_date_retain, &scr_native_date_release, &scr_native_date_invoke, - NULL, NULL, NULL, NULL, + &scr_native_date_get, NULL, NULL, NULL, }; scr_dyn_handle_install(SCR_DYNH_DATE, &ops); ScrNativeDate *date = malloc(sizeof *date); diff --git a/tests/corpus/5844-date-unknown-values.ts b/tests/corpus/5844-date-unknown-values.ts new file mode 100644 index 000000000..e5b1aed5f --- /dev/null +++ b/tests/corpus/5844-date-unknown-values.ts @@ -0,0 +1,57 @@ +// Date values converted into `unknown` slots stay Dates: typeof answers +// "object", instanceof Date holds, util.inspect prints the ISO form or +// "Invalid Date", JSON.stringify calls toJSON (null for an invalid date), +// a method read through an untyped reference is a function that takes a +// Date receiver, and the value converts back to a typed Date with the same +// time value. +import { inspect } from "node:util"; + +const epoch = new Date(0); +const bare: unknown = epoch; +const record: unknown = { when: new Date(86_400_000), n: 1 }; +const list: unknown = [new Date(Date.UTC(2024, 1, 29, 12, 30)), new Date(Number.NaN)]; +const bag: Record = { at: epoch }; + +console.log(typeof bare, bare instanceof Date, inspect(bare), JSON.stringify(bare)); +console.log(record, JSON.stringify(record), Object.keys(record as object)); +console.log(list, JSON.stringify(list)); +console.log(bag, JSON.stringify(bag)); + +const when = (record as { when: unknown }).when; +console.log(typeof when, when instanceof Date); +if (when instanceof Date) console.log(when.getTime(), when.toISOString(), when.getUTCDate()); +const back = bare as Date; +console.log(back.getTime(), back.toISOString()); +const dynamic = record as any; +console.log(dynamic.when.getTime(), dynamic.when.getUTCFullYear(), String(dynamic.when instanceof Date)); +const read = dynamic.when.getUTCHours; +console.log(typeof read, read.name, read.call(dynamic.when), dynamic.when.missing); +try { + read.call({}); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} + +type Event = { at: Date; tags: string[] }; +const events: Event[] = [ + { at: new Date(0), tags: ["a"] }, + { at: new Date(7), tags: [] }, +]; +const eventsUnknown: unknown = events; +console.log(eventsUnknown, JSON.stringify(eventsUnknown)); +console.log((eventsUnknown as Event[])[1]!.at.toISOString()); +class Holder { + value: unknown = null; +} +const holder = new Holder(); +holder.value = new Date(1000); +console.log(holder, JSON.stringify(holder.value)); +const invalid: any = { d: new Date(Number.NaN) } as unknown; +try { + console.log(invalid.d.toISOString()); +} catch (error) { + console.log((error as Error).name, (error as Error).message); +} +console.log(invalid.d.getTime(), JSON.stringify(invalid)); +const stamped: Readonly = new Date(0); +console.log(stamped.getTime(), stamped.toISOString()); diff --git a/tests/diagnostics/utility-types.ts b/tests/diagnostics/utility-types.ts index d22a8f969..c6d10648b 100644 --- a/tests/diagnostics/utility-types.ts +++ b/tests/diagnostics/utility-types.ts @@ -7,9 +7,9 @@ // differential corpus); what stays rejected: // - symbol-keyed index signatures (no lowering for symbol keys); // - index-signature value types outside the overflow-map slice (Dates and -// other lib API objects, even when the value itself has a representation); -// - utility types over LIB interfaces stay a type world, not data shapes. -// (Dot access/writes to index-signature keys compile now — the overflow +// other lib API objects, even when the value itself has a representation). +// (Readonly bindings compile, holding the Date as a checked value. +// Dot access/writes to index-signature keys compile now — the overflow // path in dot spelling — and self-referential mapped types intern as // named recursive shapes; differential corpus covers both.) const bySymbol: { [s: symbol]: string } = {}; diff --git a/tests/harness/__snapshots__/utility-types.ts.txt b/tests/harness/__snapshots__/utility-types.ts.txt index f8209335a..71f4d3083 100644 --- a/tests/harness/__snapshots__/utility-types.ts.txt +++ b/tests/harness/__snapshots__/utility-types.ts.txt @@ -32,13 +32,4 @@ utility-types.ts:19:14 - error SC2004: uses of 'dateBag' inherit the blocker on | ^~~~~~~ 20 | console.log(boot); - hint: the declaration of 'dateBag' did not compile — fix the diagnostic reported there and these sites clear with it - -utility-types.ts:22:33 - error SC1101: converting typed values to 'unknown' is not supported yet - - 21 | - 22 | const stamped: Readonly = new Date(0); - | ^~~~~~~~~~~ - 23 | console.log(stamped.getTime()); - - hint: numbers, strings, booleans, JSON-safe records/arrays/unions (a deep copy), functions, program class instances, and supported native handles convert into 'unknown' slots; functions, exact-class round trips, and handles preserve identity, while this value's type has no dynamic representation yet \ No newline at end of file + hint: the declaration of 'dateBag' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index c08267e2d..e15f6ac9f 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1909,6 +1909,7 @@ "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, + "5844-date-unknown-values.ts": 3969, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4602,6 +4603,7 @@ "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, + "5844-date-unknown-values.ts": 4543, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, From 74702ac4be7b39158b888b466c95c6a70a03cc29 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 05:42:55 -0500 Subject: [PATCH 11/17] Run a full cycle pass when a structure dies outside passes Full passes are scheduled by heap growth, and the fraction they wait for widens while growth-triggered passes find nothing, up to 16 times the heap after the last full pass. That keeps a large live heap that keeps growing (a compiler checking a large program) from being walked again and again. But growth alone cannot tell such a heap from one that keeps garbage: a program that drops a large structure and builds another, as a compiler rebuilding in watch mode does, holds the dead structures' cyclic parts until the heap has grown to 17 times its size. Ten rebuilds of benchmarks/regions/compiler.ts without lifetime regions reached 1.3 GB that way. The acyclic parts of such a structure go by reference counting, and that shows: the live count falls between passes. The collector now sums those falls since the last full pass. Once they reach 1/32 of the heap that pass left and have stopped (a structure torn down by reference counting falls over several checks, and a pass in the middle would find its rest still referenced), a full pass is due as soon as the heap has grown the base quarter past it, whatever the growth fraction. One that finds fewer older objects than had died doubles the fall the next one needs, up to 256 times, and a productive one restores it. A full pass that the growth rule did not choose (this one, a backlog pass, a scheduled-age pass) now raises the growth baselines of the levels it walked but does not lower them. After such a pass reclaims a dropped structure, the heap regrowing to where it was is the program rebuilding what it had. From the low point, growth passes at both levels would walk the rebuilt live heap and find nothing. On the ten rebuilds that was a third of the collector's added time. A heap that only grows never falls, so its schedule is unchanged. The checks run where the release path and the event loop already decide which pass to run. --- packages/runtime/src/scr_cycle.c | 100 +++++++++++++++++++++++++++-- packages/runtime/test/test_cycle.c | 79 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 6cadfcda6..69485dd6c 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -94,6 +94,25 @@ * objects that are not garbage. A pass that frees almost * nothing therefore doubles this fraction, and a productive * one restores it. + * full, death live objects have died outside passes since the last + * full pass (the live count fell between passes, by at + * least 1/32 of the heap that pass left) and have stopped + * dying, and the heap has grown the base fraction since. + * Growth alone cannot tell a heap that keeps live data + * from one that keeps garbage, and once full passes stop + * finding anything the fraction widens to the cap. A + * program that drops a large structure and builds another + * (a compiler rebuilding in watch mode) would then hold + * the dead structures' cyclic parts until the heap had + * grown to the cap. The acyclic parts of such a structure + * go by reference counting, which shows as the fall. One + * that finds fewer older objects than had died doubles the + * fall the next one needs, and a productive one restores + * it. Like every full pass the growth rule did not choose + * (backlog, scheduled age), it can raise the levels' + * growth baselines but not lower them: the heap regrowing + * to where it was before such a pass reclaimed a dropped + * structure is a rebuild, not growth. * mature the same two rules one level down: a growth fraction * measured from the last mature-or-full pass and scheduled * by everything mature passes free, and a backlog rule over @@ -196,6 +215,12 @@ typedef struct { unsigned char growth_shift[SCR_CYC_NGENS]; unsigned char growth_streak[SCR_CYC_NGENS]; unsigned backlog_shift[SCR_CYC_NGENS]; + size_t fallen; + size_t live_seen; + size_t full_after; + unsigned char death_shift; + bool falling; + bool keep_base; size_t nbuffered; size_t trigger; size_t alloc_trigger; @@ -234,6 +259,12 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD}; #define scr_cyc_scheduled_mature_age (scr_cyc_thread.scheduled_mature_age) #define scr_xg_freed (scr_cyc_thread.xg_freed) #define scr_cyc_mature_dead (scr_cyc_thread.mature_dead) +#define scr_cyc_fallen (scr_cyc_thread.fallen) +#define scr_cyc_live_seen (scr_cyc_thread.live_seen) +#define scr_cyc_full_after (scr_cyc_thread.full_after) +#define scr_cyc_death_shift (scr_cyc_thread.death_shift) +#define scr_cyc_falling (scr_cyc_thread.falling) +#define scr_cyc_keep_base (scr_cyc_thread.keep_base) #define scr_cyc_kinds (scr_cyc_thread.kinds) #define scr_cyc_kinds_cap (scr_cyc_thread.kinds_cap) #endif @@ -475,6 +506,22 @@ static SCR_TL unsigned scr_cyc_backlog_shift[SCR_CYC_NGENS]; static SCR_TL size_t scr_cyc_mature_dead = 0; #endif +/* The death rule (see the scheduling notes above). `fallen`: how far the + * live count fell between passes since the last full pass; `live_seen`: the + * live count at the end of the last pass or check; `full_after`: the live + * count after the last full pass; `falling`: whether it fell since the + * previous check; `death_shift`: doublings of the fall a pass needs, earned + * by passes that found less than had died; `keep_base`: the pass running + * is a full pass the growth rule did not choose, which may raise the + * levels' growth baselines but not lower them. */ +#define SCR_CYC_DEATH_DIV 32 +#define SCR_CYC_DEATH_MAX_SHIFT 8 +#ifndef SCR_WORKERS +static SCR_TL size_t scr_cyc_fallen = 0, scr_cyc_live_seen = 0, scr_cyc_full_after = 0; +static SCR_TL unsigned char scr_cyc_death_shift = 0; +static SCR_TL bool scr_cyc_falling = false, scr_cyc_keep_base = false; +#endif + static size_t scr_cyc_nursery_threshold(void) { static SCR_TL size_t cached = 0; if (cached == 0) { @@ -621,6 +668,27 @@ static void scr_cyc_adapt_growth(unsigned gen, size_t growth, } } +/* How far the live count fell since the last check: what died outside + * passes, less what was allocated meanwhile. Every pass ends with a fresh + * reading, so what passes free does not count. */ +static void scr_cyc_note_fall(void) { + scr_cyc_falling = scr_cyc_live < scr_cyc_live_seen; + if (scr_cyc_falling) scr_cyc_fallen += scr_cyc_live_seen - scr_cyc_live; + scr_cyc_live_seen = scr_cyc_live; +} + +/* The death rule: enough has died since the last full pass, it has stopped + * dying (a structure torn down by reference counting falls over several + * checks, and a pass in the middle would find its rest still referenced), + * and the heap has grown the base fraction since. */ +static SCR_CYC_SCHEDULING bool scr_cyc_died(void) { + if (scr_cyc_falling) return false; + size_t base = scr_cyc_growth_base(scr_cyc_full_after); + size_t need = base / SCR_CYC_DEATH_DIV; + need = need > (SIZE_MAX >> scr_cyc_death_shift) ? SIZE_MAX : need << scr_cyc_death_shift; + return scr_cyc_fallen >= need && scr_cyc_live > base + base / SCR_CYC_GROWTH_DIV; +} + /* One scheduled pass at `gen`. A mature or old pass adapts that level's * backlog threshold to its yield; the explicit sweep (scr_collect_cycles) is * not a scheduling decision and leaves the thresholds alone. Productive means @@ -630,11 +698,14 @@ static void scr_cyc_adapt_growth(unsigned gen, size_t growth, * * Every level shares this one body, so a program that links the collector * carries a single copy of the scheduling logic. */ -static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_growth) { +static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_growth, bool by_death) { size_t base = scr_cyc_growth_base(scr_cyc_live_after[gen]); size_t growth = scr_cyc_live > base ? scr_cyc_live - base : 0; + size_t fallen = scr_cyc_fallen; size_t old_freed_before = scr_cyc_old_freed; + scr_cyc_keep_base = gen == SCR_CYC_OLD && !by_growth; size_t freed = scr_cyc_pass(gen); + scr_cyc_keep_base = false; size_t old_reclaimed = scr_cyc_old_freed - old_freed_before + scr_cyc_mature_dead; if (gen == SCR_CYC_NURSERY) return; if (scr_cands.n != 0) { @@ -643,7 +714,10 @@ static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_grow else if (scr_cyc_backlog_shift[gen] < SCR_CYC_BACKLOG_MAX_SHIFT) scr_cyc_backlog_shift[gen]++; } - if (by_growth) + if (by_death) { + if (old_reclaimed >= fallen) scr_cyc_death_shift = 0; + else if (scr_cyc_death_shift < SCR_CYC_DEATH_MAX_SHIFT) scr_cyc_death_shift++; + } else if (by_growth) scr_cyc_adapt_growth(gen, growth, gen == SCR_CYC_OLD ? old_reclaimed : freed); } @@ -652,15 +726,17 @@ static SCR_CYC_SCHEDULING void scr_cyc_scheduled_pass(unsigned gen, bool by_grow * per pass, so it is kept out of line and built for size, which leaves the * hot buffering path in scr_cyc_on_release small. */ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { + scr_cyc_note_fall(); unsigned gen = SCR_CYC_OLD; bool by_growth = scr_cyc_grown(SCR_CYC_OLD); - if (!by_growth && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { + bool by_death = !by_growth && scr_cyc_died(); + if (!by_growth && !by_death && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) { gen = SCR_CYC_MATURE; by_growth = scr_cyc_grown(SCR_CYC_MATURE); if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE)) gen = SCR_CYC_NURSERY; } - scr_cyc_scheduled_pass(gen, by_growth); + scr_cyc_scheduled_pass(gen, by_growth, by_death); } /* One scheduled pass, for callers that reach a natural collection point @@ -669,6 +745,7 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) { * per turn would walk the whole live heap every turn — which is exactly the * cost the generations exist to avoid. */ void scr_cyc_collect_scheduled(void) { + scr_cyc_note_fall(); if (scr_cyc_buffered() == 0) { scr_cyc_scheduled_mature_age = 0; return; @@ -677,7 +754,7 @@ void scr_cyc_collect_scheduled(void) { scr_cyc_scheduled_mature_age = 0; } else if (++scr_cyc_scheduled_mature_age >= scr_cyc_nursery_threshold()) { - scr_cyc_scheduled_pass(SCR_CYC_OLD, false); + scr_cyc_scheduled_pass(SCR_CYC_OLD, false, false); return; } scr_cyc_collect_due(); @@ -1167,11 +1244,20 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) { /* A limited pass walked only some of the candidates: the schedule's * baselines stay those of the last pass over them all. */ + scr_cyc_live_seen = scr_cyc_live; if (scr_cyc_take == NULL) { - if (gen_limit >= SCR_CYC_MATURE) scr_cyc_live_after[SCR_CYC_MATURE] = scr_cyc_live; + /* A full pass the growth rule did not choose (death, backlog, scheduled + * age) may raise the growth baselines but not lower them. After it + * reclaims a dropped structure, the heap regrowing to where it was is + * the program rebuilding what it had, not growth: from the low point, + * the next growth passes would walk the rebuilt live heap for nothing, + * at both levels. */ + for (unsigned g = SCR_CYC_MATURE; g <= gen_limit; g++) + if (!scr_cyc_keep_base || scr_cyc_live > scr_cyc_live_after[g]) scr_cyc_live_after[g] = scr_cyc_live; if (gen_limit >= SCR_CYC_OLD) { - scr_cyc_live_after[SCR_CYC_OLD] = scr_cyc_live; scr_cyc_scheduled_mature_age = 0; + scr_cyc_fallen = 0; + scr_cyc_full_after = scr_cyc_live; } } /* markRoots drained buffers in bulk and teardowns moved the count around; diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index ba0bc0819..8fdac2d13 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -1187,6 +1187,84 @@ static void check_large_old_buffer_with_nursery_candidates(void) { } } +/* The death rule: once objects have died outside passes (the live count + * fell by 1/32 of the heap the last full pass left, here the floor's), a + * full pass is due as soon as the heap has grown the base quarter past it, + * however wide the growth fraction has become. With the old level's + * fraction at the cap, the dead old ring below would otherwise wait until + * the heap had grown to the cap. A fall that the pass it brings finds + * nothing behind doubles the fall the next one needs. */ +static size_t watched_freed; +static void watched_free(void *obj) { + watched_freed++; + node_free(obj); +} + +/* Fresh live leaves that are never released, each followed by a dead + * one-node ring, which keeps the release path running its passes without + * buffering the leaves, until the watched ring is freed or `limit` leaves + * have been made. Returns how many it made. */ +static size_t grow_until_watched(Node **keep, size_t limit, size_t watched) { + size_t n = 0; + while (watched_freed < watched && n < limit) { + keep[n++] = make_leaf(); + release_live(make_ring(1)); + } + return n; +} + +static void check_death_brings_full_pass(void) { + enum { FLOOR = 4096, RING = 64, LEAVES = 1024 }; + static ScrCycType watched_type = { &node_trace, &watched_free, 0 }; + size_t cap = configured_growth_cap(); + if (cap < 4) return; /* growth alone is due about as soon */ + size_t limit = FLOOR + FLOOR / 4 + 2 * configured_nursery_threshold() + 2; + Node **leaves = calloc(LEAVES, sizeof(*leaves)); + Node **keep = calloc(limit, sizeof(*keep)); + if (!leaves || !keep) scr_trap("cycle test: out of memory\n"); + /* Growth passes that find nothing widen the old fraction to the cap. */ + for (int i = 0; i < 4; i++) grow_dead_cycle_until_full_pass(0, false); + + Node *ring = make_ring_of(RING, &watched_type); + age_to_old(ring); + for (size_t i = 0; i < LEAVES; i++) leaves[i] = make_leaf(); + scr_collect_cycles(); + check(scr_cyc_live < FLOOR - LEAVES, "live heap did not start below the growth floor"); + watched_freed = 0; + drop_leaves(leaves, LEAVES); /* gone by reference counting: the live count falls */ + scr_cyc_collect_scheduled(); /* a check reads the whole fall */ + release_live(ring); /* a dead old cycle */ + size_t grown = grow_until_watched(keep, limit, RING); + check(watched_freed == RING, "the fall did not bring a full pass by the base fraction"); + drop_leaves(keep, grown); + scr_collect_cycles(); + + /* A fall with nothing behind it: the pass it brings finds nothing. */ + for (size_t i = 0; i < LEAVES; i++) leaves[i] = make_leaf(); + scr_collect_cycles(); + drop_leaves(leaves, LEAVES); + scr_cyc_collect_scheduled(); + grown = grow_until_watched(keep, limit, 1); + drop_leaves(keep, grown); + /* So the next death pass needs twice the fall: 1/32 of the floor, enough + * before, no longer is. */ + ring = make_ring_of(RING, &watched_type); + age_to_old(ring); + for (size_t i = 0; i < FLOOR / 32; i++) leaves[i] = make_leaf(); + scr_collect_cycles(); + watched_freed = 0; + drop_leaves(leaves, FLOOR / 32); + scr_cyc_collect_scheduled(); + release_live(ring); + grown = grow_until_watched(keep, limit, RING); + check(watched_freed == 0, "a fall smaller than the backed-off one brought a full pass"); + drop_leaves(keep, grown); + scr_collect_cycles(); + check(watched_freed == RING, "the full sweep did not reclaim the dead old ring"); + free(leaves); + free(keep); +} + int main(void) { check_old_garbage_reclaimed_while_growing(); check_older_rings_into_old(); @@ -1207,6 +1285,7 @@ int main(void) { check_growth_schedule_follows_yield(); check_growth_cap_bounds_floating_garbage(); check_mixed_dead_structure_narrows(); + check_death_brings_full_pass(); check_nursery_bypass(); check_mature_bypass(); check_probe_sees_candidate_rings(); From f676f5ad2d92e4337ccf3e95cf3e4d8d4fcf9090 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 06:13:12 -0500 Subject: [PATCH 12/17] Select a new property bag's prototype through one helper per class A class's property-bag branch created the bag from the prototype data of the most derived constructible class the receiver is an instance of. That selection was an instanceOf chain over the class's whole subclass tree, inlined into the branch, and each nested subclass repeated it for its own subtree, so the dispatch functions grew with the square of the hierarchy's depth. In the three.js nodes fixture they reached 350,000 lines of LLVM IR, and register allocation of the read-only copies set the native build's longest partition. A class with constructible subclasses that have prototype data now selects through %class.bagPrototype:, which tests its subclasses from the last to the first and calls their helpers. Other classes read their prototype data directly, as before. --- .../lowering/class-dynamic-dispatch.ts | 127 +++++++++++++----- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index d14aaf924..444a9cf23 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -698,6 +698,8 @@ export class ClassDynamicDispatch { private readonly bagClasses = new Set(); private readonly bagInitializers = new Map[]>(); private readonly typedPropertyBags = new Map(); + /** Per-class prototype selection for new property bags (bagPrototypeHelper). */ + private readonly bagPrototypeHelpers = new Map(); private readonly readPropertyBags: { helper: IrFunction; initialize: Extract; @@ -1127,38 +1129,8 @@ export class ClassDynamicDispatch { current.subclasses.some( (child) => lowerer.classCanBeConstructed(child) && needsPrototype(child), ); - const prototypeFor = (current: ClassInfo, receiver: IrExpr, loc: SrcLoc): IrExpr | null => { - const currentReceiver: IrExpr = - receiver.type.kind === "object" && receiver.type.className !== current.def.name - ? { - kind: "downcast", - value: receiver, - type: { kind: "object", className: current.def.name }, - loc, - } - : receiver; - let result = classPrototypeData(lowerer, current, loc, currentReceiver); - for (const child of current.subclasses) { - if (!lowerer.classCanBeConstructed(child)) continue; - const selected = prototypeFor(child, receiver, loc); - if (selected && result) - result = { - kind: "ternary", - cond: { - kind: "instanceOf", - value: receiver, - className: child.def.name, - type: BOOL, - loc, - }, - then: selected, - else_: result, - type: DYN, - loc, - }; - } - return result; - }; + const prototypeFor = (current: ClassInfo, receiver: IrExpr, loc: SrcLoc): IrExpr | null => + this.bagPrototype(lowerer, current, receiver, loc); for (const existingInit of this.bagInitializers.get(className) ?? []) { if (existingInit.value.kind === "dynObjLit" && needsPrototype(info)) { const prototype = prototypeFor(info, existingInit.obj, existingInit.loc); @@ -3922,10 +3894,20 @@ export class ClassDynamicDispatch { obj: receiver, value: transformExpr(initializers[0]!.value, { stmt: (stmt) => stmt, - expr: (expr) => - expr.kind === "dynCheck" && expr.value.kind === "varRef" && expr.value.localId === "p.0" - ? receiver - : expr, + expr: (expr) => { + const checked = (e: IrExpr): boolean => + e.kind === "dynCheck" && e.value.kind === "varRef" && e.value.localId === "p.0"; + // The checked receiver, possibly cast to this class (the prototype + // helper's argument), is the typed parameter itself. + if ( + expr.kind === "downcast" && + checked(expr.value) && + expr.type.kind === "object" && + expr.type.className === type.className + ) + return receiver; + return checked(expr) ? receiver : expr; + }, }), loc, }; @@ -3954,6 +3936,79 @@ export class ClassDynamicDispatch { return helper; } + /** The prototype a new property bag of `current` (or of the subclass the + * receiver is) starts from, or null when `current` has no prototype data. + * A class with constructible subclasses that have prototype data selects + * it through its `%class.bagPrototype` helper; any other class reads its + * own prototype data. */ + private bagPrototype( + lowerer: Lowerer, + current: ClassInfo, + receiver: IrExpr, + loc: SrcLoc, + ): IrExpr | null { + const arg: IrExpr = + receiver.type.kind === "object" && receiver.type.className !== current.def.name + ? { + kind: "downcast", + value: receiver, + type: { kind: "object", className: current.def.name }, + loc, + } + : receiver; + const helper = this.bagPrototypeHelper(lowerer, current, loc); + return helper + ? { kind: "call", callee: helper.name, args: [arg], type: DYN, loc } + : classPrototypeData(lowerer, current, loc, arg); + } + + /** `%class.bagPrototype:(this)`: the prototype data of the most + * derived constructible class `this` is an instance of, tested from the + * last subclass to the first and then the class itself. One helper per + * class, calling its subclasses' helpers, keeps every property-bag branch + * a single call: a whole hierarchy inlined into each branch grows with the + * square of its depth. Null when the class has no prototype data, in which + * case its subclasses are not tested either, or while no constructible + * subclass has any. The body is rebuilt on each use, as classes become + * constructible. */ + private bagPrototypeHelper(lowerer: Lowerer, current: ClassInfo, loc: SrcLoc): IrFunction | null { + const type: IrType = { kind: "object", className: current.def.name }; + const self = varRef("this", type, loc); + const own = classPrototypeData(lowerer, current, loc, self); + if (!own) return null; + const body: IrStmt[] = []; + const children = current.subclasses.filter((child) => lowerer.classCanBeConstructed(child)); + for (const child of children.reverse()) { + const selected = this.bagPrototype(lowerer, child, self, loc); + if (!selected) continue; + body.push({ + kind: "if", + cond: { kind: "instanceOf", value: self, className: child.def.name, type: BOOL, loc }, + then: [{ kind: "return", value: selected, loc }], + else_: null, + loc, + }); + } + let helper = this.bagPrototypeHelpers.get(current.def.name); + if (!helper && body.length === 0) return null; + body.push({ kind: "return", value: own, loc }); + if (!helper) { + helper = { + name: `%class.bagPrototype:${current.def.name}`, + params: [{ localId: "this", name: "this", type }], + returnType: DYN, + locals: [{ id: "this", name: "this", type, mutable: false }], + body, + loc, + }; + this.bagPrototypeHelpers.set(current.def.name, helper); + this.generated.add(helper); + lowerer.liftedFns.push(helper); + } + helper.body = body; + return helper; + } + /** Prototype state may appear in a later reachable body, so read-only bags * settle only after the dispatch worklist closes. A read never stores: an * absent empty bag stays undefined (callers test it), and an absent From f62275dec00607d9e2390f45c494cbd636358c86 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 06:32:10 -0500 Subject: [PATCH 13/17] Add the process.execArgv surface to the manifest process.execArgv now lowers to its own libCall, which the determinism attestation treats as a process input like process.argv. The node-builtin.process.execArgv surface entry lets a library fence deny it, and surface-manifest.json is regenerated with the entry. --- packages/compiler/src/frontend/lowering/surfaces.ts | 6 ++++++ packages/compiler/surface-manifest.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 8ad735791..848b34afe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2456,6 +2456,12 @@ export const AMBIENT_SURFACE_FNS: readonly AmbientSurfaceRow[] = [ name: "process.argv0", fns: ["process.argv0"], }, + { + id: "node-builtin.process.execArgv", + kind: "node-builtin", + name: "process.execArgv", + fns: ["process.execArgv"], + }, { id: "node-builtin.process.execPath", kind: "node-builtin", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 683df3777..219095bc0 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1944,6 +1944,12 @@ "status": "static", "note": "reads, writes, deletes, and enumeration of the process environment (the process global)" }, + { + "id": "node-builtin.process.execArgv", + "kind": "node-builtin", + "name": "process.execArgv", + "status": "static" + }, { "id": "node-builtin.process.execPath", "kind": "node-builtin", From 0ee069f094d4aa7c880e0b1282250659c4abefc1 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 06:53:41 -0500 Subject: [PATCH 14/17] Answer typeof for builtin module functions and classes `typeof zlib.Inflate` and `typeof zlib.createGzip` stopped at SC1090 or SC2020, because the operand lowered as a library function or class value. A typeof of a function or class exported by a Node builtin module, read through a plain binding, now folds to "function" as in Node: the read has no side effects to preserve. --- .../src/frontend/lowering/lower-exprs.ts | 41 +++++++++++++++++++ .../5974-typeof-module-exports.cjs.json | 6 +++ tests/corpus/5974-typeof-module-exports.cjs | 9 ++++ tests/harness/corpus-costs.json | 2 + 4 files changed, 58 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5974-typeof-module-exports.cjs.json create mode 100644 tests/corpus/5974-typeof-module-exports.cjs diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 6774de117..f0538bb71 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -648,6 +648,30 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { // have non-stdlib symbols and keep the ordinary path. let tested = expr.expression; while (ts.isParenthesizedExpression(tested)) tested = tested.expression; + // `typeof zlib.Inflate` / `typeof zlib.createGzip`: an export of a + // Node builtin module read through a plain binding (an import or + // require result). The read has no side effects, and Node's answer for + // every exported function and class is "function". + if ( + ts.isPropertyAccessExpression(tested) && + ts.isIdentifier(tested.expression) && + lowerer.isStdlibMember(tested) + ) { + const sym = lowerer.checker.getSymbolAtLocation(tested.name); + const decls = sym ? lowerer.checker.declarationsOf(sym) : []; + if ( + decls.some(isBuiltinModuleValueDecl) && + decls.every((decl) => builtinModuleExportParent(decl) !== undefined) + ) { + const t = lowerer.typeOf(tested); + if ( + lowerer.checker.getCallSignatures(t).length > 0 || + lowerer.checker.getConstructSignatures(t).length > 0 + ) { + return { kind: "strLit", value: "function", type: STRING, loc }; + } + } + } if (ts.isIdentifier(tested)) { const sym = lowerer.checker.getSymbolAtLocation(tested); if (!sym) { @@ -15825,3 +15849,20 @@ function checkedIteratorPack(lowerer: Lowerer, source: IrExpr, loc: SrcLoc): IrE } return { kind: "call", callee: name, args: [source], type: DYN, loc }; } + +/** The `declare module "..."` body (or nested namespace body) a module + * export's declaration sits directly in, or undefined. */ +function builtinModuleExportParent(decl: ts.Node): ts.ModuleBlock | undefined { + const parent = ts.isVariableDeclaration(decl) ? decl.parent?.parent?.parent : decl.parent; + return parent !== undefined && ts.isModuleBlock(parent) ? parent : undefined; +} + +/** A function, class or variable exported by a builtin module's body. */ +function isBuiltinModuleValueDecl(decl: ts.Node): boolean { + return ( + builtinModuleExportParent(decl) !== undefined && + (ts.isFunctionDeclaration(decl) || + ts.isClassDeclaration(decl) || + ts.isVariableDeclaration(decl)) + ); +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5974-typeof-module-exports.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5974-typeof-module-exports.cjs.json new file mode 100644 index 000000000..469ee3272 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5974-typeof-module-exports.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5974-typeof-module-exports.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/5974-typeof-module-exports.cjs b/tests/corpus/5974-typeof-module-exports.cjs new file mode 100644 index 000000000..7e209f147 --- /dev/null +++ b/tests/corpus/5974-typeof-module-exports.cjs @@ -0,0 +1,9 @@ +// typeof on functions and classes exported by Node builtin modules, read +// through require bindings: Node answers "function" for each. +const zlib = require("node:zlib"); +const path = require("node:path"); +const fs = require("node:fs"); +console.log(typeof zlib.createGzip, typeof zlib.createBrotliDecompress, typeof zlib.gunzipSync); +console.log(typeof zlib.Inflate, typeof zlib.Gzip, typeof zlib.DeflateRaw); +console.log(typeof path.join, typeof path.posix.join, typeof fs.readFileSync); +console.log(typeof path.sep); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 2c2eeab31..25f00b1f7 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1908,6 +1908,7 @@ "5970-net-connect-host-name.ts": 2400, "5971-buffer-chunks-to-string.ts": 2700, "5972-net-set-encoding-listeners.ts": 2550, + "5974-typeof-module-exports.cjs": 2000, "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, @@ -4603,6 +4604,7 @@ "5970-net-connect-host-name.ts": 2800, "5971-buffer-chunks-to-string.ts": 3100, "5972-net-set-encoding-listeners.ts": 3000, + "5974-typeof-module-exports.cjs": 2500, "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, From 1cd06fda4c969f512a26b8e18fb59a1c0f44b778 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 07:44:23 -0500 Subject: [PATCH 15/17] Read events.defaultMaxListeners from the ES module namespace copy Reads of defaultMaxListeners through an ES module namespace import or a named import answer the namespace's copy of the export, which keeps its value from program start until module.syncBuiltinESMExports() refreshes it, as in Node. The default import, the EventEmitter class and CommonJS module bindings keep reading the current default. A destructured CommonJS binding of defaultMaxListeners, which held a placeholder string in JavaScript files, is refused. --- .../limitations/builtin-apis/eventemitter.mdx | 2 +- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 ++--- .../generated/node-v24-backlog.json | 45 ++++++++++++------- .../generated/node-v24-internal.json | 41 ++++++++++++----- internal/compatibility/static-support.json | 4 +- .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../lowering/builtins/module-bindings.ts | 18 ++++++++ .../lowering/builtins/module-resolution.ts | 9 ++-- .../src/frontend/lowering/lower-exprs.ts | 15 +++++++ .../src/frontend/lowering/lower-stmts.ts | 14 ++++-- .../compiler/src/frontend/lowering/lowerer.ts | 15 ++++++- .../src/frontend/lowering/surfaces.ts | 2 +- .../compiler/src/ir/builtin-signatures.ts | 4 ++ packages/compiler/src/ir/validate.ts | 2 + packages/compiler/surface-manifest.json | 2 +- .../5846-events-esm-default-max.ts.json | 6 +++ .../5847-events-cjs-default-max.cjs.json | 6 +++ packages/runtime/src/scr_events_emitter.c | 8 ++++ packages/runtime/src/scr_events_emitter.h | 2 + tests/corpus/5846-events-esm-default-max.ts | 28 ++++++++++++ tests/corpus/5847-events-cjs-default-max.cjs | 10 +++++ tests/harness/corpus-costs.json | 4 ++ 23 files changed, 204 insertions(+), 47 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5846-events-esm-default-max.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5847-events-cjs-default-max.cjs.json create mode 100644 tests/corpus/5846-events-esm-default-max.ts create mode 100644 tests/corpus/5847-events-cjs-default-max.cjs diff --git a/docs/content/limitations/builtin-apis/eventemitter.mdx b/docs/content/limitations/builtin-apis/eventemitter.mdx index 0b44cd549..c4435f4c7 100644 --- a/docs/content/limitations/builtin-apis/eventemitter.mdx +++ b/docs/content/limitations/builtin-apis/eventemitter.mdx @@ -1 +1 @@ -- **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. +- **EventEmitter:** Literal event names use fixed argument tuples. Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion. Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported. Reading `defaultMaxListeners` through a CommonJS destructured or member binding (`const { defaultMaxListeners } = require("events")`) is unsupported. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 500876593..db36cdb52 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "56f660f5e2eb7391a048", + "artifactVersion": "cade1b8e6e0c5c42fcc9", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 568cdb4c9..dadc9e836 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -569,9 +569,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -57593,9 +57593,9 @@ "anchor": "", "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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 683d477a5..1b7d28783 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1142, - "verify-gap": 2187, + "audit-partial": 1143, + "verify-gap": 2186, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -25009,7 +25009,9 @@ "confidence": "high", "source": "compiler-dedicated:events.getEventListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] } } @@ -25036,7 +25038,9 @@ "confidence": "high", "source": "compiler-dedicated:events.getMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] } } @@ -25091,7 +25095,9 @@ "confidence": "high", "source": "compiler-dedicated:events.listenerCount", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] } } @@ -25143,7 +25149,9 @@ "confidence": "high", "source": "compiler-dedicated:events.setMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] } } @@ -25197,7 +25205,9 @@ "confidence": "high", "source": "compiler-dedicated:events.defaultMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] } } @@ -48430,7 +48440,8 @@ "confidence": "high", "source": "compiler-feature:module.module.isBuiltin", "tests": [ - "tests/corpus/2932-create-require-program/main.ts" + "tests/corpus/2932-create-require-program/main.ts", + "tests/corpus/5846-events-esm-default-max.ts" ] } } @@ -48527,7 +48538,8 @@ "confidence": "high", "source": "compiler-feature:module.module.syncBuiltinESMExports", "tests": [ - "tests/corpus/2932-create-require-program/main.ts" + "tests/corpus/2932-create-require-program/main.ts", + "tests/corpus/5846-events-esm-default-max.ts" ] } } @@ -57507,12 +57519,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": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 393955bc0..8463d1292 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -596,9 +596,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -41819,6 +41819,8 @@ "tests/corpus/5245-event-target-subclass-signals.ts", "tests/corpus/5247-event-target-subclass-lifetimes.ts", "tests/corpus/5248-event-target-subclass-warnings.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs", "tests/corpus/5885-events-max-listener-helpers.ts" ] }, @@ -43739,7 +43741,9 @@ "status": "partial", "evidence": "compiler-dedicated:events.getEventListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] }, "anchorSource": "exact" @@ -43765,7 +43769,9 @@ "status": "partial", "evidence": "compiler-dedicated:events.getMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] }, "anchorSource": "exact" @@ -43841,7 +43847,9 @@ "status": "partial", "evidence": "compiler-dedicated:events.listenerCount", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] }, "anchorSource": "exact" @@ -43890,7 +43898,9 @@ "status": "partial", "evidence": "compiler-dedicated:events.setMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] }, "anchorSource": "exact" @@ -43942,7 +43952,9 @@ "status": "partial", "evidence": "compiler-dedicated:events.defaultMaxListeners", "tests": [ - "tests/corpus/5885-events-max-listener-helpers.ts" + "tests/corpus/5885-events-max-listener-helpers.ts", + "tests/corpus/5846-events-esm-default-max.ts", + "tests/corpus/5847-events-cjs-default-max.cjs" ] }, "anchorSource": "exact" @@ -69416,6 +69428,7 @@ "tests/corpus/2637-create-require-bare/main.ts", "tests/corpus/2675-create-require-nested-esm.js", "tests/corpus/2932-create-require-program/main.ts", + "tests/corpus/5846-events-esm-default-max.ts", "tests/harness/npm-static.test.ts" ] }, @@ -69503,7 +69516,8 @@ "status": "partial", "evidence": "compiler-feature:module.module.isBuiltin", "tests": [ - "tests/corpus/2932-create-require-program/main.ts" + "tests/corpus/2932-create-require-program/main.ts", + "tests/corpus/5846-events-esm-default-max.ts" ] }, "anchorSource": "exact" @@ -69593,7 +69607,8 @@ "status": "partial", "evidence": "compiler-feature:module.module.syncBuiltinESMExports", "tests": [ - "tests/corpus/2932-create-require-program/main.ts" + "tests/corpus/2932-create-require-program/main.ts", + "tests/corpus/5846-events-esm-default-max.ts" ] }, "anchorSource": "exact" @@ -80949,8 +80964,12 @@ "anchor": "", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] }, "anchorSource": "chapter" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index cdd025b72..e3aec2645 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -311,7 +311,7 @@ { "chapter": "modules", "symbols": ["cache", "children", "id", "isPreloading", "loaded", "parent", "path", "paths", "Module"], "status": "partial", "evidence": ["tests/corpus/2880-cjs-module-graph/main.cjs"] }, { "chapter": "modules", "symbols": ["extensions"], "status": "not-implemented" }, { "chapter": "module", "symbols": ["module.createRequire"], "status": "partial", "evidence": ["tests/corpus/2631-create-require/main.ts", "tests/corpus/2637-create-require-bare/main.ts", "tests/corpus/2675-create-require-nested-esm.js", "tests/corpus/2932-create-require-program/main.ts", "tests/harness/npm-static.test.ts"] }, - { "chapter": "module", "symbols": ["module.isBuiltin", "module.syncBuiltinESMExports"], "status": "partial", "evidence": ["tests/corpus/2932-create-require-program/main.ts"] }, + { "chapter": "module", "symbols": ["module.isBuiltin", "module.syncBuiltinESMExports"], "status": "partial", "evidence": ["tests/corpus/2932-create-require-program/main.ts", "tests/corpus/5846-events-esm-default-max.ts"] }, { "chapter": "module", "symbols": ["builtinModules"], "status": "partial", "evidence": ["tests/corpus/2631-create-require/main.ts", "tests/corpus/2637-create-require-bare/main.ts", "tests/corpus/2932-create-require-program/main.ts"] }, { "chapter": "modules", "symbols": ["node:", "node_modules"], "status": "partial", "evidence": ["tests/corpus/1598-cjs-builtin-require/main.js", "tests/corpus/1596-cjs-modules/main.js"] }, { "chapter": "esm", "symbols": ["import", "file:", "node:", "await"], "status": "partial", "evidence": ["tests/corpus/950-modules-basic/main.ts", "tests/corpus/2646-top-level-await.ts", "tests/corpus/2933-static-dynamic-import/main.ts", "tests/corpus/2934-static-import-stored.ts", "tests/corpus/2935-module-namespace-values/main.ts"] }, @@ -390,7 +390,7 @@ { "symbols": ["crypto.randomInt"], "status": "partial", "evidence": ["tests/corpus/5442-crypto-random-values.ts", "tests/corpus/2890-crypto-static-utility.ts"] }, { "symbols": ["crypto.hkdf", "crypto.hkdfSync", "crypto.scrypt", "crypto.scryptSync"], "status": "partial", "evidence": ["tests/corpus/node24-crypto-kdf.ts", "tests/corpus/node24-crypto-kdf-errors.ts", "tests/corpus/node24-crypto-kdf-options.ts", "tests/corpus/5438-crypto-kdf-digests.ts"] }, { "symbols": ["EventEmitter", "emitter.on", "emitter.addListener", "emitter.once", "emitter.prependListener", "emitter.prependOnceListener", "emitter.off", "emitter.removeListener", "emitter.removeAllListeners", "emitter.emit", "emitter.listenerCount", "emitter.listeners", "emitter.rawListeners", "emitter.eventNames", "emitter.setMaxListeners", "emitter.getMaxListeners", "events.once"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.ts"] }, - { "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts"] }, + { "symbols": ["events.getEventListeners", "events.getMaxListeners", "events.listenerCount", "events.setMaxListeners", "events.defaultMaxListeners"], "status": "partial", "evidence": ["tests/corpus/5885-events-max-listener-helpers.ts", "tests/corpus/5846-events-esm-default-max.ts", "tests/corpus/5847-events-cjs-default-max.cjs"] }, { "symbols": ["AsyncLocalStorage", "asyncLocalStorage.run", "asyncLocalStorage.exit", "asyncLocalStorage.getStore", "asyncLocalStorage.enterWith", "asyncLocalStorage.disable"], "status": "partial", "evidence": ["tests/corpus/2213-async-local-storage.cjs"] }, { "symbols": ["StringDecoder", "stringDecoder.write", "stringDecoder.end"], "status": "partial", "evidence": ["tests/corpus/1662-string-decoder-encodings.ts", "tests/corpus/node24-string-decoder.ts", "tests/corpus/5882-string-decoder-runtime-encoding.ts"] }, { "symbols": ["setImmediate", "setInterval", "setTimeout", "clearImmediate", "clearInterval", "clearTimeout", "timersPromises.setTimeout", "timersPromises.setImmediate", "timersPromises.setInterval", "immediate.hasRef", "immediate.ref", "immediate.unref", "timeout.hasRef", "timeout.ref", "timeout.unref", "timeout.refresh"], "status": "partial", "evidence": ["tests/corpus/1804-timers-module.ts", "tests/corpus/1805-timer-callback-args.ts", "tests/corpus/2093-timers-promises.ts", "tests/corpus/2852-timers-promises-interval.ts", "tests/corpus/5830-timers-promises-signal.ts"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 6a85a8eb3..f69c96e6e 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -978,6 +978,8 @@ export const LIB_FN_SYMS: Record = { "emitter.getMax": "scr_emitter_get_max", "emitter.setDefaultMax": "scr_emitter_set_default_max", "emitter.getDefaultMax": "scr_emitter_get_default_max", + "emitter.esmDefaultMax": "scr_emitter_esm_default_max_get", + "emitter.esmSync": "scr_emitter_esm_sync", "emitter.errorMonitor": "scr_emitter_error_monitor", // node:stream (scr_stream.c): receivers borrowed, chunks borrowed, // chaining forms answer the receiver +1. The throwing members diff --git a/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts b/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts index 3fa6eb0c9..c0e178fcd 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-bindings.ts @@ -114,6 +114,24 @@ export function builtinImportOf( return null; } +/** The ES module binding kind of `ident`: "namespace" for a namespace + * import (`import * as events from "node:events"`), "named" for a named + * import (`import { defaultMaxListeners } from "node:events"`, also through + * a re-exporting module), null otherwise. Namespace members and named + * imports of a builtin read its ES module namespace, which holds copies of + * the module's exports (module.syncBuiltinESMExports refreshes them), where + * default imports and CommonJS bindings read the module object itself. */ +export function esmImportBindingKind( + lowerer: Lowerer, + ident: ts.Identifier, +): "namespace" | "named" | null { + const symbol = lowerer.checker.getSymbolAtLocation(ident); + const decl = symbol ? lowerer.checker.declarationsOf(symbol)[0] : undefined; + if (decl === undefined) return null; + if (ts.isNamespaceImport(decl)) return "namespace"; + return ts.isImportSpecifier(decl) ? "named" : null; +} + /** True for `const = require("").` — the * member-binding require import builtinImportOf resolves. Both * declaration walks (lowerVarDecl, collectGlobals) skip these: like a diff --git a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts index f1b36e3c6..5fcf35541 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-resolution.ts @@ -42,9 +42,10 @@ import { stripTypeCasts } from "./arguments.js"; /** node:module's two compiler-only calls. isBuiltin compares one evaluated * string against the pinned Node 24 list (bare builtins also accept their * node: spelling; prefix-only entries do not gain a bare alias). - * syncBuiltinESMExports is observably a no-op inside the static surface: - * builtin exports cannot be mutated, so its only supported behavior is - * evaluating no arguments and returning undefined. */ + * syncBuiltinESMExports takes no arguments and returns undefined; as a + * statement it refreshes the events module's ES module namespace copy of + * defaultMaxListeners (lowerExprStatement), the one builtin export a program + * can change. */ export function lowerNodeModuleCall( lowerer: Lowerer, expr: ts.CallExpression, @@ -64,7 +65,7 @@ export function lowerNodeModuleCall( lowerer.noLowering( "module.syncBuiltinESMExports used as a value", expr, - "call syncBuiltinESMExports() as its own statement; the supported static effect is a no-op", + "call syncBuiltinESMExports() as its own statement", ); } return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index f0538bb71..d2d990e16 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -8,6 +8,7 @@ import { lowerEventStaticProperty } from "./builtins/numbers.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; import { lowerWorkerMetadata } from "./builtins/workers.js"; +import { esmImportBindingKind } from "./builtins/module-bindings.js"; import { lowerReadlineProperty } from "./builtins/readline.js"; import { lowerBlobInstanceof } from "./builtins/blob.js"; import { lowerKeyObjectInstanceof } from "./builtins/key-object.js"; @@ -1174,6 +1175,20 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const value = lowerWorkerMetadata(bi.member, loc); if (value) return value; } + // A named import of defaultMaxListeners reads the ES module + // namespace's copy. A CommonJS destructured or member binding holds + // the value read when its declaration ran, which these bindings do + // not store. + if (bi.module === "events" && bi.member === "defaultMaxListeners") { + if (esmImportBindingKind(lowerer, expr) === "named") { + return { kind: "libCall", fn: "emitter.esmDefaultMax", args: [], type: F64, loc }; + } + lowerer.noLowering( + "events.defaultMaxListeners through a CommonJS destructured or member binding", + expr, + "read require('events').defaultMaxListeners or EventEmitter.defaultMaxListeners where the value is needed", + ); + } const c = builtinModuleConstOf(lowerer, bi.module, bi.member); if (c !== undefined) return builtinConstLit(c, loc); // module.builtinModules — the baked Node v24 list, a fresh diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ad4fd5312..cffe4c661 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6055,16 +6055,22 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm ); } // syncBuiltinESMExports() is the other compiler-only statement: the - // call validates its zero-argument shape, then emits no code because - // the static builtin surface is immutable. + // call validates its zero-argument shape, then refreshes the events + // module's ES module namespace copy of defaultMaxListeners, the one + // builtin export a program can change. const bi = ts.isIdentifier(expr.expression) ? lowerer.builtinImportOf(expr.expression) : ts.isPropertyAccessExpression(expr.expression) ? lowerer.builtinMemberOf(expr.expression) : null; if (bi?.module === "module" && bi.member === "syncBuiltinESMExports") { - lowerNodeModuleCall(lowerer, expr, bi, locOf(expr)); - return { kind: "block", body: [], loc: locOf(expr) }; + const loc = locOf(expr); + lowerNodeModuleCall(lowerer, expr, bi, loc); + return { + kind: "exprStmt", + expr: { kind: "libCall", fn: "emitter.esmSync", args: [], type: VOID, loc }, + loc, + }; } } if ( diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 12deec7ad..087b4e628 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -388,6 +388,7 @@ import { createRequireProgramModuleOf, createRequireSpecOf, fencedBuiltinImportOf, + esmImportBindingKind, } from "./builtins/module-bindings.js"; import { stripTypeCasts } from "./builtins/arguments.js"; import { lowerBuiltinModuleCall } from "./builtins/module-calls.js"; @@ -11462,9 +11463,19 @@ export class Lowerer { return pathModuleValue(this, `path/${bi.member}`, loc); } // events.defaultMaxListeners READS the process-wide default (its - // write twin routes through emitter.setDefaultMaxChk). + // write twin routes through emitter.setDefaultMaxChk). Through an ES + // module namespace it reads the namespace's copy instead. if (bi.module === "events" && bi.member === "defaultMaxListeners") { - return { kind: "libCall", fn: "emitter.getDefaultMax", args: [], type: F64, loc }; + const esm = + ts.isIdentifier(expr.expression) && + esmImportBindingKind(this, expr.expression) === "namespace"; + return { + kind: "libCall", + fn: esm ? "emitter.esmDefaultMax" : "emitter.getDefaultMax", + args: [], + type: F64, + loc, + }; } if (bi.module === "worker_threads") { const value = lowerWorkerMetadata(bi.member, loc); diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 848b34afe..d50fc8be3 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -2890,7 +2890,7 @@ export const BUILTIN_MODULE_FENCE_HINTS: Record< isBuiltin: "the lowered call checks a string against the baked Node v24 list, including bare/node: aliases and prefix-only builtins", syncBuiltinESMExports: - "the direct zero-argument call is a no-op because compiled builtin exports are immutable and cannot become stale", + "the direct zero-argument call statement refreshes the ES module namespace copy of events.defaultMaxListeners, the one builtin export a program can change", }, child_process: { execFile: diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index a9bc17ea6..7f31c5205 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3411,6 +3411,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "emitter.getMax": { argTypes: [null], result: F64 }, "emitter.setDefaultMax": { argTypes: [F64], result: VOID }, "emitter.getDefaultMax": { argTypes: [], result: F64 }, + // The ES module namespace's copy of defaultMaxListeners, and + // module.syncBuiltinESMExports() refreshing it. + "emitter.esmDefaultMax": { argTypes: [], result: F64 }, + "emitter.esmSync": { argTypes: [], result: VOID }, /** The events.errorMonitor event key: an immortal string the runtime * registry matches by identity, so no string event name reaches it. */ "emitter.errorMonitor": { argTypes: [], result: STRING }, diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 388abf0a7..f7d30a1bb 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5695,6 +5695,8 @@ function validateFunction( e.fn !== "emitter.setDefaultMax" && e.fn !== "emitter.setDefaultMaxChk" && e.fn !== "emitter.getDefaultMax" && + e.fn !== "emitter.esmDefaultMax" && + e.fn !== "emitter.esmSync" && e.fn !== "emitter.errorMonitor" && e.fn !== "emitter.checkListener" ) { diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 219095bc0..c01101f6f 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1367,7 +1367,7 @@ "name": "module.syncBuiltinESMExports", "status": "unsupported", "code": "SC2020", - "note": "the direct zero-argument call is a no-op because compiled builtin exports are immutable and cannot become stale" + "note": "the direct zero-argument call statement refreshes the ES module namespace copy of events.defaultMaxListeners, the one builtin export a program can change" }, { "id": "node-builtin.net", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5846-events-esm-default-max.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5846-events-esm-default-max.ts.json new file mode 100644 index 000000000..f1867c914 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5846-events-esm-default-max.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5846-events-esm-default-max.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5847-events-cjs-default-max.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5847-events-cjs-default-max.cjs.json new file mode 100644 index 000000000..1464ab2cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5847-events-cjs-default-max.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5847-events-cjs-default-max.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index 4f934ce24..ceecb15e3 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -1115,3 +1115,11 @@ void scr_emitter_set_default_max(double n) { scr_emitter_default_max = n; } double scr_emitter_get_default_max(void) { return scr_emitter_default_max; } + +/* The events module's ES module namespace holds a copy of + * defaultMaxListeners, taken when the program starts; ESM namespace + * and named-import reads answer it. module.syncBuiltinESMExports() copies + * the current value into it. */ +static SCR_TL double scr_emitter_esm_default_max = 10; +double scr_emitter_esm_default_max_get(void) { return scr_emitter_esm_default_max; } +void scr_emitter_esm_sync(void) { scr_emitter_esm_default_max = scr_emitter_default_max; } diff --git a/packages/runtime/src/scr_events_emitter.h b/packages/runtime/src/scr_events_emitter.h index c7edcf107..99da57a50 100644 --- a/packages/runtime/src/scr_events_emitter.h +++ b/packages/runtime/src/scr_events_emitter.h @@ -143,6 +143,8 @@ ScrEmitter *scr_emitter_set_max(ScrEmitter *em, double n); /* returns em +1 */ double scr_emitter_get_max(ScrEmitter *em); void scr_emitter_set_default_max(double n); double scr_emitter_get_default_max(void); +double scr_emitter_esm_default_max_get(void); +void scr_emitter_esm_sync(void); /* True when the emitter has at least one listener for `name` — the * stream unit's registration probe (and anyone else's). */ bool scr_emitter_has(ScrEmitter *em, const char *name); diff --git a/tests/corpus/5846-events-esm-default-max.ts b/tests/corpus/5846-events-esm-default-max.ts new file mode 100644 index 000000000..acc57328a --- /dev/null +++ b/tests/corpus/5846-events-esm-default-max.ts @@ -0,0 +1,28 @@ +// events.defaultMaxListeners through the module's ES module namespace and +// named imports reads the namespace's copy, which keeps the value from +// program start until module.syncBuiltinESMExports() refreshes it. The +// default import and the EventEmitter class read the current default. +import EventEmitterDefault, { defaultMaxListeners, EventEmitter, setMaxListeners } from "node:events"; +import * as events from "node:events"; +import { syncBuiltinESMExports } from "node:module"; + +const show = (label: string): void => { + console.log( + label, + events.defaultMaxListeners, + defaultMaxListeners, + EventEmitterDefault.defaultMaxListeners, + EventEmitter.defaultMaxListeners, + ); +}; +show("start"); +setMaxListeners(20); +show("after setMaxListeners"); +EventEmitter.defaultMaxListeners = 7; +show("after assignment"); +syncBuiltinESMExports(); +show("after sync"); +events.setMaxListeners(3); +show("after namespace setMaxListeners"); +const emitter = new EventEmitter(); +console.log(emitter.getMaxListeners(), events.getMaxListeners(emitter)); diff --git a/tests/corpus/5847-events-cjs-default-max.cjs b/tests/corpus/5847-events-cjs-default-max.cjs new file mode 100644 index 000000000..9edbe811d --- /dev/null +++ b/tests/corpus/5847-events-cjs-default-max.cjs @@ -0,0 +1,10 @@ +// CommonJS bindings of node:events read defaultMaxListeners from the module +// object, which is the EventEmitter class: every read sees the current +// default. +const events = require("node:events"); +const { EventEmitter, setMaxListeners } = require("node:events"); +console.log(events.defaultMaxListeners, EventEmitter.defaultMaxListeners); +setMaxListeners(15); +console.log(events.defaultMaxListeners, EventEmitter.defaultMaxListeners, require("events").defaultMaxListeners); +events.defaultMaxListeners = 4; +console.log(events.defaultMaxListeners, EventEmitter.defaultMaxListeners, new EventEmitter().getMaxListeners()); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 25f00b1f7..7ff735533 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1912,6 +1912,8 @@ "5990-worker-uncloneable.mjs": 3283, "5991-worker-async-dispose.mjs": 5699, "5992-worker-thread-name-lifetime.mjs": 2687, + "5846-events-esm-default-max.ts": 2633, + "5847-events-cjs-default-max.cjs": 2823, "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, @@ -4608,6 +4610,8 @@ "5990-worker-uncloneable.mjs": 22432, "5991-worker-async-dispose.mjs": 25768, "5992-worker-thread-name-lifetime.mjs": 28932, + "5846-events-esm-default-max.ts": 3102, + "5847-events-cjs-default-max.cjs": 3039, "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, From 201857eb6c811b4c31b7bd087d17c1e7ff062d10 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 07:54:21 -0500 Subject: [PATCH 16/17] Read the class base after the callable-base case in super calls superCallStmt asserted the class base before checking for a callable base. A class that extends an ordinary function constructor has no base class info, so the assertion held null. Node ignores the unused value; the native compiler throws the TypeError of a non-null assertion that held null, and failed to compile programs with such classes (for example the Effect stream fixtures). The base is now read after that case returns. --- .../limitations/runtime-type-checks.mdx | 2 ++ .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +++--- .../generated/node-v24-backlog.json | 19 ++++++----- .../generated/node-v24-internal.json | 12 ++++--- .../src/frontend/lowering/lower-classes.ts | 3 +- ...class-extends-function-constructor.js.json | 6 ++++ ...6020-class-extends-function-constructor.js | 27 ++++++++++++++++ tests/harness/corpus-costs.json | 2 ++ tests/harness/dyncheck.test.ts | 32 +++++++++++++++++++ tests/harness/native-frontend.ts | 1 + 11 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6020-class-extends-function-constructor.js.json create mode 100644 tests/corpus/6020-class-extends-function-constructor.js diff --git a/docs/content/limitations/runtime-type-checks.mdx b/docs/content/limitations/runtime-type-checks.mdx index 5514fb41e..ee7b953ce 100644 --- a/docs/content/limitations/runtime-type-checks.mdx +++ b/docs/content/limitations/runtime-type-checks.mdx @@ -3,3 +3,5 @@ Casts from values whose type is only known at runtime validate the value. For ex Casts between program classes are checked too: casting an instance to a class it does not extend throws a `TypeError`, where Node.js reads the members structurally. `SC6004` reports calls that pass such an instance to a function that casts it. A Promise converted from `unknown` to a typed result validates its fulfilled value. A mismatch rejects with a `TypeError`. Rejection reasons and reference equality are preserved across conversions. + +A non-null assertion (`value!`) checks the value when it extracts an object, array or other non-nullable member from a union. If the value holds `null` or `undefined`, the assertion throws a `TypeError`, even when the result is never used; Node.js continues until a property of the value is read. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 500876593..db36cdb52 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "56f660f5e2eb7391a048", + "artifactVersion": "cade1b8e6e0c5c42fcc9", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 568cdb4c9..dadc9e836 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -569,9 +569,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -57593,9 +57593,9 @@ "anchor": "", "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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 683d477a5..12b12d63b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1142, - "verify-gap": 2187, + "audit-partial": 1143, + "verify-gap": 2186, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -57507,12 +57507,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": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 393955bc0..2b97f675e 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -596,9 +596,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -80949,8 +80949,12 @@ "anchor": "", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] }, "anchorSource": "chapter" }, diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 8856ae9b5..06739166c 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -7861,7 +7861,6 @@ export function superCallStmt( args: IrExpr[], loc: SrcLoc, ): IrStmt { - const base = superBaseOf(info)!; const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, @@ -7906,6 +7905,8 @@ export function superCallStmt( }, loc, }; + // A callable base has no class info, so the base is read after that case. + const base = superBaseOf(info)!; if (base.builtinError) { // super(message) into the runtime-provided Error constructor: stamps // name/message on the (already-allocated) object. Receiver + message diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6020-class-extends-function-constructor.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6020-class-extends-function-constructor.js.json new file mode 100644 index 000000000..b2fa4c986 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6020-class-extends-function-constructor.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6020-class-extends-function-constructor.js" + ], + "diags": [] +} diff --git a/tests/corpus/6020-class-extends-function-constructor.js b/tests/corpus/6020-class-extends-function-constructor.js new file mode 100644 index 000000000..d98d22a55 --- /dev/null +++ b/tests/corpus/6020-class-extends-function-constructor.js @@ -0,0 +1,27 @@ +// Classes whose base is an ordinary function constructor, with a declared +// constructor calling super and with the implicit derived constructor. +function Shape(name, sides) { + this.name = name; + this.sides = sides; +} +Shape.prototype.describe = function () { + return `${this.name} with ${this.sides} sides`; +}; + +class Polygon extends Shape { + constructor(name, sides, length) { + super(name, sides); + this.length = length; + } + perimeter() { + return this.sides * this.length; + } +} + +class Unnamed extends Shape {} + +const square = new Polygon("square", 4, 3); +console.log(square.describe(), square.perimeter()); +console.log(square instanceof Polygon); +const shape = new Unnamed("triangle", 3); +console.log(shape.describe(), Object.keys(shape).join(",")); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 25f00b1f7..a4a12bf4c 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1915,6 +1915,7 @@ "600-closures-basic.ts": 2349, "601-closures-loops.ts": 1788, "602-closures-identity-recursion.ts": 2372, + "6020-class-extends-function-constructor.js": 4300, "603-closures-rc-stress.ts": 1832, "604-closures-for-of.ts": 2639, "605-closures-forward-capture-tdz.ts": 3277, @@ -4611,6 +4612,7 @@ "600-closures-basic.ts": 2534, "601-closures-loops.ts": 2297, "602-closures-identity-recursion.ts": 2427, + "6020-class-extends-function-constructor.js": 7500, "603-closures-rc-stress.ts": 2589, "604-closures-for-of.ts": 3514, "605-closures-forward-capture-tdz.ts": 4638, diff --git a/tests/harness/dyncheck.test.ts b/tests/harness/dyncheck.test.ts index ca597c0db..2ab323f37 100644 --- a/tests/harness/dyncheck.test.ts +++ b/tests/harness/dyncheck.test.ts @@ -764,6 +764,38 @@ console.log("recovered"); expect(r.stdout).toBe("TypeError true\narray: TypeError\nfunc: TypeError\nrecovered\n"); }); + test("a non-null assertion on a null object result throws at the assertion, even when the binding is never read", async () => { + // Node lets the null ride until a member read; the compiled program + // throws the stranded TypeError where `!` extracts the object. + const r = await compileAndRun( + "asserted-null-object-result", + `interface Info { + name: string; + base: Info | null; + callable: boolean; +} +function baseOf(info: Info): Info | null { + return info.base; +} +function describe(info: Info): string { + const base = baseOf(info)!; + if (info.callable) return info.name + ": callable base"; + return info.name + " extends " + base.name; +} +const root: Info = { name: "Root", base: null, callable: false }; +console.log(describe({ name: "Child", base: root, callable: false })); +try { + console.log("unreachable", describe({ name: "Wrapped", base: null, callable: true })); +} catch (e) { + console.log((e as Error).name, (e as Error).message.includes("not representable in the target union")); +} +console.log("recovered"); +`, + ); + expect(r.exitCode).toBe(0); + expect(r.stdout).toBe("Child extends Root\nTypeError true\nrecovered\n"); + }); + test("a checker-approved function mismatch strands per piece: mismatched params compile at the flow, void results trap after the call", async () => { // funcCoerceAdapter's stranded dispositions. Param strand: an array // literal of arrows collapses to one element signature — the diff --git a/tests/harness/native-frontend.ts b/tests/harness/native-frontend.ts index feee2f591..475db54aa 100644 --- a/tests/harness/native-frontend.ts +++ b/tests/harness/native-frontend.ts @@ -65,6 +65,7 @@ export const programs = [ "nullish-long-chain.ts", "class-array-optional-return.ts", "contextual-array-union-write.ts", + "6020-class-extends-function-constructor.js", ]; interface LowerRequest { From fc68a582d0614932dc360d7bc38e168f361cd134 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 18 Oct 2026 07:59:03 -0500 Subject: [PATCH 17/17] Remove the last element of a packed array inline on pop pop and a discarded pop (the length - 1 store) branch on the array's length and hole-free prefix bound. When the bound equals the length, the last element is a present dense value: the emitted code clears its slot and state, lowers the bound and the length, and moves the slot out (or releases it for a discarded pop). An empty array answers undefined without a call. Holes, present undefined and sparse storage keep the runtime call. --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +- .../generated/node-v24-backlog.json | 19 ++- .../generated/node-v24-internal.json | 12 +- .../backend/llvm/dense-array-access.test.ts | 79 +++++++++- .../src/backend/llvm/dense-array-access.ts | 69 +++++++++ packages/compiler/src/backend/llvm/emitter.ts | 29 +++- .../src/backend/llvm/expr-containers.ts | 39 ++++- .../corpus/5681-array-pop-and-length.ts.json | 6 + tests/corpus/5681-array-pop-and-length.ts | 143 ++++++++++++++++++ tests/harness/corpus-costs.json | 2 + 11 files changed, 382 insertions(+), 28 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5681-array-pop-and-length.ts.json create mode 100644 tests/corpus/5681-array-pop-and-length.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index 500876593..db36cdb52 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.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", - "artifactVersion": "56f660f5e2eb7391a048", + "artifactVersion": "cade1b8e6e0c5c42fcc9", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 568cdb4c9..dadc9e836 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -569,9 +569,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -57593,9 +57593,9 @@ "anchor": "", "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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index 683d477a5..12b12d63b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -7,8 +7,8 @@ "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1142, - "verify-gap": 2187, + "audit-partial": 1143, + "verify-gap": 2186, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -57507,12 +57507,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": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 393955bc0..2b97f675e 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -596,9 +596,9 @@ "apiEntries": 120, "static": { "supported": 0, - "partial": 50, + "partial": 51, "refused": 0, - "not-implemented": 70, + "not-implemented": 69, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -80949,8 +80949,12 @@ "anchor": "", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:process" + "status": "partial", + "evidence": "surface-manifest:node-builtin.process.execArgv", + "tests": [ + "tests/corpus/990-process-basics.ts", + "tests/corpus/2314-process-introspection.ts" + ] }, "anchorSource": "chapter" }, diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index dc23ceae2..31680a518 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -3,6 +3,7 @@ import { BOOL, F64, STRING, + UNDEFINED_T, VOID, arrayOf, type IrExpr, @@ -23,11 +24,17 @@ const ref = (localId: string, type: IrExpr["type"]): IrExpr => ({ }); const index = ref("i", F64); -function module(name: string, returnType: IrExpr["type"], body: IrStmt[]): IrModule { +function module( + name: string, + returnType: IrExpr["type"], + body: IrStmt[], + unions: IrModule["unions"] = [], +): IrModule { return { irVersion: 16, sourceFile: loc.file, entry: "__main", + unions, functions: [ { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, { @@ -174,6 +181,76 @@ test.each([32, 64] as const)( }, ); +test.each([32, 64] as const)( + "pop removes the last element of a packed array inline and keeps a cold runtime pop (%i-bit)", + (bits) => { + const maybe = { kind: "union", unionId: "maybe" } as const; + const pop: IrExpr = { + kind: "arrIntrinsic", + method: "pop", + receiver: ref("a", nums), + args: [], + type: maybe, + loc, + }; + const llvm = emitLlvmModule( + module( + "take", + maybe, + [{ kind: "return", value: pop, loc }], + [{ id: "maybe", arms: [F64, UNDEFINED_T] }], + ), + { pointerBits: bits }, + ); + const fn = body(llvm, "take"); + const size = bits === 32 ? "i32" : "i64"; + // An empty array answers undefined without a call. + expect(fn).toMatch(new RegExp(`icmp eq ${size} %t\\d+, 0\\n br i1 %t\\d+, label %remove\\.a`)); + expect(fn).toMatch(new RegExp(`sub nuw ${size} %t\\d+, 1`)); + expect(fn).toMatch(/store i64 0, ptr /); + expect(fn).toMatch(/store i8 0, ptr /); + expect(fn).toMatch(/phi i64 /); + expect(fn).toMatch(/call zeroext i8 @scr_arr_pop_state\(.*\) cold$/m); + }, +); + +test("a discarded pop releases the removed element inline", () => { + const words = ref("s", arrayOf(STRING)); + const length: IrExpr = { + kind: "arrIntrinsic", + method: "length", + receiver: words, + args: [], + type: F64, + loc, + }; + const llvm = emitLlvmModule( + module("drop", VOID, [ + { + kind: "arraySetLength", + arr: words, + length: { + kind: "bin", + op: "-", + left: length, + right: { kind: "numLit", value: 1, type: F64, loc }, + type: F64, + loc, + }, + loc, + pop: true, + }, + ]), + ); + const fn = body(llvm, "drop"); + expect(fn).toMatch(/fsub double %t\d+, 1\.0/); + expect(fn).toMatch(/load i64, ptr .*\n(?:.*\n)*? store i64 0, ptr /); + expect(fn).toMatch(/inttoptr i64 %t\d+ to ptr/); + expect(fn).toContain("call void @scr_str_release("); + // Holes, sparse storage, and every other length keep the runtime call. + expect(fn).toContain("call void @scr_arr_set_len("); +}); + test.each([32, 64] as const)( "indices below the hole-free prefix bound skip the state bytes (%i-bit)", (bits) => { diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index aeaf3e70f..183bb8239 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -508,6 +508,75 @@ export function emitDenseArraySet( B.startBlock(join); } +/** The dense half of `pop`. An empty array branches to `empty` (the + * runtime's pop returns a hole state there and changes nothing). When the + * hole-free prefix bound equals the length, the last element is a present + * value in dense storage, and the runtime's pop is exactly: clear its slot + * and state, lower the bound and the length to its index, and move the + * slot out. That block is left open and its slot bits are returned as an + * i64 (a boolean's 0 or 1, zero-extended); every other array branches to + * `slow`, which keeps the runtime call. */ +export function emitDenseArrayPop( + host: LlvmEmitterContext, + array: string, + acc: ArrayAccess, + empty: string, + slow: string, +): string { + const B = host.B; + const size = host.sizeType; + const header = (field: number): string => { + const ptr = B.tmp(); + B.line(`${ptr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${field}`); + host.markMemoryPointer(ptr, "array:header"); + return ptr; + }; + const lenPtr = header(SCR_ARR_FIELD.len), + len = B.tmp(), + none = B.tmp(); + B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + B.line(`${none} = icmp eq ${size} ${len}, 0`); + const check = B.newLabel("arr.pop.check"), + dense = B.newLabel("arr.pop.dense"); + B.condBr(none, empty, check); + B.startBlock(check); + const filledPtr = header(SCR_ARR_FIELD.filled), + filled = B.tmp(), + packed = B.tmp(); + B.line(`${filled} = load i32, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.line(`${packed} = icmp eq ${size} ${sizedFilled(host, filled)}, ${len}`); + B.condBr(packed, dense, slow); + B.startBlock(dense); + const last = B.tmp(); + B.line(`${last} = sub nuw ${size} ${len}, 1`); + const dataPtr = header(SCR_ARR_FIELD.data), + data = B.tmp(), + statesPtr = header(SCR_ARR_FIELD.present), + states = B.tmp(); + B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`); + B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`); + const valuePtr = valuePointer(host, { offset: last, data }, acc); + const statePtr = statePointer(host, { offset: last, states }); + const slotTy = slotType(acc); + let raw = B.tmp(); + B.line(`${raw} = load ${slotTy}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + if (slotTy === "i8") { + const wide = B.tmp(); + B.line(`${wide} = zext i8 ${raw} to i64`); + raw = wide; + } + B.line(`store ${slotTy} 0, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`); + B.line(`store i8 0, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`); + let last32 = last; + if (size === "i64") { + last32 = B.tmp(); + B.line(`${last32} = trunc i64 ${last} to i32`); + } + B.line(`store i32 ${last32}, ptr ${filledPtr}${host.fieldAliasAttachment(filledPtr)}`); + B.line(`store ${size} ${last}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`); + return raw; +} + /** `scr_arr_push_*` (the array takes the value's reference) returning the * new length as a double. When `len < cap` the runtime's append is exactly * a dense store at `len` plus `len + 1`: no sparse entry can exist past the diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8bae1abaf..79ad75d02 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -199,7 +199,12 @@ import { type LocalArrayRead, } from "./local-array-reads.js"; import { CheckedNarrows, emitCheckedNarrowReceiver } from "./checked-narrows.js"; -import { emitDenseArrayGet, emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js"; +import { + emitDenseArrayGet, + emitDenseArrayPop, + emitDenseArraySet, + emitDenseSlotUnlink, +} from "./dense-array-access.js"; import { emitStackMapRead, findMapReadLifetimes, @@ -8130,6 +8135,28 @@ export class LlEmitter { B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`); B.condBr(same, done, resize); B.startBlock(resize); + if (s.pop === true) { + // A discarded pop's `length - 1` on a packed array removes the + // last element inline, then releases it. + const call = B.newLabel("arr.len.call"), + pop = B.newLabel("arr.len.pop"); + const lastNumber = B.tmp(), + dropsLast = B.tmp(); + B.line(`${lastNumber} = fsub double ${lenNumber}, 1.0`); + B.line(`${dropsLast} = fcmp oeq double ${length.name}, ${lastNumber}`); + B.condBr(dropsLast, pop, call); + B.startBlock(pop); + const elem = s.arr.type.elem; + const acc = elemAccess(elem); + const raw = emitDenseArrayPop(this, arr.name, acc, call, call); + if (acc === "ref" && isRefCounted(elem)) { + const old = B.tmp(); + B.line(`${old} = inttoptr i64 ${raw} to ptr`); + this.releaseValue(old, elem); + } + B.br(done); + B.startBlock(call); + } B.line(`call void @scr_arr_set_len(ptr ${arr.name}, double ${length.name})`); this.emitPendingCheck(); B.br(done); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index a2d5fd35a..9c77690f8 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -49,7 +49,11 @@ import { STRING_UTF16_LEN_DECL, } from "./string-reads.js"; import { exactInteger, widenInteger } from "./integer-values.js"; -import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js"; +import { + emitDenseArrayNumber, + emitDenseArrayPop, + emitDenseArrayPush, +} from "./dense-array-access.js"; import { emitUnionWiden } from "./expr-records.js"; export function resolveThunkFor(host: LlvmEmitterContext, inner: IrType): string { @@ -667,19 +671,38 @@ export function emitArrIntrinsic( const rawSlot = B.slot(); const resultSlot = B.slot(); B.entryAllocas.push(`${rawSlot} = alloca i64`, `${resultSlot} = alloca ptr`); - B.line(`store i64 0, ptr ${rawSlot}`); - const state = B.tmp(); - const has = B.tmp(); - B.line(`${state} = call zeroext i8 @scr_arr_${method}_state(ptr ${r.name}, ptr ${rawSlot})`); - B.line(`${has} = icmp eq i8 ${state}, 1`); const lp = B.newLabel("remove.p"); const la = B.newLabel("remove.a"); const lj = B.newLabel("remove.j"); + // pop answers an empty or packed array inline; shift and every other + // pop take the runtime call. + let denseRaw: string | null = null; + let denseLabel = ""; + let slowLabel = ""; + if (method === "pop") { + slowLabel = B.newLabel("remove.slow"); + denseRaw = emitDenseArrayPop(host, r.name, acc, la, slowLabel); + denseLabel = B.currentLabel(); + B.br(lp); + B.startBlock(slowLabel); + } + B.line(`store i64 0, ptr ${rawSlot}`); + const state = B.tmp(); + const has = B.tmp(); + const slowRaw = B.tmp(); + B.line( + `${state} = call zeroext i8 @scr_arr_${method}_state(ptr ${r.name}, ptr ${rawSlot})${denseRaw === null ? "" : " cold"}`, + ); + B.line(`${slowRaw} = load i64, ptr ${rawSlot}`); + B.line(`${has} = icmp eq i8 ${state}, 1`); B.condBr(has, lp, la); B.startBlock(lp); - const raw = B.tmp(); + let raw = slowRaw; const value = B.tmp(); - B.line(`${raw} = load i64, ptr ${rawSlot}`); + if (denseRaw !== null) { + raw = B.tmp(); + B.line(`${raw} = phi i64 [ ${denseRaw}, %${denseLabel} ], [ ${slowRaw}, %${slowLabel} ]`); + } if (into) { // Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool. let bits = raw; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5681-array-pop-and-length.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5681-array-pop-and-length.ts.json new file mode 100644 index 000000000..d20701cae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5681-array-pop-and-length.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5681-array-pop-and-length.ts" + ], + "diags": [] +} diff --git a/tests/corpus/5681-array-pop-and-length.ts b/tests/corpus/5681-array-pop-and-length.ts new file mode 100644 index 000000000..451b32576 --- /dev/null +++ b/tests/corpus/5681-array-pop-and-length.ts @@ -0,0 +1,143 @@ +// Removing the last element with a statement-level pop() or `length = n`, +// for every element kind, on dense arrays, arrays with holes and present +// undefined values, sparse arrays and arrays shared between bindings. +// Removed references are released; the remaining elements, the length and +// later appends match Node. + +class Item { + readonly id: number; + constructor(id: number) { + this.id = id; + } +} + +function show(label: string, values: unknown[]): void { + const parts: string[] = []; + for (let i = 0; i < values.length; i++) { + if (!(i in values)) parts.push(""); + else { + const v = values[i]; + parts.push(v instanceof Item ? `I${v.id}` : (JSON.stringify(v) ?? "undefined")); + } + } + console.log(label, values.length, parts.join(" ")); +} + +const numbers = [1.5, 2.5, 3.5, 4.5]; +numbers.pop(); +numbers.length = numbers.length - 1; +numbers.push(9); +show("numbers", numbers); + +const flags = [true, false, true]; +flags.pop(); +flags.length = 1; +flags.push(false); +show("flags", flags); + +const words = ["a", "b", "c", "d"]; +const alias = words; +words.pop(); +alias.length = alias.length - 1; +words.push("z"); +show("words", words); +show("alias", alias); + +const items = [new Item(1), new Item(2), new Item(3)]; +const kept = items[2]!; +items.pop(); +items.length = 1; +items.push(new Item(4)); +show("items", items); +console.log("kept", kept.id); + +// Holes and present undefined at the end. +const holes: (number | undefined)[] = [1, 2]; +holes[4] = 5; +holes.length = 4; +show("holes trimmed", holes); +holes.length = 3; +show("holes trimmed again", holes); +const undef: (string | undefined)[] = ["x", undefined, undefined]; +undef.pop(); +undef.length = undef.length - 1; +show("undefined", undef); + +// A sparse array: a far index then shrinking past it and back by one. +const sparse: number[] = [1, 2, 3]; +sparse[2000000] = 4; +sparse.length = 2000000; +show("sparse cut", sparse.slice(0, 4)); +console.log("sparse length", sparse.length, 1999999 in sparse); +sparse.length = sparse.length - 1; +console.log("sparse length", sparse.length); + +// A stack used the way a checker pushes and pops contextual entries. +const stack: Item[] = []; +const top: number[] = []; +let checksum = 0; +for (let i = 0; i < 20000; i++) { + stack.push(new Item(i)); + top.push(i); + if (i % 3 === 2) { + stack.pop(); + top.length = top.length - 1; + } + if (stack.length > 50) { + stack.length = stack.length - 1; + top.pop(); + } + checksum = (checksum + stack[stack.length - 1]!.id + top[top.length - 1]!) % 1000003; +} +console.log("stack", stack.length, top.length, checksum); + +// Lengths that are not one less: unchanged, longer, invalid. +const other = [1, 2, 3]; +other.length = 3; +other.length = 5; +show("longer", other); +other.length = 1; +show("shorter by many", other); +try { + other.length = -1; +} catch (e) { + console.log("invalid", (e as Error).name, other.length); +} +try { + other.length = 0.5; +} catch (e) { + console.log("fraction", (e as Error).name, other.length); +} +const empty: number[] = []; +empty.pop(); +empty.length = 0; +show("empty", empty); + +// pop() results: the last element, undefined for an empty array, a hole or a +// present undefined, and values taken from sparse storage. +const nums = [0.5, -0, 7]; +console.log("pop number", nums.pop(), Object.is(nums.pop(), -0), nums.pop(), nums.pop(), nums.length); +const bools = [false, true]; +console.log("pop bool", bools.pop(), bools.pop(), bools.pop(), bools.length); +const strs = ["p", "q"]; +const strAlias = strs; +console.log("pop string", strs.pop(), strAlias.pop(), strs.pop(), strAlias.length); +const objs = [new Item(7), new Item(8)]; +const popped = objs.pop(); +console.log("pop item", popped?.id, objs.pop()?.id, objs.pop(), objs.length); +const gaps: number[] = [1]; +gaps[3] = 4; +console.log("pop gaps", gaps.pop(), gaps.pop(), gaps.pop(), gaps.pop(), gaps.pop(), gaps.length); +const maybe: (number | undefined)[] = [1, undefined]; +console.log("pop present undefined", maybe.pop(), maybe.length, maybe.pop(), maybe.length); +const far: string[] = ["a"]; +far[100000] = "b"; +console.log("pop sparse", far.pop(), far.length, far.pop(), far.length); +let popTotal = 0; +const work: number[] = []; +for (let i = 0; i < 10000; i++) { + work.push(i, i + 1); + popTotal += work.pop()!; + if (i % 5 === 0) popTotal += work.pop() ?? -1; +} +console.log("pop loop", work.length, popTotal); diff --git a/tests/harness/corpus-costs.json b/tests/harness/corpus-costs.json index 25f00b1f7..ebaa91179 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,6 +1841,7 @@ "5648-dns-lookup-forms.ts": 2992, "5649-dns-service-servers.ts": 2712, "5650-dns-lookup-errors-untyped.cjs": 2822, + "5681-array-pop-and-length.ts": 3754, "5800-broadcast-channel.ts": 2899, "5801-broadcast-channel.cjs": 2727, "5802-broadcast-channel-workers.ts": 2809, @@ -4537,6 +4538,7 @@ "5648-dns-lookup-forms.ts": 3416, "5649-dns-service-servers.ts": 4916, "5650-dns-lookup-errors-untyped.cjs": 3889, + "5681-array-pop-and-length.ts": 3216, "5800-broadcast-channel.ts": 28374, "5801-broadcast-channel.cjs": 27251, "5802-broadcast-channel-workers.ts": 27251,