diff --git a/.gitignore b/.gitignore index f747b4f62..e5b54a5fa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .pnpm-store/ # test-fixture packages are TEST DATA, committed on purpose !tests/diagnostics/npm-imports/node_modules/ +!tests/diagnostics/npm-program-type-error/node_modules/ !tests/diagnostics/retired-engine-import-refusal/node_modules/ !tests/fixtures/commander-calc/node_modules/ !tests/fixtures/npm/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f191367ca..3de3f334f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ All notable changes to scriptc will be documented in this file. +## 0.3.6 + +### Features + +- **`node:readline` adds terminal mode, cursor helpers and `emitKeypressEvents`,** along with the `history` and `completer` options and the history members. +- **`node:http` supports request `signal`s, `destroy(err)`, `servername`, `Expect` events and `maxHeaderSize`.** +- **`createHash` and `createHmac` values work as streams.** +- **`node:test` adds the `concurrency`, `plan` and `timeout` options.** +- **`fs.cpSync`, `fs.cp` and `fs/promises` `cp` are supported.** +- **`util.promisify` works over `setTimeout`, `setImmediate`, `stream.finished`, `stream.pipeline` and the zlib callback one-shots.** +- **`Array.fromAsync` works over arrays,** `Readable.from` accepts Sets, iterators and class iterables, and the `Set` composition methods accept `Map` arguments. +- **`using` disposes `Disposable`-typed values,** and `SuppressedError` exposes `error` and `suppressed`. + +### Performance + +- **Int32 class fields stay int32 next to arithmetic on dynamic operands** and in programs that register process event listeners. A large TypeScript type-checker workload runs about 12% faster with about 5% less memory. +- **`while` loops bounded by a string's length keep int32 counters.** +- **Worker threads reserve heap growth up front** once they allocate many large blocks, and share one prefault budget. +- **Small runtime functions are inlined into release executables.** +- **Integers are speculated only where the static range proof gives up.** +- **Constant-key dynamic reads are cached per site,** and `JSON.parse` follows repeated key sequences. +- **The zlib one-shot forms run on the stream engine,** and `statSync`'s `throwIfNoEntry` option no longer builds the error it discards. + +### Fixes + +- **Class instances keep their identity through structural record views,** including in `switch` statements and assertions. +- **Node 24 builtin forms that ran with wrong output now match Node or are refused,** and `Object.groupBy` results have a null prototype. +- **IPv4 hosts in special-scheme URLs parse like WHATWG URL.** +- **fs paths with a null byte are refused as Node does.** +- **`Object.setPrototypeOf` returns primitive receivers,** and `createHmac` without a key throws Node's error. +- **Boxed records that hold an async function link the dynamic promise unit.** + + + ## 0.3.5 ### Features @@ -36,8 +70,6 @@ All notable changes to scriptc will be documented in this file. - **Publication walkers with more than ten pushes keep distinct value names,** and int32 field storage survives reflection reads through `unknown`. - **Native program objects use runtime ABI v22.** External object consumers must link the matching v0.3.5 runtime pack; the versioned marker rejects incompatible runtime objects at link time. - - ## 0.3.4 ### Features diff --git a/benchmarks/scaling/arrays.ts b/benchmarks/scaling/arrays.ts index 1e731bc67..3e907d358 100644 --- a/benchmarks/scaling/arrays.ts +++ b/benchmarks/scaling/arrays.ts @@ -40,6 +40,36 @@ function queue(n: number): string { } return `${s} ${q.length}`; } +function spliceFront(n: number): string { + const a = make(n); + let s = 0; + while (a.length > 0) s += a.splice(0, 1)[0]!; + return `${s}`; +} +function queueGrow(n: number): string { + // BFS over a binary tree: the frontier grows to ~n/2 entries. + const q: number[] = [1]; + let s = 0; + let seen = 1; + while (q.length > 0) { + const v = q.shift()!; + s += v; + if (seen < n) { q.push(v * 2); seen++; } + if (seen < n) { q.push(v * 2 + 1); seen++; } + } + return `${s % 1000000007} ${seen}`; +} +function deque(n: number): string { + // Values enter and leave at both ends; ~n/3 stay live. + const d: number[] = []; + let s = 0; + for (let i = 0; i < n; i++) { + if (i & 1) d.unshift(i); + else d.push(i); + if (i % 3 === 0) s += d.shift()!; + } + return `${s} ${d.length} ${d[0]} ${d[d.length - 1]}`; +} function unshift(n: number): string { const a: number[] = []; for (let i = 0; i < n; i++) a.unshift(i); @@ -216,6 +246,9 @@ switch (which) { case "shift": out = shift(n); break; case "queue": out = queue(n); break; case "unshift": out = unshift(n); break; + case "splice-front": out = spliceFront(n); break; + case "queue-grow": out = queueGrow(n); break; + case "deque": out = deque(n); break; case "splice-tail": out = spliceTail(n); break; case "splice-mid": out = spliceMid(n); break; case "slice-small": out = sliceSmall(n); break; diff --git a/benchmarks/scaling/run.mjs b/benchmarks/scaling/run.mjs index 907ac4d69..3bd68e949 100644 --- a/benchmarks/scaling/run.mjs +++ b/benchmarks/scaling/run.mjs @@ -35,7 +35,8 @@ const SQ = [250, 500, 1000, 2000]; // [file, case, sizes] const CASES = [ ...[ - "push", "pop", "shift", "queue", "splice-tail", "slice-small", "concat", "indexOf", + "push", "pop", "shift", "queue", "queue-grow", "splice-front", "deque", "splice-tail", + "slice-small", "concat", "indexOf", "includes-str", "sort-num", "sort-sorted", "sort-str", "sort-obj", "reverse", "in-arr", "in-arr-str", "holes", "sparse", "spread", "map-filter-reduce", "array-from", "join", "flat", "lastIndexOf", "findIndex", "nested2d", "length-truncate", diff --git a/docs/content/docs/contracts.mdx b/docs/content/docs/contracts.mdx index cb0a3f75d..d1bfb8cad 100644 --- a/docs/content/docs/contracts.mdx +++ b/docs/content/docs/contracts.mdx @@ -25,6 +25,11 @@ Contracts are type aliases imported from `@scriptc/types` with `import type`. Ty Every value stored is a signed 32-bit integer by construction. The variable, field, parameter or result is stored as a 32-bit integer. + + Int32[] + Every element stored into the array, through any reference, is a signed 32-bit integer by construction. + Integer arithmetic over its elements runs in 32-bit integer operations. + Immutable<T> No code writes to the value, through any reference. @@ -120,6 +125,8 @@ scriptc recognizes a contract where it is written as the annotation of a declara
The written return type of a function, method or arrow function. NoThrow<Int32> and NoThrow<Immutable<T>> combine the result contracts.
Int32
Parameters, variables, class instance fields and function results whose type is a plain number.
+
Int32[]
+
Parameters, variables, class instance fields and function results whose type is a plain number[], written Int32[], readonly Int32[], Array<Int32> or ReadonlyArray<Int32>.
Immutable<T>
Parameters, variables, class instance fields and function results.
Acyclic
@@ -128,7 +135,7 @@ scriptc recognizes a contract where it is written as the annotation of a declara
The implements clause of a class declaration: class Node implements ProgramLifetime.
-A contract anywhere else (a type alias, an interface member, an element or type argument such as `Int32[]`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. +A contract anywhere else (a type alias, an interface member, a nested array or other type argument such as `Int32[][]` or `Map`, a function type) would promise something scriptc does not check, so the build reports it with SC7006. ## NoThrow @@ -178,6 +185,68 @@ Each value stored into an `Int32` slot (initializers, assignments, compound assi scriptc refuses an `Int32` slot whose writes it cannot all see (SC7003): a parameter of a function used as a value, or a field of a class whose instances are stored in `any` or `unknown` values. +## Int32 arrays + +`Int32[]` promises that every element stored into the array is an int32, so an element read that finds a value reads an int32. Integer kernels over such arrays, such as sums, hashes and prefix sums written with `| 0` or `Math.imul`, then compute in 32-bit integer operations instead of converting every element and intermediate result between doubles and integers: + +```ts:checksum.ts +import type { Int32 } from "@scriptc/types"; + +function checksum(data: Int32[]): number { + let h = 17; + for (let i = 0; i < data.length; i++) h = (Math.imul(h, 31) + data[i]) | 0; + return h; +} + +const samples: Int32[] = []; +for (let i = 0; i < 1000; i++) samples.push((i * 7919) & 0xffff); +console.log(checksum(samples)); +samples[1200] = -1; +console.log(samples.length, samples[1100], checksum(samples)); +``` + +```console +$ node checksum.ts +-220195363 +1201 undefined -1 +$ scriptc build checksum.ts -o checksum >/dev/null +$ ./checksum +-220195363 +1201 undefined -1 +``` + +The contract constrains the values stored, not the array's shape. Holes, present `undefined` and indices past the end read as `undefined`, and as `NaN` in arithmetic, exactly as in Node.js; writing past the end or changing `length` behaves as usual. The elements stay ordinary numbers, so `a[i] + a[j]` can exceed the int32 range. + +The promise belongs to the array, not to one variable, so scriptc checks every store into any array that can reach an `Int32[]` slot, through any reference and wherever it happens, including before the array reaches the slot: `a[i] = v`, `push`, `unshift`, `fill`, `with`, `splice` items, array literal elements, and elements copied in by spreads and `concat`. Each stored value must be an int32 in the same sense as for `Int32`, and an element read from another `Int32[]` array counts as one. Arrays flow through parameters, results, variables, class fields, object properties, `Map` values, nested arrays and captured bindings; reads through any of them are faster when only checked arrays can reach them, so the annotation is needed only where the program declares the array: + +```ts:scale.ts +import type { Int32 } from "@scriptc/types"; + +function scale(values: number[], factor: number): void { + for (let i = 0; i < values.length; i++) values[i] = values[i] * factor; +} + +const counts: Int32[] = [3, 5, 8]; +scale(counts, 1.5); +console.log(counts); +``` + +```console +$ scriptc build scale.ts +/tmp/app/scale.ts:4:55 - error SC7002: the element stored into 'values' is not provably an int32: 'values[i] * factor' (the array can reach 'counts', declared Int32[]) + + 3 | function scale(values: number[], factor: number): void { + 4 | for (let i = 0; i < values.length; i++) values[i] = values[i] * factor; + | ^~~~~~~~~~~~~~~~~~ + 5 | } + + hint: make the int32 conversion explicit, for example '(values[i] * factor) | 0', so Node stores the same value + +1 error. +``` + +A copy such as `slice()` or a spread is checked as if it were the same array as its source. The results of callbacks are not followed, so an array built by `map` or `Array.from` with a mapping function cannot be `Int32[]`; build it with a loop that stores `value | 0`. scriptc refuses an `Int32[]` slot whose array can come from code it cannot see, such as `JSON.parse`, dynamic values or the callers of a function used as a value, and an array that can reach one and escapes to such code, such as a call through a function value (SC7003). + ## Immutable `Immutable` is a deep read-only view, so TypeScript already rejects direct writes such as `KEYWORDS.set(...)`. scriptc also checks the writes TypeScript cannot see: @@ -262,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"; @@ -371,11 +440,11 @@ $ scriptc build reject.ts SC7002 - A value stored into an Int32 slot is not provably an int32; the hint shows the | 0 form. + A value stored into an Int32 slot, or an element stored into an array that can reach an Int32[] slot, is not provably an int32; the hint shows the | 0 form. SC7003 - An Int32 slot has writes scriptc cannot see. + An Int32 slot has writes scriptc cannot see, or an Int32[] array can come from or escape to code scriptc cannot see. SC7004 diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 4283a089c..5c6d7be63 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -25,7 +25,7 @@ LLVM helper -------------> assembly / object (--emit=asm|obj) Runtime pack and linker -> executable ``` -1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics. +1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics. JavaScript files that TypeScript checks (a `// @ts-check` comment, or `checkJs` in `tsconfig.json`) report values that do not match their JSDoc types as errors, and their classes use those types the way TypeScript classes use annotations. 2. **Typed IR:** A validated, serializable intermediate representation connects the frontend and backend. Types are concrete: generic functions are specialized, unions use tags, and closures have explicit captures. `--emit=ir` writes this representation as JSON. 3. **LLVM backend:** `--emit=llvm` writes textual LLVM IR. The matching LLVM 22 helper generates assembly or objects on supported macOS, Linux, and Windows hosts, including output targeting WASI. Executable and library builds use this backend. Large executables, native and WASI alike, are divided into partitions that are optimized and compiled in parallel; optimized builds summarize every partition first so small functions can still be inlined across partitions. The partition count depends only on the program, so output is the same on every host, and a rebuild reuses the compiled partitions that an edit does not affect. 4. **Linking:** Runtime packs contain the precompiled C runtime and vendored libraries. A hashed manifest selects the artifacts the program needs for its features, optimization mode, and (for libraries) state model. The target linker and SDK or sysroot produce the executable. Sanitizer builds use an instrumented C runtime. @@ -77,7 +77,7 @@ Program objects define `main` and leave selected `scr_*` runtime functions undef ## Native runtime - **Memory:** Values use reference counting. Acyclic values are freed when their last reference is released, and a cycle collector runs at defined collection points. Some cycles involving opaque native values can remain allocated. When the main thread's script ends, the process exits without freeing what the program still holds, and the operating system reclaims it; worker threads free their heaps when they exit. Builds with `--sanitize`, and processes run with `SCRIPTC_EXIT_FREE=1` (for external leak checkers), free everything at exit. -- **Concurrency:** `async`/`await` uses stackful fibers. The native event loop schedules microtasks, timers, and I/O, using kqueue on macOS and epoll on Linux. +- **Concurrency:** An `async` function that suspends only at its own `await` expressions compiles to a stackless coroutine whose heap frame holds the values live across those awaits; other suspending `async` functions and async generators use stackful fibers. The native event loop schedules microtasks, timers, and I/O, using kqueue on macOS and epoll on Linux. - **Networking:** Supported `net`, `http`, `https`, `tls`, `dgram`, and `dns` APIs use native implementations. TLS uses vendored mbedTLS. - **Numbers:** Values use JavaScript's double-precision representation. Number-to-string formatting uses shortest-roundtrip output and is tested against Node.js. - **Regular expressions and Unicode:** The runtime uses QuickJS's regular-expression and Unicode libraries, linked into programs that use them. diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 420af8200..ed2c5b1ec 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -208,6 +208,10 @@ title: "Limitations" ../limitations/dns-lookup-errors.mdx +### Socket data encoding + +../limitations/socket-data-encoding.mdx + ### UDP socket options ../limitations/udp-socket-options.mdx diff --git a/docs/content/docs/platforms.mdx b/docs/content/docs/platforms.mdx index b4f817670..96b91fc2a 100644 --- a/docs/content/docs/platforms.mdx +++ b/docs/content/docs/platforms.mdx @@ -69,9 +69,12 @@ Executables open a console window by default. For an application that shows its ### WebAssembly (WASI) +WASI is a cross-compile target, so it needs [zig](https://ziglang.org) and the `@scriptc/runtime-wasm32-wasi` runtime pack, as described in [Cross-compile](#cross-compilation-via-zig). Install the runtime pack in your project at the same version as `scriptc --version`. Without it, the build fails with `SC3003`. + Set `SCRIPTC_TARGET=wasm32-wasi` to build a standalone `.wasm` module: ```console +$ npm install --save-dev @scriptc/runtime-wasm32-wasi@0.3.5 $ SCRIPTC_TARGET=wasm32-wasi scriptc build hello.ts >/dev/null $ SCRIPTC_TARGET=wasm32-wasi scriptc run hello.ts hello, world 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/docs/content/docs/wasm.mdx b/docs/content/docs/wasm.mdx index dd3862cd2..b498eccf7 100644 --- a/docs/content/docs/wasm.mdx +++ b/docs/content/docs/wasm.mdx @@ -6,6 +6,12 @@ A WebAssembly module lets a web page or another application call functions writt You build a module in library mode, with `SCRIPTC_TARGET=wasm32-wasi` and `scriptc build --lib --profile`. The result is a WASI Preview 1 reactor: a `.wasm` file with callable exports, named imports for host callbacks, and its own memory. Your application loads the module, supplies its imports, calls its exports, and handles events, scheduling, and rendering. +Module builds need [zig](https://ziglang.org) and the `@scriptc/runtime-wasm32-wasi` runtime pack in your project, at the same version as `scriptc --version`. See [Cross-compile](/docs/platforms#cross-compilation-via-zig). + +```console +$ npm install --save-dev @scriptc/runtime-wasm32-wasi@0.3.5 +``` + ## Define the interface The library profile names the entry module and its public functions. A reachable callback call becomes a synchronous import from the scriptc namespace. @@ -90,11 +96,13 @@ After initialization, counter_step(2) returns 2, and a i64, u64JavaScript BigInt. The existing library integer checks and proofs restrict values to the exact JavaScript number range. string, bytes parametersTwo i32 arguments: byte offset and byte length. Strings use UTF-8. Entry parameters are copied into runtime-owned values. string, bytes resultsTwo extra i32 arguments identify writable four-byte output slots. The function writes the result pointer and byte length there, in little-endian order. + cstring parametersOne i32 argument: the byte offset of NUL-terminated UTF-8 text. The text is copied into a runtime-owned string; malformed UTF-8 becomes U+FFFD. A zero pointer traps with SC4012. + cstring resultsAn i32 result: the byte offset of NUL-terminated UTF-8 text in the result arena. A result containing U+0000 traps with SC4015 instead of being truncated. voidNo result. -The native library rules about directions still apply: u8/u32/i32 are export parameters and callback scalars; i64/u64 are export-only. Callback results must be scalars or void. Callback string/byte parameters are borrowed only for the duration of that callback; copy them before retaining them in the host. +The native library rules about directions still apply: u8/u32/i32 are export parameters and callback scalars; i64/u64 are export-only. Callback results must be scalars or void. Callback string/byte parameters are borrowed only for the duration of that callback; copy them before retaining them in the host. A cstring callback parameter is one i32 pointer to NUL-terminated text, borrowed the same way; a value containing U+0000 traps with SC4015. The cstring class requires "profile_format": 2. In native libraries it is a const char * with the same rules. Allocate host input buffers and output slots with scriptc_alloc(size); a zero result indicates allocation failure. Release each allocation exactly once with scriptc_free(pointer). Only free pointers returned by scriptc_alloc. These allocations remain live across application calls, initialization, and collection until freed. diff --git a/docs/content/limitations/builtin-apis/arraybuffers-and-typed-arrays.mdx b/docs/content/limitations/builtin-apis/arraybuffers-and-typed-arrays.mdx index 81dfded25..59a61518d 100644 --- a/docs/content/limitations/builtin-apis/arraybuffers-and-typed-arrays.mdx +++ b/docs/content/limitations/builtin-apis/arraybuffers-and-typed-arrays.mdx @@ -1 +1 @@ -- **ArrayBuffers and typed arrays:** Fixed-length ArrayBuffers support shared typed-array and DataView storage. Resizing, transferring, Float16 arrays, and BigInt arrays are unsupported. Numeric typed arrays and Buffers preserve their kind and shared storage through `unknown`, including when nested in records and arrays. Numeric typed-array constructors can be stored and used with `new`, including constructors read from an array's `constructor` property. JavaScript numeric-array parameters and returns preserve typed-array storage. `Buffer.from`, `Buffer.allocUnsafe`, and `Buffer.concat` return views into a per-thread pool of `Buffer.poolSize` bytes (64 KiB by default) when the size is below `Buffer.poolSize >>> 1`, as in Node; a pool slab stays in memory while any of its Buffers does. Node's own startup code allocates from the first slab, so the `byteOffset` of Buffers in that slab differs from Node's. Buffers returned by other Node APIs, such as `fs.readFileSync` and `zlib`, own their `ArrayBuffer`, where Node allocates small ones from the pool. +- **ArrayBuffers and typed arrays:** Fixed-length ArrayBuffers support shared typed-array and DataView storage. Resizing, transferring, and BigInt arrays are unsupported. `Float16Array` stores IEEE 754 half-precision values, rounding each stored number to nearest-even as Node does; `Math.f16round`, `DataView.prototype.getFloat16` and `setFloat16`, and `util.types.isFloat16Array` compile. Numeric typed arrays and Buffers preserve their kind and shared storage through `unknown`, including when nested in records and arrays. Numeric typed-array constructors can be stored and used with `new`, including constructors read from an array's `constructor` property. JavaScript numeric-array parameters and returns preserve typed-array storage. `Buffer.from`, `Buffer.allocUnsafe`, and `Buffer.concat` return views into a per-thread pool of `Buffer.poolSize` bytes (64 KiB by default) when the size is below `Buffer.poolSize >>> 1`, as in Node; a pool slab stays in memory while any of its Buffers does. Node's own startup code allocates from the first slab, so the `byteOffset` of Buffers in that slab differs from Node's. Buffers returned by other Node APIs, such as `fs.readFileSync` and `zlib`, own their `ArrayBuffer`, where Node allocates small ones from the pool. diff --git a/docs/content/limitations/builtin-apis/date.mdx b/docs/content/limitations/builtin-apis/date.mdx index 5c465b315..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`, 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/docs/content/limitations/builtin-apis/exit-status.mdx b/docs/content/limitations/builtin-apis/exit-status.mdx index e2b681daf..f13782ff2 100644 --- a/docs/content/limitations/builtin-apis/exit-status.mdx +++ b/docs/content/limitations/builtin-apis/exit-status.mdx @@ -1 +1 @@ -- **Exit status:** Numeric `process.exitCode = value` writes in statement position set the implicit exit status, which `process.exit()` uses when called without an argument. Reads, resets, and assignments used as values are unsupported. +- **Exit status:** Numeric `process.exitCode = value` writes in statement position set the implicit exit status, which `process.exit()` uses when called without an argument. `process.exit(code)` and `process.exitCode` throw a `RangeError` (`ERR_OUT_OF_RANGE`) for a code that is not a safe integer, and convert other codes to int32, as Node does. Reads, resets, and assignments used as values are unsupported. diff --git a/docs/content/limitations/builtin-apis/regular-expressions.mdx b/docs/content/limitations/builtin-apis/regular-expressions.mdx index 4578cfc93..a765bbe3c 100644 --- a/docs/content/limitations/builtin-apis/regular-expressions.mdx +++ b/docs/content/limitations/builtin-apis/regular-expressions.mdx @@ -1 +1 @@ -- **Regular expressions:** Native checked storage preserves identity, `source`, `flags`, flag properties, and stateful `test`. Static `exec`, global `match`, and numeric `lastIndex` access use the native matcher. Runtime constructors accept checked strings and native regex copies; `replace` and `replaceAll` accept native regex values with string templates or callbacks, including captures, named groups, and UTF-16 offsets. Non-numeric `lastIndex`, detached methods, and `exec` through an untyped receiver are unsupported. The `d` (match indices) and `v` (unicode sets) flags are unsupported: regex literals and `new RegExp` calls with a literal flag string containing them fail to compile with SC1120, and `new RegExp` with a computed flag string containing them throws an error naming the flag. +- **Regular expressions:** Native checked storage preserves identity, `source`, `flags`, flag properties, and stateful `test`, and the flag getters (`global`, `unicodeSets`, and the rest) compile on statically typed regexes. Static `exec`, global `match`, and numeric `lastIndex` access use the native matcher. Runtime constructors accept checked strings and native regex copies; `replace` and `replaceAll` accept native regex values with string templates or callbacks, including captures, named groups, and UTF-16 offsets. Non-numeric `lastIndex`, detached methods, and `exec` through an untyped receiver are unsupported. The `v` (unicode sets) flag supports class set operations (`--`, `&&`), nested classes, `\q{...}` string literals, and properties of strings such as `\p{RGI_Emoji}`. `new RegExp` with an invalid pattern throws a `SyntaxError` whose message comes from the native engine and can differ from Node's. The `d` (match indices) flag is unsupported: regex literals and `new RegExp` calls with a literal flag string containing it fail to compile with SC1120, and `new RegExp` with a computed flag string containing it throws an error naming the flag. 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/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/contract-types.mdx b/docs/content/limitations/contract-types.mdx index 59bb28287..977150259 100644 --- a/docs/content/limitations/contract-types.mdx +++ b/docs/content/limitations/contract-types.mdx @@ -1 +1 @@ -[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; other aliases, interface members and nested type positions are reported. In programs that use worker threads, a surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances are stored in `any` or `unknown` values, and `Immutable` refuses values stored in `any` or `unknown` or that another reference can still change. `ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed unless they belong to a released [lifetime region](/docs/experimental#lifetimeregion). +[Contract types](/docs/contracts) are checked only where they are written as the annotation of a parameter, variable, class instance field or function result, `Acyclic` only on classes, interfaces and object type aliases, and `ProgramLifetime` only in the `implements` clause of a class declaration; other aliases, interface members and nested type positions are reported. In programs that use worker threads, a surrounding `try`/`catch` does not discharge `NoThrow`. `Int32` refuses parameters of functions used as values and fields of classes whose instances are stored in `any` or `unknown` values. `Int32[]` refuses arrays that can come from or escape to code scriptc cannot see, does not follow callback results (such as `map` results), and checks a copy made by `slice` or a spread as if it were its source array. Element reads that find a value are int32s, but a scalar `Int32` slot still needs `a[i] | 0`, because a read can miss and produce `undefined`. `Immutable` refuses values stored in `any` or `unknown` or that another reference can still change. `ProgramLifetime` applies to root classes of executables only; instances the program drops are never reclaimed unless they belong to a released [lifetime region](/docs/experimental#lifetimeregion). diff --git a/docs/content/limitations/language-features/async-iteration.mdx b/docs/content/limitations/language-features/async-iteration.mdx index ee570f17d..f18377299 100644 --- a/docs/content/limitations/language-features/async-iteration.mdx +++ b/docs/content/limitations/language-features/async-iteration.mdx @@ -1 +1 @@ -- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises with an optional mapping function; async generator sources, a `thisArg`, and arrays that mix values and promises are unsupported. +- **Async iteration:** Async `yield*` delegation, stored Web or Node iterator handles, and general structural async-iterator objects are unsupported. Typed async generators support lazy execution, `await`, direct `yield`, queued `.next()`/`.return()`/`.throw()` requests, and `for await`. `for await` also accepts Node `Readable` streams, Web `ReadableStream` values, and compiled class iterators whose zero-parameter `next()` returns a promise of a `{ value, done? }` record. Abrupt completion runs cleanup, including literal `destroyOnReturn` and `preventCancel` options. The `node:timers/promises` `setInterval(delay, value)` iterator compiles without AbortSignal options. `Array.fromAsync` accepts arrays of values or of promises and typed async generators, with an optional mapping function; a `thisArg` and arrays that mix values and promises are unsupported. diff --git a/docs/content/limitations/language-features/resource-disposal.mdx b/docs/content/limitations/language-features/resource-disposal.mdx index 5163d5322..d3df4138e 100644 --- a/docs/content/limitations/language-features/resource-disposal.mdx +++ b/docs/content/limitations/language-features/resource-disposal.mdx @@ -1 +1 @@ -- **Resource disposal:** Top-level and switch-clause `using` declarations, and `DisposableStack` constructors are unsupported. Block- and function-scoped `using` and `await using` support compiled classes with zero-parameter disposal methods, object literals and other `Disposable`- or `AsyncDisposable`-typed values, and supported FileHandle, timer, immediate, child-process, and readline handles. A class instance reached through a `Disposable`- or `unknown`-typed value throws an SC2020 error when its declaration runs; declare the variable with the class type. Array `for (using ... of ...)` loops also compile. `SuppressedError` values expose `error` and `suppressed`; `extends SuppressedError` and casting `unknown` to `SuppressedError` are unsupported (narrow with `instanceof`). +- **Resource disposal:** Top-level and switch-clause `using` declarations, and `AsyncDisposableStack`, are unsupported. Block- and function-scoped `using` and `await using` support compiled classes with zero-parameter disposal methods, object literals and other `Disposable`- or `AsyncDisposable`-typed values, and supported FileHandle, timer, immediate, child-process, and readline handles. A class instance reached through a `Disposable`- or `unknown`-typed value throws an SC2020 error when its declaration runs; declare the variable with the class type. Array `for (using ... of ...)` loops also compile. `SuppressedError` values expose `error` and `suppressed`; `extends SuppressedError` and casting `unknown` to `SuppressedError` are unsupported (narrow with `instanceof`). `DisposableStack` supports `use`, `adopt`, `defer`, `move`, `dispose`, `disposed` and `[Symbol.dispose]`, and works with `using`; `use` accepts class instances, object literals and other `Disposable`-typed values, and refuses unions that include class instances (narrow the value first). Disposal runs in reverse order and raises Node's `SuppressedError` chain when more than one callback throws. diff --git a/docs/content/limitations/native-worker-threads.mdx b/docs/content/limitations/native-worker-threads.mdx index 0527c24b0..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`, and a `transferList` of MessagePorts; runtime paths, `eval`, loaders, custom stdio, shared environments, and resource limits are unsupported. Worker argument strings containing null bytes are also unsupported. +`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`. `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. A port's `onmessage` property, `addEventListener`, `receiveMessageOnPort(parentPort)`, and `BroadcastChannel` are unsupported. 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. 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/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/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/content/limitations/socket-data-encoding.mdx b/docs/content/limitations/socket-data-encoding.mdx new file mode 100644 index 000000000..8094f8143 --- /dev/null +++ b/docs/content/limitations/socket-data-encoding.mdx @@ -0,0 +1 @@ +After `socket.setEncoding("utf8")`, an inline `'data'` listener receives strings, as in Node, whatever type its chunk parameter declares. A listener passed by reference, such as a named function whose parameter is typed `Buffer`, keeps its `Buffer` parameter, so a string chunk throws `TypeError: expected Uint8Array at $[0], got string`. Declare that parameter as `string` or `string | Buffer`, or pass an inline function. diff --git a/docs/content/limitations/type-checking.mdx b/docs/content/limitations/type-checking.mdx index e12c3afb4..5af0361c6 100644 --- a/docs/content/limitations/type-checking.mdx +++ b/docs/content/limitations/type-checking.mdx @@ -1,3 +1,3 @@ -scriptc checks programs against the `es2025` standard library and its own ambient declarations. Some declarations differ from TypeScript's standard library or `@types/node`: `JSON.parse` returns `unknown`, and a Promise executor's reject reason is typed as `Error`. +scriptc checks programs against the `es2025` standard library, the ESNext declarations for `Array.fromAsync`, explicit resource management and `Error.isError`, and its own ambient declarations. Some declarations differ from TypeScript's standard library or `@types/node`: `JSON.parse` returns `unknown`, and a Promise executor's reject reason is typed as `Error`. A program that passes its project's TypeScript check may still use unsupported operations. A second preflight using the project's declarations distinguishes those sites from ordinary type errors and reports scriptc diagnostics with rewrite hints. Declared APIs without a compiler implementation, such as unsupported console members, produce `SC2020`. diff --git a/docs/content/limitations/untyped-values.mdx b/docs/content/limitations/untyped-values.mdx index f0ab9a526..c0811a51e 100644 --- a/docs/content/limitations/untyped-values.mdx +++ b/docs/content/limitations/untyped-values.mdx @@ -1,6 +1,6 @@ Supported uses of `any` use native checked values. Operations without native lowering produce diagnostics such as `SC2011`. Use `unknown` with a checked cast where an operation needs a concrete type. Map and Set type arguments may use `any`, with the same checked storage as `unknown`. -Locals, parameters, and returns can carry untyped values; record and tuple fields can hold `unknown`. JavaScript instance fields can also hold native checked values. Bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any`/`unknown` class fields, static checked-value fields, statically typed array elements, and union members are unsupported. +Locals, parameters, and returns can carry untyped values; record and tuple fields can hold `unknown`. JavaScript instance fields and TypeScript class fields declared `unknown` or `object` (also with `null` or `undefined`, or as arrays of those) can also hold native checked values. Bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any` class fields, other checked-value static fields, statically typed array elements, and union members are unsupported. Program class instances retain identity through `unknown` and casts back to their exact class or a base class, including classes with internal collections. Inspecting dynamic properties throws a catchable `TypeError` if the class's fields cannot pass through checked storage. Supported native handles also retain identity. JavaScript argument and return conversions preserve native array references; other JSON-shaped record and array conversions can copy values. diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index d6311fdf7..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": "76b4947b60f682a7ab02", + "artifactVersion": "cade1b8e6e0c5c42fcc9", "rowCount": 3880 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 740dffeca..dadc9e836 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -299,9 +299,9 @@ "apiEntries": 78, "static": { "supported": 18, - "partial": 34, + "partial": 35, "refused": 0, - "not-implemented": 26, + "not-implemented": 25, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -493,10 +493,10 @@ "entries": 119, "apiEntries": 119, "static": { - "supported": 0, - "partial": 37, + "supported": 16, + "partial": 39, "refused": 0, - "not-implemented": 82, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -509,9 +509,9 @@ "apiEntries": 24, "static": { "supported": 0, - "partial": 23, + "partial": 24, "refused": 0, - "not-implemented": 1, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -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 @@ -689,9 +689,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 45, + "partial": 53, "refused": 0, - "not-implemented": 72, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -704,9 +704,9 @@ "apiEntries": 25, "static": { "supported": 0, - "partial": 20, + "partial": 22, "refused": 0, - "not-implemented": 5, + "not-implemented": 3, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -779,9 +779,9 @@ "apiEntries": 62, "static": { "supported": 0, - "partial": 39, + "partial": 42, "refused": 0, - "not-implemented": 23, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -794,9 +794,9 @@ "apiEntries": 109, "static": { "supported": 0, - "partial": 53, + "partial": 72, "refused": 0, - "not-implemented": 56, + "not-implemented": 37, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -884,9 +884,9 @@ "apiEntries": 66, "static": { "supported": 0, - "partial": 26, + "partial": 46, "refused": 0, - "not-implemented": 40, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -28609,9 +28609,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 by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -28678,9 +28678,9 @@ "inherited": true }, "static": { - "status": "partial", - "detail": "Implemented by a dedicated static compiler/runtime path.", - "verification": "test-backed" + "status": "not-implemented", + "detail": "No static compiler lowering is registered for this API yet.", + "verification": "registry-gap" } }, { @@ -28747,9 +28747,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 by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50037,9 +50037,9 @@ "anchor": "class-netblocklist", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50055,9 +50055,9 @@ "anchor": "blocklistaddaddressaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50073,9 +50073,9 @@ "anchor": "blocklistaddaddressesaddresses-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50091,9 +50091,9 @@ "anchor": "blocklistaddcidrcidr", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50109,9 +50109,9 @@ "anchor": "blocklistaddcidrscidrs", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50127,9 +50127,9 @@ "anchor": "blocklistaddrangestart-end-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50145,9 +50145,9 @@ "anchor": "blocklistaddsubnetnet-prefix-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50163,9 +50163,9 @@ "anchor": "blocklistcheckaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50181,9 +50181,9 @@ "anchor": "blocklistclear", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50204,9 +50204,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50222,9 +50222,9 @@ "anchor": "blocklistisblocklistvalue", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50240,9 +50240,9 @@ "anchor": "blocklistremoveaddressaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50258,9 +50258,9 @@ "anchor": "blocklistremovecidrcidr", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50276,9 +50276,9 @@ "anchor": "blocklistremoverangestart-end-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50294,9 +50294,9 @@ "anchor": "blocklistremovesubnetnet-prefix-type", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50317,9 +50317,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50389,9 +50389,9 @@ "anchor": "class-netsocketaddress", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -50407,9 +50407,9 @@ "anchor": "socketaddressparseinput", "nodeStability": null, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "supported", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -52503,9 +52503,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 call shapes accepted by the compiler lowering.", + "verification": "test-backed" } }, { @@ -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" } }, { @@ -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" } }, { @@ -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" } }, { @@ -67597,9 +67597,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -67620,9 +67620,9 @@ "inherited": false }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented by a dedicated static compiler/runtime path.", + "verification": "test-backed" } }, { @@ -71599,9 +71599,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" } }, { @@ -71714,9 +71714,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" } }, { @@ -71806,9 +71806,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" } }, { @@ -73324,9 +73324,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" } }, { @@ -73347,9 +73347,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" } }, { @@ -73508,9 +73508,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" } }, { @@ -74060,9 +74060,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74083,9 +74083,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74106,9 +74106,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74129,9 +74129,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74152,9 +74152,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74175,9 +74175,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74221,9 +74221,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74244,9 +74244,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74267,9 +74267,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74451,9 +74451,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74497,9 +74497,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74566,9 +74566,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74681,9 +74681,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74704,9 +74704,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74727,9 +74727,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -74750,9 +74750,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Checks existing native runtime brands without inspecting public properties, with stored function values and tested predicate narrowing; unrepresented object forms retain compiler boundaries.", + "verification": "test-backed" } }, { @@ -82920,9 +82920,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" } }, { @@ -82943,9 +82943,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" } }, { @@ -82966,9 +82966,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" } }, { @@ -82989,9 +82989,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" } }, { @@ -83012,9 +83012,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" } }, { @@ -83035,9 +83035,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" } }, { @@ -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" } }, { @@ -83633,9 +83633,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" } }, { @@ -83656,9 +83656,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" } }, { @@ -83679,9 +83679,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" } }, { @@ -83702,9 +83702,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" } }, { @@ -83748,9 +83748,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" } }, { @@ -83886,9 +83886,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" } }, { @@ -83909,9 +83909,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" } }, { @@ -83932,9 +83932,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" } }, { @@ -84047,9 +84047,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" } }, { @@ -84139,9 +84139,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" } }, { @@ -84208,9 +84208,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 72c10cab0..12b12d63b 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -3,12 +3,12 @@ "nodeVersion": "24.22.0", "nodeCommit": "f14cd3aa7bf9ca8df2cc5b8bb14beb0f335b1d93", "summary": { - "taskCount": 3472, - "tierItemCount": 3472, + "taskCount": 3456, + "tierItemCount": 3456, "tiers": { "static": { - "audit-partial": 1086, - "verify-gap": 2259, + "audit-partial": 1143, + "verify-gap": 2186, "replace-refusal": 40, "classify": 2, "implement": 85 @@ -16,7 +16,7 @@ }, "priorities": { "high": 2289, - "normal": 710, + "normal": 694, "low": 473 } }, @@ -5885,7 +5885,8 @@ "source": "compiler-dedicated:console.assert", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] } } @@ -6029,7 +6030,8 @@ "source": "compiler-dedicated:console.dir", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] } } @@ -6057,7 +6059,8 @@ "source": "compiler-dedicated:console.dirxml", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] } } @@ -25006,8 +25009,7 @@ "confidence": "high", "source": "compiler-dedicated:events.getEventListeners", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] } } @@ -25028,12 +25030,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:events", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:events.getMaxListeners", + "tests": [ + "tests/corpus/5885-events-max-listener-helpers.ts" + ] } } }, @@ -25087,8 +25091,7 @@ "confidence": "high", "source": "compiler-dedicated:events.listenerCount", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] } } @@ -25109,15 +25112,12 @@ "priority": "high", "tiers": { "static": { - "status": "partial", - "action": "audit-partial", - "verification": "test-backed", - "confidence": "high", - "source": "compiler-dedicated:events.on", - "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" - ] + "status": "not-implemented", + "action": "verify-gap", + "verification": "registry-gap", + "confidence": "low", + "source": "compiler-unmatched:events", + "tests": [] } } }, @@ -25143,8 +25143,7 @@ "confidence": "high", "source": "compiler-dedicated:events.setMaxListeners", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] } } @@ -25192,12 +25191,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:events", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:events.defaultMaxListeners", + "tests": [ + "tests/corpus/5885-events-max-listener-helpers.ts" + ] } } }, @@ -34347,7 +34348,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -34377,7 +34379,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -34407,7 +34410,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -34487,7 +34491,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -34517,7 +34522,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -34547,7 +34553,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -49222,322 +49229,15 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:aa4d5d08e9a5", - "chapter": "net", - "label": "blockList.addAddress(address[, type])", - "signature": "`blockList.addAddress(address[, type])`", - "apiSymbol": "blockList.addAddress", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddaddressaddress-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:bed278373fec", - "chapter": "net", - "label": "blockList.addAddresses(addresses[, type])", - "signature": "`blockList.addAddresses(addresses[, type])`", - "apiSymbol": "blockList.addAddresses", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddaddressesaddresses-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:3eab1fde8dda", - "chapter": "net", - "label": "blockList.addCIDR(cidr)", - "signature": "`blockList.addCIDR(cidr)`", - "apiSymbol": "blockList.addCIDR", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddcidrcidr", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:85bb2c28b8b4", - "chapter": "net", - "label": "blockList.addCIDRs(cidrs)", - "signature": "`blockList.addCIDRs(cidrs)`", - "apiSymbol": "blockList.addCIDRs", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddcidrscidrs", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:035828cd10e1", - "chapter": "net", - "label": "blockList.addRange(start, end[, type])", - "signature": "`blockList.addRange(start, end[, type])`", - "apiSymbol": "blockList.addRange", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddrangestart-end-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:a2c2a1e796ad", - "chapter": "net", - "label": "blockList.addSubnet(net, prefix[, type])", - "signature": "`blockList.addSubnet(net, prefix[, type])`", - "apiSymbol": "blockList.addSubnet", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistaddsubnetnet-prefix-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:278a3ce510c8", - "chapter": "net", - "label": "blockList.check(address[, type])", - "signature": "`blockList.check(address[, type])`", - "apiSymbol": "blockList.check", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistcheckaddress-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:e0681ee0179f", - "chapter": "net", - "label": "blockList.clear()", - "signature": "`blockList.clear()`", - "apiSymbol": "blockList.clear", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistclear", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:07e5de315d95", - "chapter": "net", - "label": "blockList.fromJSON(value)", - "signature": "`blockList.fromJSON(value)`", - "apiSymbol": "blockList.fromJSON", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistfromjsonvalue", - "nodeStability": { - "index": "1.2", - "level": 1, - "text": "Release candidate", - "inherited": false - }, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:4ff6a4c12f52", - "chapter": "net", - "label": "BlockList.isBlockList(value)", - "signature": "`BlockList.isBlockList(value)`", - "apiSymbol": "BlockList.isBlockList", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistisblocklistvalue", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:6ccaedabc10a", - "chapter": "net", - "label": "blockList.removeAddress(address[, type])", - "signature": "`blockList.removeAddress(address[, type])`", - "apiSymbol": "blockList.removeAddress", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistremoveaddressaddress-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:03c6467a8162", - "chapter": "net", - "label": "blockList.removeCIDR(cidr)", - "signature": "`blockList.removeCIDR(cidr)`", - "apiSymbol": "blockList.removeCIDR", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistremovecidrcidr", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:8ba4f8ec27e3", - "chapter": "net", - "label": "blockList.removeRange(start, end[, type])", - "signature": "`blockList.removeRange(start, end[, type])`", - "apiSymbol": "blockList.removeRange", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistremoverangestart-end-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:6cc98f6368e6", - "chapter": "net", - "label": "blockList.removeSubnet(net, prefix[, type])", - "signature": "`blockList.removeSubnet(net, prefix[, type])`", - "apiSymbol": "blockList.removeSubnet", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklistremovesubnetnet-prefix-type", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:fb2019fd5533", - "chapter": "net", - "label": "blockList.toJSON()", - "signature": "`blockList.toJSON()`", - "apiSymbol": "blockList.toJSON", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#blocklisttojson", - "nodeStability": { - "index": "1.2", - "level": 1, - "text": "Release candidate", - "inherited": false - }, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:net.BlockList", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] } } }, @@ -49612,32 +49312,15 @@ "priority": "normal", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] - } - } - }, - { - "id": "net:d8b7ddef1a9e", - "chapter": "net", - "label": "SocketAddress.parse(input)", - "signature": "`SocketAddress.parse(input)`", - "apiSymbol": "SocketAddress.parse", - "docsUrl": "https://nodejs.org/docs/v24.22.0/api/net.html#socketaddressparseinput", - "nodeStability": null, - "priority": "normal", - "tiers": { - "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:net", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:net.SocketAddress", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] } } }, @@ -52059,12 +51742,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:os", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.os.setPriority", + "tests": [ + "tests/corpus/5883-os-set-priority.ts" + ] } } }, @@ -57822,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" + ] } } }, @@ -57871,6 +57559,7 @@ "confidence": "high", "source": "surface-manifest:node-builtin.process.exitCode", "tests": [ + "tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", @@ -65937,7 +65626,8 @@ "source": "compiler-dedicated:StringDecoder", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] } } @@ -65965,7 +65655,8 @@ "source": "compiler-dedicated:stringDecoder.end", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] } } @@ -65993,7 +65684,8 @@ "source": "compiler-dedicated:stringDecoder.write", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] } } @@ -66990,12 +66682,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" + ] } } }, @@ -67015,12 +66713,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" + ] } } }, @@ -67040,12 +66744,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" + ] } } }, @@ -67065,12 +66772,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" + ] } } }, @@ -67090,12 +66803,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" + ] } } }, @@ -67115,12 +66834,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" + ] } } }, @@ -67945,12 +67670,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" + ] } } }, @@ -67970,12 +67698,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" + ] } } }, @@ -69077,7 +68808,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69107,7 +68839,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69137,7 +68870,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69167,7 +68901,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69197,7 +68932,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69227,7 +68963,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69257,7 +68994,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69287,7 +69025,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69317,7 +69056,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69338,12 +69078,15 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:timers", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:timersPromises.scheduler.wait", + "tests": [ + "tests/corpus/5839-timers-promises-scheduler.ts", + "tests/corpus/5889-timers-scheduler-signal.ts" + ] } } }, @@ -69363,12 +69106,15 @@ "priority": "low", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:timers", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-dedicated:timersPromises.scheduler.yield", + "tests": [ + "tests/corpus/5839-timers-promises-scheduler.ts", + "tests/corpus/5889-timers-scheduler-signal.ts" + ] } } }, @@ -69422,7 +69168,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69452,7 +69199,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69482,7 +69230,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69589,7 +69338,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69619,7 +69369,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69649,7 +69400,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -69679,7 +69431,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] } } @@ -73753,12 +73506,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:url", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:url.urlSearchParams.entries", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] } } }, @@ -73878,12 +73634,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:url", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:url.urlSearchParams.keys", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] } } }, @@ -73978,12 +73737,15 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:url", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:url.urlSearchParams.values", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] } } }, @@ -75797,12 +75559,16 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.format", + "tests": [ + "tests/corpus/1636-util-format.ts", + "tests/corpus/2440-console-inspect-args.ts", + "tests/corpus/5835-util-format-integer-specifiers.ts" + ] } } }, @@ -75822,12 +75588,16 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.formatWithOptions", + "tests": [ + "tests/corpus/1636-util-format.ts", + "tests/corpus/2440-console-inspect-args.ts", + "tests/corpus/5835-util-format-integer-specifiers.ts" + ] } } }, @@ -76016,12 +75786,18 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.inspect", + "tests": [ + "tests/corpus/1630-inspect-scalars.ts", + "tests/corpus/1632-inspect-records.ts", + "tests/corpus/1637-inspect-dyn.ts", + "tests/corpus/2485-inspect-circular-refs.ts", + "tests/corpus/5884-inspect-layout-options.ts" + ] } } }, @@ -76700,12 +76476,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isBigInt64Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76725,12 +76508,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isBigIntObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76750,12 +76540,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isBigUint64Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76775,12 +76572,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isBooleanObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76800,12 +76604,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isBoxedPrimitive", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76825,12 +76636,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isCryptoKey", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76880,12 +76698,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isDate", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76905,12 +76730,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isExternal", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -76930,12 +76762,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isFloat16Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77165,12 +77004,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isKeyObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77220,12 +77066,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isMapIterator", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77300,12 +77153,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isNumberObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77445,12 +77305,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isSetIterator", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77470,12 +77337,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isSharedArrayBuffer", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77495,12 +77369,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isStringObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -77520,12 +77401,19 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:util", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "compiler-feature:util.util.types.isSymbolObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] } } }, @@ -83954,12 +83842,16 @@ "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.BroadcastChannel extends EventTarget", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -83979,12 +83871,16 @@ "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.broadcastChannel.close", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -84004,12 +83900,16 @@ "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.broadcastChannel.postMessage", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -84029,12 +83929,16 @@ "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.broadcastChannel.ref", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -84054,12 +83958,16 @@ "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.broadcastChannel.unref", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -84079,12 +83987,16 @@ "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.onmessage", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] } } }, @@ -84137,7 +84049,8 @@ "tests": [ "tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", - "tests/corpus/5797-worker-message-port-transfer.ts" + "tests/corpus/5797-worker-message-port-transfer.ts", + "tests/corpus/5805-worker-receive-message-parent-port.ts" ] } } @@ -84702,12 +84615,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" + ] } } }, @@ -84777,12 +84692,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.resourceLimits", + "tests": [ + "tests/corpus/5806-worker-resource-limits.ts" + ] } } }, @@ -84802,12 +84719,16 @@ "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.stderr", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] } } }, @@ -84827,12 +84748,16 @@ "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.stdin", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] } } }, @@ -84852,12 +84777,16 @@ "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.stdout", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] } } }, @@ -84906,12 +84835,15 @@ "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.threadName", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] } } }, @@ -84939,7 +84871,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] } } @@ -84968,7 +84902,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] } } @@ -84997,7 +84933,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] } } @@ -85051,7 +84989,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] } } @@ -85072,12 +85012,15 @@ "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.getEnvironmentData", + "tests": [ + "tests/corpus/5803-worker-environment-data.ts", + "tests/corpus/5804-worker-environment-data.cjs" + ] } } }, @@ -85097,12 +85040,15 @@ "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.markAsUntransferable", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] } } }, @@ -85122,12 +85068,15 @@ "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.isMarkedAsUntransferable", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] } } }, @@ -85147,12 +85096,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" + ] } } }, @@ -85230,7 +85181,8 @@ "tests": [ "tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", - "tests/corpus/5797-worker-message-port-transfer.ts" + "tests/corpus/5797-worker-message-port-transfer.ts", + "tests/corpus/5805-worker-receive-message-parent-port.ts" ] } } @@ -85251,12 +85203,15 @@ "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.setEnvironmentData", + "tests": [ + "tests/corpus/5803-worker-environment-data.ts", + "tests/corpus/5804-worker-environment-data.cjs" + ] } } }, @@ -85276,12 +85231,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" + ] } } }, @@ -85359,12 +85316,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.resourceLimits", + "tests": [ + "tests/corpus/5806-worker-resource-limits.ts" + ] } } }, @@ -85438,12 +85397,15 @@ "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.threadName", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] } } }, diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index e50ae977d..2b97f675e 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -311,9 +311,9 @@ "apiEntries": 78, "static": { "supported": 18, - "partial": 34, + "partial": 35, "refused": 0, - "not-implemented": 26, + "not-implemented": 25, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -520,10 +520,10 @@ "entries": 122, "apiEntries": 119, "static": { - "supported": 0, - "partial": 37, + "supported": 16, + "partial": 39, "refused": 0, - "not-implemented": 82, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -536,9 +536,9 @@ "apiEntries": 24, "static": { "supported": 0, - "partial": 23, + "partial": 24, "refused": 0, - "not-implemented": 1, + "not-implemented": 0, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -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 @@ -746,9 +746,9 @@ "apiEntries": 117, "static": { "supported": 0, - "partial": 45, + "partial": 53, "refused": 0, - "not-implemented": 72, + "not-implemented": 64, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -761,9 +761,9 @@ "apiEntries": 25, "static": { "supported": 0, - "partial": 20, + "partial": 22, "refused": 0, - "not-implemented": 5, + "not-implemented": 3, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -836,9 +836,9 @@ "apiEntries": 62, "static": { "supported": 0, - "partial": 39, + "partial": 42, "refused": 0, - "not-implemented": 23, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -851,9 +851,9 @@ "apiEntries": 109, "static": { "supported": 0, - "partial": 53, + "partial": 72, "refused": 0, - "not-implemented": 56, + "not-implemented": 37, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -941,9 +941,9 @@ "apiEntries": 66, "static": { "supported": 0, - "partial": 26, + "partial": 46, "refused": 0, - "not-implemented": 40, + "not-implemented": 20, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -19141,6 +19141,7 @@ "tests/corpus/5097-console-table.ts", "tests/corpus/5098-console-time-warnings.ts", "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts", "tests/corpus/console-native-value.js", "tests/harness/npm-static.test.ts" ] @@ -19297,7 +19298,8 @@ "evidence": "compiler-dedicated:console.assert", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] }, "anchorSource": "exact" @@ -19436,7 +19438,8 @@ "evidence": "compiler-dedicated:console.dir", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] }, "anchorSource": "exact" @@ -19463,7 +19466,8 @@ "evidence": "compiler-dedicated:console.dirxml", "tests": [ "tests/corpus/5096-console-assert-dir.ts", - "tests/corpus/5099-console-methods-dynamic.cjs" + "tests/corpus/5099-console-methods-dynamic.cjs", + "tests/corpus/5884-inspect-layout-options.ts" ] }, "anchorSource": "exact" @@ -41814,7 +41818,8 @@ "tests/corpus/5244-event-target-subclass.js", "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/5248-event-target-subclass-warnings.ts", + "tests/corpus/5885-events-max-listener-helpers.ts" ] }, "anchorSource": "chapter" @@ -43734,8 +43739,7 @@ "status": "partial", "evidence": "compiler-dedicated:events.getEventListeners", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] }, "anchorSource": "exact" @@ -43758,8 +43762,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:events" + "status": "partial", + "evidence": "compiler-dedicated:events.getMaxListeners", + "tests": [ + "tests/corpus/5885-events-max-listener-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -43834,8 +43841,7 @@ "status": "partial", "evidence": "compiler-dedicated:events.listenerCount", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] }, "anchorSource": "exact" @@ -43858,12 +43864,8 @@ "inherited": true }, "static": { - "status": "partial", - "evidence": "compiler-dedicated:events.on", - "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" - ] + "status": "not-implemented", + "evidence": "compiler-unmatched:events" }, "anchorSource": "exact" }, @@ -43888,8 +43890,7 @@ "status": "partial", "evidence": "compiler-dedicated:events.setMaxListeners", "tests": [ - "tests/corpus/1677-emitter-listeners.ts", - "tests/corpus/2620-ee-override-once-order.ts" + "tests/corpus/5885-events-max-listener-helpers.ts" ] }, "anchorSource": "exact" @@ -43938,8 +43939,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:events" + "status": "partial", + "evidence": "compiler-dedicated:events.defaultMaxListeners", + "tests": [ + "tests/corpus/5885-events-max-listener-helpers.ts" + ] }, "anchorSource": "exact" }, @@ -52160,6 +52164,7 @@ "tests/corpus/5287-blob-inspect.ts", "tests/corpus/5288-blob-validation.js", "tests/corpus/5290-blob-dynamic.js", + "tests/corpus/5830-timers-promises-signal.ts", "tests/corpus/text-codec-values/main.ts", "tests/harness/fetch-conformance.test.ts" ] @@ -53663,7 +53668,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -53692,7 +53698,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -53721,7 +53728,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -53796,7 +53804,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -53825,7 +53834,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -53854,7 +53864,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -71655,7 +71666,9 @@ "tests/corpus/2596-net-arg-ladders.cjs", "tests/corpus/2597-dgram-send-ladders.cjs", "tests/corpus/2672-http-request-response-callback.ts", - "tests/corpus/2689-http-server-timeout-properties.ts" + "tests/corpus/2689-http-server-timeout-properties.ts", + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" ] }, "anchorSource": "chapter" @@ -71709,8 +71722,12 @@ "anchor": "class-netblocklist", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "partial", + "evidence": "compiler-dedicated:net.BlockList", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71727,8 +71744,12 @@ "anchor": "blocklistaddaddressaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addAddress", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71745,8 +71766,12 @@ "anchor": "blocklistaddaddressesaddresses-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addAddresses", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71763,8 +71788,12 @@ "anchor": "blocklistaddcidrcidr", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addCIDR", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71781,8 +71810,12 @@ "anchor": "blocklistaddcidrscidrs", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addCIDRs", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71799,8 +71832,12 @@ "anchor": "blocklistaddrangestart-end-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addRange", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71817,8 +71854,12 @@ "anchor": "blocklistaddsubnetnet-prefix-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.addSubnet", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71835,8 +71876,12 @@ "anchor": "blocklistcheckaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.check", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71853,8 +71898,12 @@ "anchor": "blocklistclear", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.clear", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71876,8 +71925,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.fromJSON", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71894,8 +71947,12 @@ "anchor": "blocklistisblocklistvalue", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:BlockList.isBlockList", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71912,8 +71969,12 @@ "anchor": "blocklistremoveaddressaddress-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.removeAddress", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71930,8 +71991,12 @@ "anchor": "blocklistremovecidrcidr", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.removeCIDR", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71948,8 +72013,12 @@ "anchor": "blocklistremoverangestart-end-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.removeRange", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71966,8 +72035,12 @@ "anchor": "blocklistremovesubnetnet-prefix-type", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.removeSubnet", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -71989,8 +72062,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:blockList.toJSON", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -72061,8 +72138,12 @@ "anchor": "class-netsocketaddress", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "partial", + "evidence": "compiler-dedicated:net.SocketAddress", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -72079,8 +72160,12 @@ "anchor": "socketaddressparseinput", "nodeStability": null, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:net" + "status": "supported", + "evidence": "compiler-dedicated:SocketAddress.parse", + "tests": [ + "tests/corpus/5659-net-blocklist-socketaddress.ts", + "tests/fixtures/node-types/net-blocklist.ts" + ] }, "anchorSource": "exact" }, @@ -74016,7 +74101,8 @@ "tests/corpus/2917-builtin-callable-widths.ts", "tests/corpus/4598-os-available-parallelism.ts", "tests/corpus/5140-os-host-facts.ts", - "tests/corpus/5141-os-constants.ts" + "tests/corpus/5141-os-constants.ts", + "tests/corpus/5883-os-set-priority.ts" ] }, "anchorSource": "chapter" @@ -74565,8 +74651,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:os" + "status": "partial", + "evidence": "surface-manifest:node-builtin.os.setPriority", + "tests": [ + "tests/corpus/5883-os-set-priority.ts" + ] }, "anchorSource": "exact" }, @@ -79208,6 +79297,7 @@ "tests/corpus/5194-process-emit-warning-tick.cjs", "tests/corpus/5210-process-memory-usage.ts", "tests/corpus/5211-process-identity.ts", + "tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/990-process-basics.ts", "tests/corpus/node24-env-files-errors.ts", "tests/corpus/node24-env-files.ts", @@ -80859,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" }, @@ -80902,6 +80996,7 @@ "status": "partial", "evidence": "surface-manifest:node-builtin.process.exitCode", "tests": [ + "tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", @@ -90732,6 +90827,7 @@ "evidence": "derived:descendants", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts", "tests/corpus/node24-string-decoder.ts" ] }, @@ -90759,7 +90855,8 @@ "evidence": "compiler-dedicated:StringDecoder", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] }, "anchorSource": "exact" @@ -90786,7 +90883,8 @@ "evidence": "compiler-dedicated:stringDecoder.end", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] }, "anchorSource": "exact" @@ -90813,7 +90911,8 @@ "evidence": "compiler-dedicated:stringDecoder.write", "tests": [ "tests/corpus/1662-string-decoder-encodings.ts", - "tests/corpus/node24-string-decoder.ts" + "tests/corpus/node24-string-decoder.ts", + "tests/corpus/5882-string-decoder-runtime-encoding.ts" ] }, "anchorSource": "exact" @@ -90847,11 +90946,16 @@ "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/snapshot.ts", "tests/fixtures/node-test/cases/suite-variants.ts", "tests/fixtures/node-test/cases/todo-suites.ts", "tests/harness/node-test.test.ts" @@ -92481,8 +92585,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" }, @@ -92504,8 +92615,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" }, @@ -92527,8 +92645,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" }, @@ -92550,8 +92672,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" }, @@ -92619,8 +92748,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" }, @@ -92642,8 +92778,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" }, @@ -93464,8 +93607,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" }, @@ -93487,8 +93634,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" }, @@ -94535,7 +94686,10 @@ "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts", "tests/corpus/2844-node-disposable-resources.ts", - "tests/corpus/2852-timers-promises-interval.ts" + "tests/corpus/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts", + "tests/corpus/5839-timers-promises-scheduler.ts", + "tests/corpus/5889-timers-scheduler-signal.ts" ] }, "anchorSource": "chapter" @@ -94587,7 +94741,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94616,7 +94771,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94645,7 +94801,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94697,7 +94854,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94726,7 +94884,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94755,7 +94914,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94807,7 +94967,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94836,7 +94997,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94865,7 +95027,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94888,8 +95051,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:timers" + "status": "partial", + "evidence": "compiler-dedicated:timersPromises.scheduler.wait", + "tests": [ + "tests/corpus/5839-timers-promises-scheduler.ts", + "tests/corpus/5889-timers-scheduler-signal.ts" + ] }, "anchorSource": "exact" }, @@ -94911,8 +95078,12 @@ "inherited": false }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:timers" + "status": "partial", + "evidence": "compiler-dedicated:timersPromises.scheduler.yield", + "tests": [ + "tests/corpus/5839-timers-promises-scheduler.ts", + "tests/corpus/5889-timers-scheduler-signal.ts" + ] }, "anchorSource": "exact" }, @@ -94963,7 +95134,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -94992,7 +95164,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -95021,7 +95194,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -95122,7 +95296,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -95151,7 +95326,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -95180,7 +95356,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -95209,7 +95386,8 @@ "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/2852-timers-promises-interval.ts", + "tests/corpus/5830-timers-promises-signal.ts" ] }, "anchorSource": "exact" @@ -98896,6 +99074,8 @@ "tests/corpus/5227-url-legacy-dependency/main.ts", "tests/corpus/5228-url-domain-conversion.ts", "tests/corpus/5229-url-legacy-errors.cjs", + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs", "tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-errors.js", "tests/corpus/node24-file-url-options.ts", @@ -99680,8 +99860,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:url" + "status": "partial", + "evidence": "compiler-feature:url.urlSearchParams.entries", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] }, "anchorSource": "exact" }, @@ -99795,8 +99979,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:url" + "status": "partial", + "evidence": "compiler-feature:url.urlSearchParams.keys", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] }, "anchorSource": "exact" }, @@ -99887,8 +100075,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:url" + "status": "partial", + "evidence": "compiler-feature:url.urlSearchParams.values", + "tests": [ + "tests/corpus/5833-url-search-params-iterators.ts", + "tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ] }, "anchorSource": "exact" }, @@ -100766,6 +100958,12 @@ "packages/compiler/test/deep-equality.test.ts", "tests/corpus/1473-promisify-execfile.ts", "tests/corpus/1573-promisify-execfile-env-spread.ts", + "tests/corpus/1630-inspect-scalars.ts", + "tests/corpus/1632-inspect-records.ts", + "tests/corpus/1636-util-format.ts", + "tests/corpus/1637-inspect-dyn.ts", + "tests/corpus/2440-console-inspect-args.ts", + "tests/corpus/2485-inspect-circular-refs.ts", "tests/corpus/2678-util-parseargs.ts", "tests/corpus/2912-promisify-fs-readfile.ts", "tests/corpus/3133-text-decoder-streaming.ts", @@ -100788,6 +100986,10 @@ "tests/corpus/5628-promisify-timers.ts", "tests/corpus/5629-promisify-stream.ts", "tests/corpus/5630-promisify-zlib.ts", + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "tests/corpus/5835-util-format-integer-specifiers.ts", + "tests/corpus/5884-inspect-layout-options.ts", "tests/corpus/node24-deep-equality-properties.js", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality.ts", @@ -101884,8 +102086,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.format", + "tests": [ + "tests/corpus/1636-util-format.ts", + "tests/corpus/2440-console-inspect-args.ts", + "tests/corpus/5835-util-format-integer-specifiers.ts" + ] }, "anchorSource": "exact" }, @@ -101907,8 +102114,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.formatWithOptions", + "tests": [ + "tests/corpus/1636-util-format.ts", + "tests/corpus/2440-console-inspect-args.ts", + "tests/corpus/5835-util-format-integer-specifiers.ts" + ] }, "anchorSource": "exact" }, @@ -102091,8 +102303,15 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.inspect", + "tests": [ + "tests/corpus/1630-inspect-scalars.ts", + "tests/corpus/1632-inspect-records.ts", + "tests/corpus/1637-inspect-dyn.ts", + "tests/corpus/2485-inspect-circular-refs.ts", + "tests/corpus/5884-inspect-layout-options.ts" + ] }, "anchorSource": "exact" }, @@ -102855,8 +103074,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isBigInt64Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -102878,8 +103105,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isBigIntObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -102901,8 +103136,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isBigUint64Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -102924,8 +103167,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isBooleanObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -102947,8 +103198,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isBoxedPrimitive", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -102970,8 +103229,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isCryptoKey", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103022,8 +103289,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isDate", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103045,8 +103320,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isExternal", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103068,8 +103351,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isFloat16Array", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103294,8 +103585,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isKeyObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103346,8 +103645,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isMapIterator", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103421,8 +103728,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isNumberObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103560,8 +103875,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isSetIterator", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103583,8 +103906,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isSharedArrayBuffer", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103606,8 +103937,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isStringObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -103629,8 +103968,16 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:util" + "status": "partial", + "evidence": "compiler-feature:util.util.types.isSymbolObject", + "tests": [ + "tests/corpus/5831-util-types-brands.ts", + "tests/corpus/5832-util-types-brands-dynamic.js", + "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" + ] }, "anchorSource": "exact" }, @@ -112740,6 +113087,22 @@ "tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", "tests/corpus/5797-worker-message-port-transfer.ts", + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts", + "tests/corpus/5803-worker-environment-data.ts", + "tests/corpus/5804-worker-environment-data.cjs", + "tests/corpus/5805-worker-receive-message-parent-port.ts", + "tests/corpus/5806-worker-resource-limits.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts", + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs", + "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", @@ -112847,8 +113210,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.BroadcastChannel extends EventTarget", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "exact" }, @@ -112870,8 +113238,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.broadcastChannel.close", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "exact" }, @@ -112893,8 +113266,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.broadcastChannel.postMessage", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "exact" }, @@ -112916,8 +113294,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.broadcastChannel.ref", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "exact" }, @@ -112939,8 +113322,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.broadcastChannel.unref", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "exact" }, @@ -112962,8 +113350,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.onmessage", + "tests": [ + "tests/corpus/5800-broadcast-channel.ts", + "tests/corpus/5801-broadcast-channel.cjs", + "tests/corpus/5802-broadcast-channel-workers.ts" + ] }, "anchorSource": "ancestor" }, @@ -113013,7 +113406,8 @@ "tests": [ "tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", - "tests/corpus/5797-worker-message-port-transfer.ts" + "tests/corpus/5797-worker-message-port-transfer.ts", + "tests/corpus/5805-worker-receive-message-parent-port.ts" ] }, "anchorSource": "exact" @@ -113600,8 +113994,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" }, @@ -113669,8 +114066,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.resourceLimits", + "tests": [ + "tests/corpus/5806-worker-resource-limits.ts" + ] }, "anchorSource": "ancestor" }, @@ -113692,8 +114092,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.stderr", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] }, "anchorSource": "ancestor" }, @@ -113715,8 +114120,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.stdin", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] }, "anchorSource": "ancestor" }, @@ -113738,8 +114148,13 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.stdout", + "tests": [ + "tests/corpus/5874-worker-stdio-streams.ts", + "tests/corpus/5875-worker-stdout-pipe.cjs", + "tests/corpus/5879-worker-stdin.ts" + ] }, "anchorSource": "ancestor" }, @@ -113789,8 +114204,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.threadName", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] }, "anchorSource": "ancestor" }, @@ -113817,7 +114236,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] }, "anchorSource": "exact" @@ -113845,7 +114266,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] }, "anchorSource": "exact" @@ -113873,7 +114296,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] }, "anchorSource": "exact" @@ -113924,7 +114349,9 @@ "tests": [ "tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", - "tests/corpus/native-worker-exit-listeners.ts" + "tests/corpus/native-worker-exit-listeners.ts", + "tests/corpus/5807-worker-error-values.ts", + "tests/corpus/5808-worker-unhandled-rejections.ts" ] }, "anchorSource": "exact" @@ -113947,8 +114374,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker_threads.getEnvironmentData", + "tests": [ + "tests/corpus/5803-worker-environment-data.ts", + "tests/corpus/5804-worker-environment-data.cjs" + ] }, "anchorSource": "exact" }, @@ -113970,8 +114401,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker_threads.markAsUntransferable", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] }, "anchorSource": "exact" }, @@ -113993,8 +114428,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker_threads.isMarkedAsUntransferable", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] }, "anchorSource": "exact" }, @@ -114016,8 +114455,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" }, @@ -114090,7 +114532,8 @@ "tests": [ "tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", - "tests/corpus/5797-worker-message-port-transfer.ts" + "tests/corpus/5797-worker-message-port-transfer.ts", + "tests/corpus/5805-worker-receive-message-parent-port.ts" ] }, "anchorSource": "exact" @@ -114113,8 +114556,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.worker_threads.setEnvironmentData", + "tests": [ + "tests/corpus/5803-worker-environment-data.ts", + "tests/corpus/5804-worker-environment-data.cjs" + ] }, "anchorSource": "exact" }, @@ -114136,8 +114583,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" }, @@ -114215,8 +114665,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.resourceLimits", + "tests": [ + "tests/corpus/5806-worker-resource-limits.ts" + ] }, "anchorSource": "chapter" }, @@ -114289,8 +114742,12 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:worker_threads" + "status": "partial", + "evidence": "compiler-feature:worker_threads.threadName", + "tests": [ + "tests/corpus/5870-worker-thread-names.ts", + "tests/corpus/5871-worker-untransferable.cjs" + ] }, "anchorSource": "chapter" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 51b8c2064..cdd025b72 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -104,6 +104,7 @@ "node-builtin.os.endianness": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.freemem": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.getPriority": ["tests/corpus/5140-os-host-facts.ts"], + "node-builtin.os.setPriority": ["tests/corpus/5883-os-set-priority.ts"], "node-builtin.os.hostname": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.loadavg": ["tests/corpus/5140-os-host-facts.ts"], "node-builtin.os.machine": ["tests/corpus/5140-os-host-facts.ts"], @@ -145,7 +146,7 @@ "node-builtin.process.versions": ["tests/corpus/process-versions-storage.ts"], "node-builtin.process.rows": ["tests/corpus/2992-stream-rows.ts", "tests/harness/terminal-geometry.test.ts"], "node-builtin.process.columns": ["tests/corpus/1448-stream-columns.ts", "tests/harness/terminal-geometry.test.ts"], - "node-builtin.process.exitCode": ["tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", "tests/corpus/2944-process-exit-code-wrap.ts"], + "node-builtin.process.exitCode": ["tests/corpus/5809-process-exit-and-rejections.cjs", "tests/corpus/2941-process-exit-code.ts", "tests/corpus/2942-process-exit-code-override.ts", "tests/corpus/2943-process-exit-code-invalid.ts", "tests/corpus/2944-process-exit-code-wrap.ts"], "node-builtin.querystring": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2917-builtin-callable-widths.ts"], "node-builtin.readline": ["tests/corpus/1475-readline-closed-stdin.ts"], "node-builtin.stream": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1746-stream-for-await.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/2629-stream-consumers.ts", "tests/corpus/2861-readable-iterator-close.ts"], @@ -204,10 +205,20 @@ {"chapter": "worker_threads", "symbols": ["worker.terminate"], "status": "partial", "evidence": ["tests/corpus/native-worker-terminate-loop.ts", "tests/corpus/native-worker-terminate-wait.ts", "tests/corpus/native-worker-terminate-suspended.ts", "tests/corpus/native-worker-loop-cancellation.ts"]}, {"chapter": "worker_threads", "symbols": ["worker.ref", "worker.unref"], "status": "partial", "evidence": ["tests/corpus/native-worker-references.ts"]}, {"chapter": "worker_threads", "symbols": ["port.close", "port.start", "port.ref", "port.unref", "port.hasRef"], "status": "partial", "evidence": ["tests/corpus/native-worker-port-lifetime.ts"]}, - {"chapter": "worker_threads", "parent": "Class: `Worker`", "signatures": ["Event: `'online'`", "Event: `'message'`", "Event: `'error'`", "Event: `'exit'`"], "status": "partial", "evidence": ["tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", "tests/corpus/native-worker-exit-listeners.ts"]}, + {"chapter": "worker_threads", "parent": "Class: `Worker`", "signatures": ["Event: `'online'`", "Event: `'message'`", "Event: `'error'`", "Event: `'exit'`"], "status": "partial", "evidence": ["tests/corpus/native-worker-references.ts", "tests/corpus/native-worker-error.ts", "tests/corpus/native-worker-exit-listeners.ts", "tests/corpus/5807-worker-error-values.ts", "tests/corpus/5808-worker-unhandled-rejections.ts"]}, {"chapter": "worker_threads", "parent": "Class: `MessagePort`", "signatures": ["Event: `'message'`", "Event: `'close'`"], "status": "partial", "evidence": ["tests/corpus/native-worker-port-lifetime.ts", "tests/corpus/native-worker-messages.ts"]}, - {"chapter": "worker_threads", "symbols": ["MessageChannel", "worker_threads.receiveMessageOnPort"], "status": "partial", "evidence": ["tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", "tests/corpus/5797-worker-message-port-transfer.ts"]}, + {"chapter": "worker_threads", "symbols": ["MessageChannel", "worker_threads.receiveMessageOnPort"], "status": "partial", "evidence": ["tests/corpus/5793-worker-message-channel.ts", "tests/corpus/5796-worker-message-channel.cjs", "tests/corpus/5797-worker-message-port-transfer.ts", "tests/corpus/5805-worker-receive-message-parent-port.ts"]}, + {"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"]}, + {"chapter": "util", "symbols": ["util.format", "util.formatWithOptions"], "status": "partial", "evidence": ["tests/corpus/1636-util-format.ts", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/5835-util-format-integer-specifiers.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", "util.types.isDate", "util.types.isSharedArrayBuffer", "util.types.isMapIterator", "util.types.isSetIterator", "util.types.isKeyObject", "util.types.isNumberObject", "util.types.isStringObject", "util.types.isBooleanObject", "util.types.isBigIntObject", "util.types.isSymbolObject", "util.types.isBoxedPrimitive", "util.types.isBigInt64Array", "util.types.isBigUint64Array", "util.types.isFloat16Array", "util.types.isCryptoKey", "util.types.isExternal"], "status": "partial", "evidence": ["tests/corpus/5831-util-types-brands.ts", "tests/corpus/5832-util-types-brands-dynamic.js", "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"]}, + { "chapter": "util", "signatures": ["`util.inspect(object[, options])`"], "status": "partial", "evidence": ["tests/corpus/1630-inspect-scalars.ts", "tests/corpus/1632-inspect-records.ts", "tests/corpus/1637-inspect-dyn.ts", "tests/corpus/2485-inspect-circular-refs.ts", "tests/corpus/5884-inspect-layout-options.ts"] }, { "chapter": "util", "symbols": ["util.styleText"], "status": "partial", "evidence": ["tests/corpus/node24-style-text.ts", "tests/corpus/node24-style-text-validation.js", "tests/corpus/node24-style-text-environment.js", "tests/corpus/node24-style-text.cjs", "tests/fixtures/node-types/style-text.ts"] }, { "chapter": "util", "symbols": ["util.isDeepStrictEqual"], "status": "partial", "evidence": ["tests/corpus/node24-deep-equality.ts", "tests/corpus/node24-deep-equality.cjs", "tests/corpus/node24-deep-equality-properties.js", "tests/fixtures/node-types/deep-equality.ts", "packages/compiler/test/deep-equality.test.ts"] }, { "chapter": "deprecations", "symbols": ["util.toUSVString"], "status": "not-applicable" }, @@ -337,14 +348,18 @@ { "chapter": "webstreams", "signatures": ["`streamConsumers.arrayBuffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/4875-stream-consumers-array-buffer.ts"] }, { "chapter": "url", "symbols": ["url.fileURLToPath", "url.fileURLToPathBuffer", "url.pathToFileURL"], "status": "partial", "evidence": ["tests/corpus/node24-file-url-buffer.ts", "tests/corpus/node24-file-url-options.ts", "tests/corpus/node24-file-url-errors.js", "tests/fixtures/node-types/file-url-conversion.ts"] }, { "chapter": "url", "symbols": ["URL.canParse", "URL.parse", "url.toString", "url.toJSON"], "status": "partial", "evidence": ["tests/corpus/node24-url-factories.ts", "tests/corpus/node24-url-factory-errors.ts", "tests/fixtures/node-types/url-factories.ts", "tests/harness/project-config.test.ts"] }, + { "chapter": "url", "symbols": ["urlSearchParams.entries", "urlSearchParams.keys", "urlSearchParams.values"], "status": "partial", "evidence": ["tests/corpus/5833-url-search-params-iterators.ts", "tests/corpus/5834-url-search-params-iterators-dynamic.mjs"] }, { "chapter": "url", "signatures": ["Class: `URL`"], "status": "partial", "evidence": ["tests/corpus/url-runtime-base.ts", "tests/corpus/node24-url-mutation.ts", "tests/corpus/node24-url-mutation-coercion.js", "tests/fixtures/node-types/url-mutation.ts"] }, { "chapter": "url", "parent": "Class: `URL`", "signatures": ["`protocol` Type: {string}", "`username` Type: {string}", "`password` Type: {string}", "`host` Type: {string}", "`hostname` Type: {string}", "`port` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/node24-url-authority.ts", "tests/corpus/node24-url-authority-coercion.js", "tests/fixtures/node-types/url-mutation.ts"] }, { "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"] }, + { "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"] }, @@ -359,7 +374,7 @@ { "symbols": ["console.log", "console.info", "console.debug", "console.error", "console.warn"], "status": "partial", "evidence": ["tests/corpus/1460-console-error-warn.ts", "tests/corpus/2440-console-inspect-args.ts", "tests/corpus/2631-create-require/main.ts", "tests/corpus/console-native-value.js", "tests/harness/npm-static.test.ts"] }, { "symbols": ["console.count", "console.countReset", "console.group", "console.groupCollapsed", "console.groupEnd"], "status": "partial", "evidence": ["tests/corpus/5095-console-count-group.ts", "tests/corpus/5098-console-time-warnings.ts", "tests/corpus/5099-console-methods-dynamic.cjs"] }, { "symbols": ["console.time", "console.timeEnd", "console.timeLog"], "status": "partial", "evidence": ["tests/corpus/5098-console-time-warnings.ts"] }, - { "symbols": ["console.assert", "console.dir", "console.dirxml"], "status": "partial", "evidence": ["tests/corpus/5096-console-assert-dir.ts", "tests/corpus/5099-console-methods-dynamic.cjs"] }, + { "symbols": ["console.assert", "console.dir", "console.dirxml"], "status": "partial", "evidence": ["tests/corpus/5096-console-assert-dir.ts", "tests/corpus/5099-console-methods-dynamic.cjs", "tests/corpus/5884-inspect-layout-options.ts"] }, { "symbols": ["console.table"], "status": "partial", "evidence": ["tests/corpus/5097-console-table.ts", "tests/corpus/5099-console-methods-dynamic.cjs"] }, { "symbols": ["console.clear", "console.profile", "console.profileEnd", "console.timeStamp"], "status": "partial", "evidence": ["tests/corpus/5099-console-methods-dynamic.cjs"] }, { "symbols": ["assert.AssertionError"], "status": "partial", "evidence": ["tests/corpus/5510-assert-error-properties.js", "tests/corpus/5511-assert-error-class.ts", "tests/corpus/5512-assert-error-options.js"] }, @@ -374,13 +389,17 @@ { "symbols": ["crypto.timingSafeEqual"], "status": "supported", "evidence": ["tests/corpus/5444-crypto-timing-safe-equal.js", "tests/corpus/2890-crypto-static-utility.ts"] }, { "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.getEventListeners", "events.listenerCount", "events.once", "events.on", "events.setMaxListeners"], "status": "partial", "evidence": ["tests/corpus/1677-emitter-listeners.ts", "tests/corpus/2620-ee-override-once-order.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": ["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"] }, - { "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"] }, + { "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"] }, + { "symbols": ["timersPromises.scheduler.wait", "timersPromises.scheduler.yield"], "status": "partial", "evidence": ["tests/corpus/5839-timers-promises-scheduler.ts", "tests/corpus/5889-timers-scheduler-signal.ts"] }, { "symbols": ["stream.finished", "stream.pipeline", "stream.getDefaultHighWaterMark", "readable.push", "readable.unshift", "readable.read", "readable.pause", "readable.resume", "readable.isPaused", "readable.setEncoding", "readable.pipe", "readable.unpipe", "readable.destroy", "writable.write", "writable.end", "writable.cork", "writable.uncork", "writable.setDefaultEncoding"], "status": "partial", "evidence": ["tests/corpus/1685-stream-readable-basics.ts", "tests/corpus/1688-stream-writable-basics.ts", "tests/corpus/1814-stream-pipeline.cjs", "tests/corpus/5302-stream-promises-options.ts", "tests/corpus/5303-stream-finished-callback-opts.cjs", "tests/corpus/5425-pipeline-generator-stage.ts", "tests/corpus/5428-pipeline-generator-stages.cjs", "tests/corpus/5617-pipeline-function-ends.ts", "tests/corpus/5619-pipeline-function-ends.cjs"] }, { "symbols": ["net.createServer", "net.connect", "net.createConnection", "net.getDefaultAutoSelectFamilyAttemptTimeout", "net.setDefaultAutoSelectFamilyAttemptTimeout", "server.address", "server.close", "server.listen", "socket.destroy", "socket.end", "socket.pause", "socket.resume", "socket.setEncoding", "socket.setTimeout", "socket.write"], "status": "partial", "evidence": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"] }, { "symbols": ["dgram.createSocket", "socket.address", "socket.bind", "socket.close", "socket.connect", "socket.ref", "socket.remoteAddress", "socket.send", "socket.unref"], "status": "partial", "evidence": ["tests/corpus/2597-dgram-send-ladders.cjs"] }, + { "symbols": ["net.SocketAddress", "net.BlockList"], "status": "partial", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, + { "symbols": ["SocketAddress.parse", "BlockList.isBlockList", "blockList.addAddress", "blockList.addAddresses", "blockList.addCIDR", "blockList.addCIDRs", "blockList.addRange", "blockList.addSubnet", "blockList.check", "blockList.clear", "blockList.fromJSON", "blockList.removeAddress", "blockList.removeCIDR", "blockList.removeRange", "blockList.removeSubnet", "blockList.toJSON"], "status": "supported", "evidence": ["tests/corpus/5659-net-blocklist-socketaddress.ts", "tests/fixtures/node-types/net-blocklist.ts"] }, { "symbols": ["readline.createInterface", "rl.close"], "status": "partial", "evidence": ["tests/corpus/1475-readline-closed-stdin.ts"] }, { "symbols": ["test", "it", "test.skip", "test.todo", "test.only", "describe", "suite", "before", "after", "beforeEach", "afterEach", "context.test", "context.skip", "context.todo", "context.assert"], "status": "partial", "evidence": ["tests/harness/node-test.test.ts"] }, { "symbols": ["http.request", "http.get", "http.createServer", "https.request", "https.get", "https.createServer", "request.end", "request.destroy", "request.setTimeout", "request.write", "response.end", "response.getHeader", "response.hasHeader", "response.removeHeader", "response.setHeader", "response.write", "response.writeHead", "headersTimeout", "requestTimeout", "timeout", "keepAliveTimeout", "keepAliveTimeoutBuffer"], "status": "partial", "evidence": ["tests/corpus/1780-http-res-surface.ts", "tests/corpus/2672-http-request-response-callback.ts", "tests/corpus/2689-http-server-timeout-properties.ts"] } diff --git a/native/llvm-codegen/README.md b/native/llvm-codegen/README.md index 5b8f38f2a..8debaeeaf 100644 --- a/native/llvm-codegen/README.md +++ b/native/llvm-codegen/README.md @@ -35,6 +35,8 @@ requested output. Repeating `--output` divides the program into that many partitions, which are compiled concurrently and linked together. The helper splits the emitter's LLVM assembly text directly, so partitions also parse concurrently; input outside the emitter's narrow grammar is parsed whole and split with LLVM instead. Internal definitions become hidden so partitions can reference each other. At opt-level 0 each partition is compiled independently. Optimized partitions follow the ThinLTO model: each is simplified and summarized on its own thread, a thin link decides which small functions every partition imports for inlining and which hidden definitions become internal again, and each partition is then optimized and compiled on its own thread. Partition placement depends only on the module and the partition count, so output never depends on the host. +WebAssembly code generation differs from LLVM's default pipeline in two ways that do not change its output. The pass that gives multiple-entry loops a single entry runs only on functions whose control flow is irreducible; its reachability analysis grows with the square of a function's block count even when it has nothing to change. Object emission also leaves out the module pass that collects external symbols for assembly declarations, so each function's machine code is freed once it is printed instead of when the whole module is compiled. + `--cache-dir ` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect. Unsanitized `speed` program builds, and unsanitized `release` builds of programs small enough for one partition, pass `--import-bitcode ` once per runtime-pack unit the program links that ships bitcode. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted. diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index b39d2c1bf..a5f87e4b6 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -10,9 +10,15 @@ #include "llvm/ADT/StringExtras.h" #include "llvm/ADT/StringRef.h" #include "llvm/ADT/StringSet.h" +#include "llvm/Analysis/RuntimeLibcallInfo.h" +#include "llvm/Analysis/TargetLibraryInfo.h" #include "llvm/Bitcode/BitcodeReader.h" #include "llvm/Bitcode/BitcodeWriter.h" #include "llvm/Bitcode/BitcodeWriterPass.h" +#include "llvm/CodeGen/MachineDominators.h" +#include "llvm/CodeGen/MachineFunction.h" +#include "llvm/CodeGen/MachineFunctionPass.h" +#include "llvm/CodeGen/MachineModuleInfo.h" #include "llvm/IR/DiagnosticInfo.h" #include "llvm/IR/DiagnosticPrinter.h" #include "llvm/IR/LegacyPassManager.h" @@ -20,6 +26,9 @@ #include "llvm/IR/Verifier.h" #include "llvm/IRReader/IRReader.h" #include "llvm/LTO/LTO.h" +#include "llvm/Pass.h" +#include "llvm/PassInfo.h" +#include "llvm/PassRegistry.h" #include "llvm/Passes/PassBuilder.h" #include "llvm/Support/Caching.h" #include "llvm/Support/FileSystem.h" @@ -140,6 +149,138 @@ std::optional verify(Module &M, StringRef Code) { return std::nullopt; } +// Runs WebAssembly's pass that gives multiple-entry loops a single entry only +// on functions that have such a loop. The pass first records, for every +// block, every block it can reach, so its time and memory grow with the +// square of the block count even when it changes nothing: a module +// initializer of 40,000 blocks took four minutes and 22 GB in it. It changes +// a function only when two loop entries can reach each other, which a +// reducible control flow graph never has, so skipping reducible functions +// leaves the output unchanged. +class IrreducibleControlFlowOnly final : public MachineFunctionPass { +public: + static char ID; + + explicit IrreducibleControlFlowOnly(FunctionPass *Fix) + : MachineFunctionPass(ID), Fix(Fix) {} + + StringRef getPassName() const override { return Fix->getPassName(); } + + bool doInitialization(Module &M) override { + MachineFunctionPass::doInitialization(M); + return Fix->doInitialization(M); + } + + bool doFinalization(Module &M) override { return Fix->doFinalization(M); } + + void getAnalysisUsage(AnalysisUsage &Usage) const override { + Fix->getAnalysisUsage(Usage); + } + + bool runOnMachineFunction(MachineFunction &MF) override { + if (isReducible(MF)) + return false; + // The wrapped pass reads the machine function through the module's + // machine code analysis, which this pass requires on its behalf. + if (Fix->getResolver() == nullptr) { + auto *Resolver = new AnalysisResolver(getResolver()->getPMDataManager()); + Resolver->addAnalysisImplsPair( + &MachineModuleInfoWrapperPass::ID, + &getAnalysis()); + Fix->setResolver(Resolver); + } + return Fix->runOnFunction(MF.getFunction()); + } + +private: + // A graph is reducible when every edge that a depth-first search finds + // going back to a block on its stack targets a block that dominates the + // edge's source. Blocks the entry cannot reach count as irreducible, since + // the wrapped pass also examines them. + static bool isReducible(MachineFunction &MF) { + MachineDominatorTree Dominators(MF); + enum class State : unsigned char { New, Active, Done }; + std::vector States(MF.getNumBlockIDs(), State::New); + SmallVector, + 32> + Stack; + size_t Visited = 1; + MachineBasicBlock *Entry = &MF.front(); + States[Entry->getNumber()] = State::Active; + Stack.push_back({Entry, Entry->succ_begin()}); + while (!Stack.empty()) { + auto &[Block, Next] = Stack.back(); + if (Next == Block->succ_end()) { + States[Block->getNumber()] = State::Done; + Stack.pop_back(); + continue; + } + MachineBasicBlock *Successor = *Next++; + State &Seen = States[Successor->getNumber()]; + if (Seen == State::Active) { + if (!Dominators.dominates(Successor, Block)) + return false; + } else if (Seen == State::New) { + Seen = State::Active; + ++Visited; + Stack.push_back({Successor, Successor->succ_begin()}); + } + } + return Visited == MF.size(); + } + + std::unique_ptr Fix; +}; + +char IrreducibleControlFlowOnly::ID = 0; + +// The code generation pipeline for one module, with two changes for +// WebAssembly. The irreducible control flow pass runs only where it can +// change the function (see IrreducibleControlFlowOnly). Object emission also +// leaves out the last machine pass, a module pass that records the external +// symbols every function uses so that assembly output can declare them +// before the first function. A module pass in the middle of the pipeline +// keeps the machine code of every function alive until all of them are +// compiled. Object files do not contain those declarations, and the printer +// creates each symbol when it lowers the first use, so without the pass each +// function's machine code is freed once it is printed. +class CodeGenPasses final : public legacy::PassManagerBase { +public: + CodeGenPasses(const TargetMachine &Machine, CodeGenFileType Type) + : Wasm(Machine.getTargetTriple().isWasm()), + Object(Type == CodeGenFileType::ObjectFile) {} + + void add(Pass *P) override { + if (Wasm) { + StringRef Argument = passArgument(*P); + if (Argument == "wasm-fix-irreducible-control-flow") { + Passes.add( + new IrreducibleControlFlowOnly(static_cast(P))); + return; + } + if (Object && Argument == "wasm-mclower-prepass") { + delete P; + return; + } + } + Passes.add(P); + } + + void run(Module &M) { Passes.run(M); } + +private: + static StringRef passArgument(const Pass &P) { + const PassInfo *Info = + PassRegistry::getPassRegistry()->getPassInfo(P.getPassID()); + return Info == nullptr ? StringRef() : Info->getPassArgument(); + } + + bool Wasm; + bool Object; + legacy::PassManager Passes; +}; + // Writes code for an optimized module to a unique sibling of OutputPath, // which is appended to Temporaries once it exists. std::optional @@ -153,7 +294,7 @@ generateCode(Module &M, TargetMachine &Machine, CodeGenFileType Type, return EmitFailure{"output_open_failed", EC.message()}; Temporaries.push_back(TemporaryPath); raw_fd_ostream Output(TemporaryFd, true); - legacy::PassManager CodeGeneration; + CodeGenPasses CodeGeneration(Machine, Type); if (Machine.addPassesToEmitFile(CodeGeneration, Output, nullptr, Type)) return EmitFailure{"emission_not_supported", "target does not support the requested file type"}; @@ -325,7 +466,7 @@ compileUnoptimizedPartitions(const EmitOptions &Options, P.PB.buildPerModuleDefaultPipeline(OptimizationLevel::O0) .run(**Mod, P.MAM); raw_svector_ostream Output(Objects[I]); - legacy::PassManager CodeGeneration; + CodeGenPasses CodeGeneration(*Machine, CodeGenFileType::ObjectFile); if (Machine->addPassesToEmitFile(CodeGeneration, Output, nullptr, CodeGenFileType::ObjectFile)) return Failures.set({"emission_not_supported", @@ -437,6 +578,44 @@ compileOptimizedPartitions(const EmitOptions &Options, return true; }; + // The backend's own code generation cannot use CodeGenPasses, so + // WebAssembly partitions are compiled here with the passes it would add, + // and returning false skips it. + if (TargetTriple.isWasm()) + Config.PreCodeGenModuleHook = [&](unsigned Task, const Module &Optimized) { + size_t I = Task - 1; + if (I >= Count) + return true; + std::string LookupError; + std::unique_ptr Machine = + createTargetMachine(Options.Target, Options.OptLevel, LookupError); + if (!Machine) { + Failures.set({"target_machine_failed", LookupError}); + return false; + } + // The backend passes the module it owns; only the hook's signature is + // const. + Module &Mod = const_cast(Optimized); + Objects[I].clear(); + raw_svector_ostream Output(Objects[I]); + CodeGenPasses CodeGeneration(*Machine, CodeGenFileType::ObjectFile); + TargetLibraryInfoImpl LibraryInfo(Mod.getTargetTriple(), + Machine->Options.VecLib); + CodeGeneration.add(new TargetLibraryInfoWrapperPass(LibraryInfo)); + CodeGeneration.add(new RuntimeLibraryInfoWrapper( + Mod.getTargetTriple(), Machine->Options.ExceptionModel, + Machine->Options.FloatABIType, Machine->Options.EABIVersion, + Machine->Options.MCOptions.ABIName, Machine->Options.VecLib)); + if (Machine->addPassesToEmitFile(CodeGeneration, Output, nullptr, + CodeGenFileType::ObjectFile)) { + Failures.set({"emission_not_supported", + "target does not support object emission"}); + return false; + } + CodeGeneration.run(Mod); + return false; + }; + // The link refers to module identifiers for its whole lifetime. std::vector Names(Count); for (size_t I = 0; I < Count; ++I) diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index 1d63579dd..ff55c940e 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index 8ee9cb173..b3b0a58dc 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index 7fc63f63d..ef9fe8bcc 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 6705e67c7..a33eb60ab 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index c278b55ed..25cd08204 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index ad5e47544..b07577400 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index b128cd53a..23dc94994 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.3.5", + "version": "0.3.6", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index d72b26cf4..434b76fe6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.3.5", + "version": "0.3.6", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 5af2aa487..8cb8c972f 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -708,6 +708,7 @@ interface MessageEvent extends Event { readonly data: T; readonly origin: string; readonly lastEventId: string; + readonly source: unknown; readonly ports: readonly MessagePort[]; } declare var MessageEvent: { @@ -1026,7 +1027,7 @@ declare module "node:timers/promises" { options?: unknown, ): AsyncGenerator; export const scheduler: { - wait(delay?: number): Promise; + wait(delay?: number, options?: TimerOptions): Promise; yield(): Promise; }; } @@ -2109,6 +2110,8 @@ declare module "os" { export function version(): string; export function endianness(): "BE" | "LE"; export function getPriority(pid?: number): number; + export function setPriority(priority: number): void; + export function setPriority(pid: number, priority: number): void; /* The TARGET platform's null device ("\\\\.\\nul" under a win32 triple). */ export const devNull: string; /* The platform's constants: null-prototype objects with read-only @@ -3250,6 +3253,7 @@ declare module "util/types" { export function isInt8Array(value: unknown): value is Int8Array; export function isInt16Array(value: unknown): value is Int16Array; export function isInt32Array(value: unknown): value is Int32Array; + export function isFloat16Array(value: unknown): value is Float16Array; export function isFloat32Array(value: unknown): value is Float32Array; export function isFloat64Array(value: unknown): value is Float64Array; export function isMap(value: unknown): value is Map; @@ -3264,6 +3268,21 @@ declare module "util/types" { export function isGeneratorFunction(value: unknown): boolean; export function isGeneratorObject(value: unknown): boolean; export function isModuleNamespaceObject(value: unknown): boolean; + export function isDate(value: unknown): value is Date; + export function isSharedArrayBuffer(value: unknown): value is SharedArrayBuffer; + export function isMapIterator(value: unknown): boolean; + export function isSetIterator(value: unknown): boolean; + export function isKeyObject(value: unknown): boolean; + export function isNumberObject(value: unknown): boolean; + export function isStringObject(value: unknown): boolean; + export function isBooleanObject(value: unknown): boolean; + export function isBigIntObject(value: unknown): boolean; + export function isSymbolObject(value: unknown): boolean; + export function isBoxedPrimitive(value: unknown): boolean; + export function isBigInt64Array(value: unknown): value is BigInt64Array; + export function isBigUint64Array(value: unknown): value is BigUint64Array; + export function isCryptoKey(value: unknown): boolean; + export function isExternal(value: unknown): boolean; } declare module "node:util/types" { export * from "util/types"; @@ -3319,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; @@ -3410,6 +3434,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 +3474,8 @@ declare module "node:test" { MockFunctionContext, MockFunctionCall, MockTracker, + MockTimers, + MockTimersOptions, }; export default test; } @@ -3618,7 +3657,8 @@ declare module "querystring" { | string | number | boolean - | ReadonlyArray + | bigint + | ReadonlyArray | null | undefined; } @@ -4436,6 +4476,48 @@ declare module "net" { remoteFamily?: string; } export type AddressInfo = import("node:dgram").AddressInfo; + export type IPVersion = "ipv4" | "ipv6"; + export interface SocketAddressInitOptions { + address?: string; + family?: IPVersion; + flowlabel?: number; + port?: number; + } + /* A native handle (scr_net.c); the getters read its parsed address. */ + export class SocketAddress { + constructor(options?: SocketAddressInitOptions); + readonly address: string; + readonly family: IPVersion; + readonly port: number; + readonly flowlabel: number; + toJSON(): { address: string; port: number; family: IPVersion; flowlabel: number }; + /* undefined when the input is not "[:]" */ + static parse(input: string): SocketAddress | undefined; + static isSocketAddress(value: unknown): value is SocketAddress; + } + /* Address, range and subnet rules; check() matches IPv4 addresses + * against IPv6 rules through their IPv4-mapped form, as Node does. */ + export class BlockList { + constructor(); + readonly rules: readonly string[]; + readonly size: number; + addAddress(address: string | SocketAddress, type?: IPVersion): void; + addAddresses(addresses: readonly (string | SocketAddress)[], type?: IPVersion): void; + addRange(start: string | SocketAddress, end: string | SocketAddress, type?: IPVersion): void; + addSubnet(net: string | SocketAddress, prefix: number, type?: IPVersion): void; + addCIDR(cidr: string): void; + addCIDRs(cidrs: readonly string[]): void; + removeAddress(address: string | SocketAddress, type?: IPVersion): void; + removeRange(start: string | SocketAddress, end: string | SocketAddress, type?: IPVersion): void; + removeSubnet(net: string | SocketAddress, prefix: number, type?: IPVersion): void; + removeCIDR(cidr: string): void; + clear(): void; + check(address: string | SocketAddress, type?: IPVersion): boolean; + toJSON(): string[]; + fromJSON(data: string | readonly string[]): void; + static isBlockList(value: unknown): value is BlockList; + static readonly PRIVATE_RANGES: readonly string[]; + } /* Node's IPv4Reg and IPv6Reg tests: 4, 6, or 0 for isIP. */ export function isIP(input: string): number; export function isIPv4(input: string): boolean; @@ -5764,19 +5846,42 @@ declare module "worker_threads" { export const isMainThread: boolean; export const isInternalThread: boolean; export const threadId: number; + export interface ResourceLimits { + maxYoungGenerationSizeMb?: number; + maxOldGenerationSizeMb?: number; + codeRangeSizeMb?: number; + stackSizeMb?: number; + } + /* {} in the main thread */ + export const resourceLimits: ResourceLimits; + /* the Worker's name option, trimmed ("WorkerThread" by default); "" in the main thread */ + export const threadName: string | null; export interface WorkerOptions { workerData?: unknown; transferList?: readonly unknown[]; argv?: readonly unknown[]; name?: string; + resourceLimits?: ResourceLimits; + stdout?: boolean; + stderr?: boolean; + stdin?: boolean; [option: string]: unknown; } export class Worker { constructor(filename: string | URL, options?: WorkerOptions); readonly threadId: number; + readonly resourceLimits?: ResourceLimits; + readonly threadName: string | null; + /* with the stdout/stderr options: the worker's writes to those streams */ + readonly stdout: import("stream").Readable; + 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: Error) => void): this; + on(event: "error", listener: (error: any) => void): this; on(event: "exit", listener: (code: number) => void): this; on(event: string, listener: (...args: any[]) => void): this; once(event: string, listener: (...args: any[]) => void): this; @@ -5818,6 +5923,27 @@ declare module "worker_threads" { readonly port2: MessagePort; } export function receiveMessageOnPort(port: MessagePort): { message: any } | undefined; + /* This thread's environment data; each new Worker starts from a clone. */ + export function setEnvironmentData(key: any, value?: any): void; + 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. */ + export class BroadcastChannel extends EventTarget { + constructor(name: string); + readonly name: string; + onmessage: ((event: MessageEvent) => void) | null; + onmessageerror: ((event: MessageEvent) => void) | null; + postMessage(message: unknown): void; + close(): void; + ref(): this; + unref(): this; + } export const parentPort: MessagePort | null; export const workerData: any; } @@ -5963,6 +6089,12 @@ declare module "events" { eventName: string, options?: { signal?: AbortSignal }, ): Promise; + /* The emitter's own listenerCount/getMaxListeners/setMaxListeners/ + * listeners, over EventEmitter instances. */ + export function listenerCount(emitter: EventEmitter, eventName: string | symbol): number; + export function getMaxListeners(emitter: EventEmitter): number; + export function setMaxListeners(n?: number, ...eventTargets: EventEmitter[]): void; + export function getEventListeners(emitter: EventEmitter, name: string): Array<() => void>; } export = EventEmitter; } diff --git a/packages/compiler/package.json b/packages/compiler/package.json index fa18a1b60..6e5442dc6 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.3.5", + "version": "0.3.6", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 5fb408403..8972b9e70 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -513,6 +513,19 @@ test("a subclass constructor runs after its base constructor's escape", () => { checkExact(mod, ["object:Base", "object:Sub"], ["link"]); }); +test("a base without a constructor function lets nothing escape", () => { + // Instantiations of a generic class name the generic class as their base, + // which has no constructor function of its own. + const mod = module(); + mod.classes = [ + { name: "Chain", fields: [{ name: "next", type: link }], loc }, + { name: "Chain%0", base: "Chain", fields: [{ name: "next", type: link }], loc }, + ]; + mod.unions = [{ id: "link", arms: [obj("Chain%0"), { kind: "nullT" }] }]; + mod.functions.push(ctor("Chain%0", [setThis("Chain%0", "next", link)])); + checkExact(mod, []); +}); + test("field reads of this inside the constructor do not escape", () => { const read: IrStmt = { kind: "fieldSet", diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index 24d3c470f..c7d476527 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -216,8 +216,13 @@ export function computeCycleMutability( escapePoints.set(name, point); return point; }; - const escapes = (name: string): boolean => - escapePoint(name) < (fnByName.get(`%${name}.constructor`)?.body.length ?? 1); + const escapes = (name: string): boolean => { + const fn = fnByName.get(`%${name}.constructor`); + // A program class without a constructor function runs no code at construction. Generic + // classes are such bases: their instantiations name them as their base. + if (fn === undefined && classByName.get(name)?.runtime !== true) return false; + return escapePoint(name) < (fn?.body.length ?? 1); + }; const ancestorEscaped = (name: string): boolean => { for (let c = classByName.get(name)?.base; c !== undefined; c = classByName.get(c)?.base) if (escapes(c)) return true; diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts index d12ef08b9..c51d03744 100644 --- a/packages/compiler/src/backend/llvm/byte-numbers.ts +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -182,7 +182,11 @@ export function emitByteNumber( const integerType = bits <= 32 ? "i32" : "i64"; if (spec.write) { let raw: string; - if (spec.floating) { + if (spec.floating && bits === 16) { + host.declare(`declare zeroext i16 @scr_f16_from_f64(double)`); + raw = B.tmp(); + B.line(`${raw} = call zeroext i16 @scr_f16_from_f64(double ${value!.name})`); + } else if (spec.floating) { let stored = value!.name; if (bits === 32) { stored = B.tmp(); @@ -228,7 +232,10 @@ export function emitByteNumber( const raw = endianBits(host, loaded, spec.width, little); fastResult = B.tmp(); if (spec.floating) { - if (bits === 32) { + if (bits === 16) { + host.declare(`declare double @scr_f16_to_f64(i16 zeroext)`); + B.line(`${fastResult} = call double @scr_f16_to_f64(i16 zeroext ${raw})`); + } else if (bits === 32) { const float = B.tmp(); B.line(`${float} = bitcast i32 ${raw} to float`); B.line(`${fastResult} = fpext float ${float} to double`); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts index 2572a22fd..90909a024 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.test.ts @@ -723,3 +723,40 @@ test("to-dyn of a copied record projects it, while live and identity conversions fn.body = [convert(cls), ret(num())]; expect(analyzeWith(copies).get(fn.name)).toBeUndefined(); }); + +test("a parameter stored as its last use takes the owned convention", () => { + const cell: IrType = { kind: "object", className: "Cell" }; + const self: IrType = { kind: "object", className: "Box" }; + const store = (value: IrExpr): IrStmt => ({ + kind: "fieldSet", + obj: ref("this", self), + className: "Box", + field: "item", + value, + loc, + }); + const ctor = (body: IrStmt[]): IrFunction => ({ + name: "%Box.constructor", + loc, + returnType: { kind: "void" }, + params: [ + { localId: "this", name: "this", type: self }, + { localId: "item", name: "item", type: cell }, + ], + locals: [local("this", self), local("item", cell)], + body, + }); + // `this.item = item` last: callers hand over their reference. + expect(analyze(ctor([store(ref("item", cell))])).borrowed.get("%Box.constructor")).toEqual( + new Set([0]), + ); + // A later read keeps the borrowed convention (the store retains a copy). + const later = ctor([ + store(ref("item", cell)), + { kind: "exprStmt", expr: call("use", [ref("item", cell)]), loc }, + ]); + expect(analyze(later).borrowed.get("%Box.constructor")).toEqual(new Set([0, 1])); + // A last use that only passes the parameter on is not a store. + const passed = ctor([{ kind: "exprStmt", expr: call("use", [ref("item", cell)]), loc }]); + expect(analyze(passed).borrowed.get("%Box.constructor")).toEqual(new Set([0, 1])); +}); diff --git a/packages/compiler/src/backend/llvm/call-lifetimes.ts b/packages/compiler/src/backend/llvm/call-lifetimes.ts index 72acc625e..65dd76de7 100644 --- a/packages/compiler/src/backend/llvm/call-lifetimes.ts +++ b/packages/compiler/src/backend/llvm/call-lifetimes.ts @@ -9,6 +9,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; import { borrowsMapReadInputs } from "./map-read-lifetimes.js"; import { LazyCaptures } from "./lazy-captures.js"; +import { storedLastUseRead } from "./local-extents.js"; interface ForwardedUse { callee: string; @@ -343,6 +344,7 @@ export function analyzeCallLifetimes( const lazyCaptures = new LazyCaptures(functions); const nodes = new Map(); const unsafe: Parameter[] = []; + const consumed: { fn: string; index: number }[] = []; const result: CallLifetimes = { parameters: new Map(), locals: new Map(), @@ -416,6 +418,7 @@ export function analyzeCallLifetimes( // A captured parameter that nothing rebinds keeps its entry value; its // environment box takes a reference of its own when it is created. const unchangedCaptures = lazyCaptures.parameters(fn); + const walks = walkParameters.get(fn.name); fn.params.forEach((param, index) => { const local = locals.get(param.localId); if ( @@ -424,8 +427,16 @@ export function analyzeCallLifetimes( isRefCounted(param.type) && (stable(local) || unchangedCaptures.has(local.id)) && !uses.declarations.has(local.id) - ) + ) { borrowed.add(index); + if ( + !walks?.has(index) && + !unchangedCaptures.has(local.id) && + param.name !== "this" && + consumedParameter(fn, param.localId) + ) + consumed.push({ fn: fn.name, index }); + } }); if (borrowed.size > 0) result.borrowed.set(fn.name, borrowed); nodes.set( @@ -499,5 +510,33 @@ export function analyzeCallLifetimes( result.locals.set(fn.name, safe); result.projectedLocals.set(fn.name, projected); } + // Consumed parameters take the owned convention, unless only projected. + for (const { fn, index } of consumed) { + if (result.parameters.get(fn)?.has(index) === true) continue; + const borrowed = result.borrowed.get(fn); + if (!borrowed) continue; + borrowed.delete(index); + if (borrowed.size === 0) result.borrowed.delete(fn); + } return result; } + +/** A parameter whose last use in the body stores it (a field, record or + * element store, a record literal field, a push) and moves its reference + * there (lastUseRead). Its owned convention lets a caller hand over a + * temporary or its own last use; a borrowed one would retain the stored + * copy while the caller releases its temporary right after the call. The + * read must be the only node of its kind in the function, like every + * moved read. */ +function consumedParameter(fn: IrFunction, localId: string): boolean { + const read = storedLastUseRead(fn.body, -1, localId, true); + if (read === null) return false; + let count = 0; + const expr = (e: IrExpr): boolean => { + if (e === read) count++; + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); + fn.body.every(stmt); + return count === 1; +} diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index 0a473e65b..8a8778919 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -159,6 +159,7 @@ export const BYTES_ELEM_NUM: Record = { u16: 6, i16: 7, u8c: 8, + f16: 9, }; /** The payload marker of an ABSENT record field slot (IR fieldAbsent): the diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts index cc53d65b4..071d8e1ff 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.test.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.test.ts @@ -156,6 +156,93 @@ test("global closure aliases resolve without borrowing the caller's local namesp entry.body.push({ kind: "assign", localId: "%g.callback", value: closure("predicate"), loc }); expect(facts(mod).get("consume")?.has("test")).not.toBe(true); }); +test("recursion that forwards the unchanged callback agrees with the outer calls", () => { + const mod = fixture(); + const consumer = mod.functions[1]!; + const recurse: IrStmt = { + kind: "exprStmt", + expr: { kind: "call", callee: "consume", args: [ref("test")], type: BOOL, loc }, + loc, + }; + consumer.body.unshift(recurse); + expect(facts(mod).get("consume")?.get("test")).toBe("predicate"); + // Forwarding another function's callback is an ordinary incoming call. + consumer.body[0] = { + kind: "exprStmt", + expr: { kind: "call", callee: "consume", args: [closure("other")], type: BOOL, loc }, + loc, + }; + mod.functions.push({ ...mod.functions[0]!, name: "other" }); + expect(facts(mod).get("consume")?.has("test")).not.toBe(true); +}); +test("a callback forwarded through another consumer's parameter resolves", () => { + const mod = fixture(); + const entry = mod.functions[2]!; + const forward: IrFunction = { + name: "forward", + ownsPrototype: true, + params: [{ localId: "f", name: "f", type: callback }], + locals: [{ id: "f", name: "f", type: callback, mutable: true }], + returnType: BOOL, + body: [ + { + kind: "return", + value: { kind: "call", callee: "consume", args: [ref("f")], type: BOOL, loc }, + loc, + }, + ], + loc, + }; + mod.functions.push(forward); + entry.body = [ + { + kind: "exprStmt", + expr: { kind: "call", callee: "forward", args: [closure("predicate")], type: BOOL, loc }, + loc, + }, + ]; + expect(facts(mod).get("consume")?.get("test")).toBe("predicate"); + // A forwarder that rebinds its parameter forwards an unknown value. + forward.body.unshift({ kind: "assign", localId: "f", value: closure("other"), loc }); + mod.functions.push({ ...mod.functions[0]!, name: "other" }); + expect(facts(mod).get("consume")?.has("test")).not.toBe(true); +}); +test("a closure capturing an unchanged callback parameter calls its target", () => { + const mod = fixture(); + const consumer = mod.functions[1]!; + const inner: IrFunction = { + name: "%inner", + params: [], + captures: [{ localId: "c", name: "c", type: callback }], + locals: [{ id: "c", name: "c", type: callback, mutable: true, boxed: true }], + returnType: BOOL, + body: [ + { + kind: "return", + value: { + kind: "callValue", + callee: ref("c"), + args: [{ kind: "numLit", value: 2, type: F64, loc }], + type: BOOL, + loc, + }, + loc, + }, + ], + loc, + }; + mod.functions.push(inner); + consumer.locals[0]!.boxed = true; + consumer.body.unshift({ + kind: "exprStmt", + expr: { kind: "closure", fnName: "%inner", captures: ["test"], type: funcOf([], BOOL), loc }, + loc, + }); + expect(facts(mod).get("%inner")?.get("c")).toBe("predicate"); + // A write through the environment rebinds the shared binding. + inner.body.unshift({ kind: "assign", localId: "c", value: closure("predicate"), loc }); + expect(facts(mod).get("%inner")?.has("c")).not.toBe(true); +}); test("receiver elision admits only synchronous receiver-free work", () => { const fn = fixture().functions[0]!; expect(callbackIgnoresReceiver(fn)).toBe(true); diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.ts b/packages/compiler/src/backend/llvm/constant-callbacks.ts index d77b9af43..6e6155460 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.ts @@ -1,12 +1,17 @@ -import type { IrExpr, IrModule } from "../../ir/ir.js"; +import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js"; import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js"; +import { writtenLocals } from "../../ir/callback-specialization.js"; import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; /** Closed direct-call consumers can devirtualize a callback when every * incoming call supplies the same function. Keep its evaluated environment, * arguments and ordinary ownership; only the loaded code pointer changes. - * Escaped, exported, recursive forwarding and unknown targets stay indirect. */ + * A call that forwards the consumer's own unchanged parameter (recursion) + * agrees with the others; a parameter forwarded to another consumer, or + * captured unchanged by a closure, resolves through the incoming calls of + * the function that declares it. Escaped, exported, mutually recursive + * forwarding and unknown targets stay indirect. */ export function findConstantCallbacks( mod: IrModule, lifetimes: CallLifetimes, @@ -39,6 +44,9 @@ export function findConstantCallbacks( ); const globals = new Map(); const writes = new Set(); + // Parameters and captures each function (or a closure sharing the + // binding) writes after entry. + const rebinds = writtenLocals(mod); // A function named anywhere but as a direct call's callee can be entered // by a generated adapter or the runtime, which a direct-call census cannot // specialize: the entry, exports, class helpers and methods here, and @@ -175,10 +183,18 @@ export function findConstantCallbacks( const next = definitions.get(owner)?.get(id); if (next) return resolve(owner, next, seen); const fn = functions.get(owner); + const parameter = fn?.params.findIndex((param) => param.localId === id) ?? -1; + if (fn && parameter >= 0) return parameterTarget(fn, parameter, seen); const index = fn?.captures?.findIndex((capture) => capture.localId === id) ?? -1; const local = fn?.locals.find((candidate) => candidate.id === id); const sites = closureSites.get(owner); - if (index < 0 || !local || local.mutable || !sites?.length) return null; + if ( + index < 0 || + !local || + (local.mutable && rebinds.get(owner)?.has(id) !== false) || + !sites?.length + ) + return null; let target: string | null = null; for (const site of sites) { const outer = site.captures[index]; @@ -194,6 +210,40 @@ export function findConstantCallbacks( } return target; } + // The one function every incoming call passes in a consumer's callback + // parameter that its body never rebinds. Results are cached: helpers + // forwarding one callback through several calls resolve it once. + const parameterTargets = new Map(); + function parameterTarget(fn: IrFunction, index: number, seen: Set): string | null { + const param = fn.params[index]!; + const key = `%param:${fn.name}:${index}`; + const cached = parameterTargets.get(key); + if (cached !== undefined) return cached; + if ( + seen.has(key) || + param.type.kind !== "func" || + !consumers.has(fn.name) || + escaped.has(fn.name) || + rebinds.get(fn.name)?.has(param.localId) !== false + ) + return null; + seen.add(key); + const calls = incoming.get(fn.name) ?? []; + let target: string | null = null; + for (const call of calls) { + const value = call.args[index]; + if (call.owner === fn.name && value?.kind === "varRef" && value.localId === param.localId) + continue; + const next = value ? resolve(call.owner, value, new Set(seen)) : null; + if (!next || (target && target !== next)) { + target = null; + break; + } + target = next; + } + parameterTargets.set(key, target); + return target; + } // Global targets are module facts. Resolve them once, and copy only // entries actually used as indirect callees in each body. A module-wide // global/function cross product would dominate large-source emission. @@ -207,7 +257,11 @@ export function findConstantCallbacks( const needed = indirectCallees.get(fn.name)!; if (!needed.size) continue; const targets = new Map(); + const params = new Set(fn.params.map((param) => param.localId)); for (const id of needed) { + // A parameter called in its own body resolves below, only where the + // body borrows it. + if (params.has(id)) continue; if (localIds.get(fn.name)?.has(id)) { const target = resolveLocal(fn.name, id, new Set()); if (target) targets.set(id, target); @@ -228,16 +282,7 @@ export function findConstantCallbacks( : lifetimes.borrowed.get(fn.name)?.has(index)) ) return; - let target: string | null = null; - for (const call of calls) { - const value = call.args[index]; - const next = value ? resolve(call.owner, value) : null; - if (!next || (target && target !== next)) { - target = null; - break; - } - target = next; - } + const target = parameterTarget(fn, index, new Set()); if (target) targets.set(param.localId, target); }); } 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/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 845633148..86e97a374 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1108,11 +1108,14 @@ export class LlDyn { private readonly recordNameTables = new Map(); /** Targets the one-pass parser (scr_json_parse_schema) can build - * directly: number, boolean and string leaves inside arrays and plain - * records (no tuple, index signature, internal '%' slot or `__proto__` - * member; 1..64 fields). Everything else keeps the checked-dynamic - * route alone. */ - private jsonSchemaSupported(t: IrType, visiting: Set): boolean { + * directly: number, boolean and string leaves inside arrays and records + * (no tuple, internal '%' slot or `__proto__` member; at most 64 declared + * fields, and at least one unless an index signature collects the + * members), record members typed as unions of those leaves, records and + * arrays, null and undefined (optional and nullable members; see + * jsonUnionSupported), and index signatures whose value type is not a + * union. Everything else keeps the checked-dynamic route alone. */ + private jsonSchemaSupported(t: IrType, visiting: Set, member = false): boolean { switch (t.kind) { case "f64": case "bool": @@ -1123,21 +1126,56 @@ export class LlDyn { case "record": { if (visiting.has(t.shapeId)) return true; const shape = this.host.recordsById.get(t.shapeId); - if (!shape || shape.tuple || shape.indexValue) return false; - if (shape.fields.length === 0 || shape.fields.length > 64) return false; + if (!shape || shape.tuple) return false; + const index = shape.indexValue; + if (shape.fields.length > 64 || (shape.fields.length === 0 && !index)) return false; visiting.add(t.shapeId); - return shape.fields.every( - (f) => - !f.name.startsWith("%") && - f.name !== "__proto__" && - this.jsonSchemaSupported(f.type, visiting), + return ( + shape.fields.every( + (f) => + !f.name.startsWith("%") && + f.name !== "__proto__" && + this.jsonSchemaSupported(f.type, visiting, true), + ) && + (!index || this.jsonSchemaSupported(index, visiting)) ); } + case "union": + return member && this.jsonUnionSupported(t, visiting); default: return false; } } + /** A union record member the one-pass parser writes directly: every arm + * a number, boolean, string, null, undefined, or a supported record or + * array, with at most one record and one array arm and no discriminant. + * Each JSON value kind then selects at most one arm, so the checked + * route's first full match is that arm, and a value that arm rejects + * fails every arm. */ + private jsonUnionSupported(t: IrType & { kind: "union" }, visiting: Set): boolean { + const def = this.host.unionsById.get(t.unionId); + if (!def || def.discriminant) return false; + let records = 0, + arrays = 0; + return def.arms.every((arm) => { + switch (arm.kind) { + case "f64": + case "bool": + case "string": + case "nullT": + case "undefinedT": + return true; + case "record": + return ++records === 1 && this.jsonSchemaSupported(arm, visiting); + case "array": + return ++arrays === 1 && this.jsonSchemaSupported(arm, visiting); + default: + return false; + } + }); + } + /** The ScrJsonSchema constant for t (scr_runtime.h layout). Records list * fields in declaration order — the order JSON.stringify writes — so the * runtime's next-field guess usually matches without a scan. */ @@ -1155,7 +1193,7 @@ export class LlDyn { case "bool": case "string": { const kind = t.kind === "f64" ? 0 : t.kind === "bool" ? 1 : 2; - body = `${S} ${kind}, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null`; + body = `${S} ${kind}, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null, ptr null, ${S} 0`; break; } case "array": { @@ -1169,7 +1207,7 @@ export class LlDyn { `}`, ``, ); - body = `${S} 4, ${S} 0, ptr null, ptr @${elem}, ptr null, ptr @${ctor}, ptr ${vAdapters(host, t).release}`; + body = `${S} 4, ${S} 0, ptr null, ptr @${elem}, ptr null, ptr @${ctor}, ptr ${vAdapters(host, t).release}, ptr null, ${S} 0`; break; } case "record": { @@ -1190,24 +1228,112 @@ export class LlDyn { const offset = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i64 0, i32 ${index.get(fieldName)}) to ${S})`; return `${fieldTy} { ptr ${host.cstr(fieldName)}, ${S} ${Buffer.byteLength(fieldName, "utf8")}, ${S} ${offset}, ptr @${sub} }`; }); - const fields = `${name}_fields`; - this.defs.push( - `@${fields} = internal constant [${entries.length} x ${fieldTy}] [${entries.join(", ")}]`, - ``, - ); - body = `${S} 3, ${S} ${entries.length}, ptr @${fields}, ptr null, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}`; + let fields = "null"; + if (entries.length > 0) { + fields = `@${name}_fields`; + this.defs.push( + `${fields} = internal constant [${entries.length} x ${fieldTy}] [${entries.join(", ")}]`, + ``, + ); + } + // An index signature's undeclared members go to the overflow map + // after the declared fields. + const overflow = shape.indexValue + ? { + elem: `@${this.jsonSchema(shape.indexValue)}`, + offset: `ptrtoint (ptr getelementptr (%${struct}, ptr null, i64 0, i32 ${shape.fields.length + base}) to ${S})`, + } + : { elem: "null", offset: "0" }; + body = `${S} 3, ${S} ${entries.length}, ptr ${fields}, ptr ${overflow.elem}, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}, ptr null, ${S} ${overflow.offset}`; break; } + case "union": + body = `${S} 5, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null, ptr @${this.jsonUnionSchema(t, name)}, ${S} 0`; + break; default: throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.kind}`); } this.defs.push( - `@${name} = internal constant { ${S}, ${S}, ptr, ptr, ptr, ptr, ptr } { ${body} }`, + `@${name} = internal constant { ${S}, ${S}, ptr, ptr, ptr, ptr, ptr, ptr, ${S} } { ${body} }`, ``, ); return name; } + /** The ScrJsonUnion descriptor of a union member (scr_json.h layout): + * its slot representation, the arm each JSON value kind selects, and the + * values the runtime stores for null and for a missing member. Boxed + * unions build their boxes through `_box`. */ + private jsonUnionSchema(t: IrType & { kind: "union" }, name: string): string { + const host = this.host; + const S = host.sizeType; + const def = host.unionsById.get(t.unionId); + if (!def) throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.unionId}`); + const nullable = host.nullableUnions.has(t.unionId); + const inline = host.inlineUnions.record(t); + const repr = nullable ? 0 : inline ? 1 : 2; + // Indexed by SCR_JARM_*: null, number, boolean, string, object, array, absent. + const armKinds = ["nullT", "f64", "bool", "string", "record", "array", "undefinedT"]; + const tags = armKinds.map((kind) => def.arms.findIndex((arm) => arm.kind === kind)); + const arms = armKinds.map((_, i) => + i >= 1 && i <= 5 && tags[i]! >= 0 + ? `ptr @${this.jsonSchema(def.arms[tags[i]!]!)}` + : "ptr null", + ); + const nullTag = tags[0]!; + const absentTag = tags[6]!; + const nullValue = !inline && nullTag >= 0 ? host.unitInstanceRef(t.unionId, nullTag) : "null"; + const absentValue = !inline && absentTag >= 0 ? host.absentInstanceRef(t.unionId) : "null"; + let box = "null"; + if (repr === 2) { + box = `@${name}_box`; + const lines = [`define internal ptr ${box}(i32 %tag, i64 %bits) ${FN_ATTRS} {`, `entry:`]; + const cases: string[] = []; + const blocks: string[] = []; + def.arms.forEach((arm, tag) => { + if (arm.kind === "nullT" || arm.kind === "undefinedT") return; + cases.push(`i32 ${tag}, label %arm${tag}`); + blocks.push(`arm${tag}:`); + if (arm.kind === "f64") { + host.declare(`declare ptr @scr_union_new_f64(i32, double)`); + blocks.push( + ` %f${tag} = bitcast i64 %bits to double`, + ` %u${tag} = call ptr @scr_union_new_f64(i32 ${tag}, double %f${tag})`, + ); + } else if (arm.kind === "bool") { + host.declare(`declare ptr @scr_union_new_bool(i32, i1 zeroext)`); + blocks.push( + ` %b${tag} = trunc i64 %bits to i1`, + ` %u${tag} = call ptr @scr_union_new_bool(i32 ${tag}, i1 zeroext %b${tag})`, + ); + } else { + const rc = vAdapters(host, arm); + host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`); + blocks.push( + ` %p${tag} = inttoptr i64 %bits to ptr`, + ` %u${tag} = call ptr @scr_union_new_ref(i32 ${tag}, ptr %p${tag}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host, arm)})`, + ); + } + blocks.push(` ret ptr %u${tag}`); + }); + this.defs.push( + ...lines, + ` switch i32 %tag, label %none [ ${cases.join(" ")} ]`, + `none:`, + ` unreachable`, + ...blocks, + `}`, + ``, + ); + } + const descriptor = `${name}_union`; + this.defs.push( + `@${descriptor} = internal constant { ${S}, [7 x ${S}], [7 x ptr], ptr, ptr, ptr } { ${S} ${repr}, [7 x ${S}] [${tags.map((tag) => `${S} ${tag}`).join(", ")}], [7 x ptr] [${arms.join(", ")}], ptr ${nullValue}, ptr ${absentValue}, ptr ${box} }`, + ``, + ); + return descriptor; + } + /** The declared-name tables scr_record_source_note scans: parallel * constant arrays of field-name bytes and byte lengths in declaration * order (the order the record serializes), interned per shape. Internal @@ -2295,6 +2421,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"; @@ -3087,82 +3222,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)!); @@ -3593,6 +3665,21 @@ export class LlDyn { B.terminate(`ret ptr ${r}`); B.startBlock(lNext); } + // PROMISE: Promise.prototype.then/catch/finally as function values; + // every other key is undefined (promises have no own properties). + { + const isP = B.tmp(); + B.line(`${isP} = icmp eq i32 ${kd}, ${DYN_KIND.PROMISE}`); + const lP = B.newLabel("kg.p"); + const lNext = B.newLabel("kg.n"); + B.condBr(isP, lP, lNext); + B.startBlock(lP); + host.declare(`declare ptr @scr_dyn_promise_key_get(ptr, ptr)`); + const r = B.tmp(); + B.line(`${r} = call ptr @scr_dyn_promise_key_get(ptr %d, ptr %k)`); + B.terminate(`ret ptr ${r}`); + B.startBlock(lNext); + } // The canonical-index parse, shared by BYTES/ARR/STR: digits only, // no leading zero. Leaves (digits, idx). const parseIndex = (): { digits: string; idx: string } => { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a9e840837..3fd8ec127 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, @@ -89,6 +89,7 @@ import { } from "./split-loops.js"; import { scalarizeNumericRecords } from "../../ir/scalar-records.js"; import { RESULT_ENTRY_PREFIX, addRecordResultEntries } from "./record-results.js"; +import { specializeCallbackCalls } from "../../ir/callback-specialization.js"; import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { @@ -142,6 +143,7 @@ import { mangleArgPack, mangleAsyncAdoptCall, mangleAsyncAwaitCall, + mangleAsyncInlineAwaitCall, mangleAsyncSpawn, mangleBorrowedFunction, mangleClassObj, @@ -197,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, @@ -209,6 +216,7 @@ import { findMapEntryFusions, type MapEntryFusion } from "./map-entry-fusion.js" import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js"; import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; +import { lastUseRead } from "./local-extents.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; import { ReferenceWrites, type Writes } from "./reference-writes.js"; @@ -480,11 +488,14 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}) options.debugSources === undefined ? addRecordResultEntries( scalarizeNumericRecords( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), + mangleFunction, ), mangleFunction, ), @@ -505,11 +516,14 @@ export function emitLlvmModuleSource( options.debugSources === undefined ? addRecordResultEntries( scalarizeNumericRecords( - specializeNumericCalls( - foldConstantGlobals( - copyStableCaptures( - hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + specializeCallbackCalls( + specializeNumericCalls( + foldConstantGlobals( + copyStableCaptures( + hoistFinalFieldReads(freezeInitOnlyGlobals(dropUnwrittenPropertyBags(mod))), + ), ), + mangleFunction, ), mangleFunction, ), @@ -528,6 +542,18 @@ function llStrBytes(text: string): string { } const NO_BORROWED: ReadonlySet = new Set(); + +/** Runtime entry points (and await-call entries) that may park the running + * execution context, as they appear in emitted function text. */ +const SUSPENDS = + /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_)|sc_a[wd]_)/; +/** The suspensions of a fiber body's own awaits, which its stackless + * coroutine form suspends itself at instead. */ +const AWAIT_SUSPENSIONS = /@(?:scr_await_(?:void|f64|bool|str|ref|hop)\b|sc_a[wd]_[A-Za-z0-9_]+)/g; +/** SUSPENDS in a stackless coroutine body, whose frame bookkeeping reads its + * own fiber and promise. */ +const COROUTINE_SUSPENDS = + /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_(?!self\b|promise\b))|sc_a[wd]_)/; /** ScrExcKind tags of a generator slot holding a number or a boolean. */ const SCR_EXC_F64 = 1; const SCR_EXC_BOOL = 2; @@ -651,6 +677,12 @@ export class LlEmitter { * generator stacks) and so do debug builds (the debugger reads locals * from the native frame). */ private stacklessGenerators = false; + /** Async bodies that suspend only at their own awaits compile to the same + * switched coroutines on native targets (selectStacklessAsync), under the + * generators' conditions; library and Windows builds keep fibers. */ + private stacklessAsyncAllowed = false; + /** The async functions selectStacklessAsync lowered as coroutines. */ + private readonly stacklessAsync = new Set(); /** Some function in the module is lowered as a coroutine. */ private usesCoroutines = false; /** The target lacks a rounding instruction at its baseline CPU (x86-64 @@ -837,6 +869,12 @@ export class LlEmitter { /** Async functions returned directly at some `return f(...)` site of an * async function; each gets an adopting entry (emitAsyncAwaitCall). */ readonly adoptCallees = new Set(); + /** Fiberless async functions a stackless coroutine body awaits directly; + * each gets an inline await entry (emitCoroInlineAwait). */ + readonly inlineAwaitCallees = new Set(); + /** The async functions whose bodies never suspend and that a direct + * await may enter in place (selectStacklessAsync). */ + private inlineAwaitable = new Set(); /** The promise a `return` emitted past its finally regions already * produced for its adopting await (borrowed from the return's parked * slot); that await takes it instead of evaluating its operand. */ @@ -850,6 +888,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(); @@ -988,6 +1029,9 @@ export class LlEmitter { * not a constructor (where they change), not async or a generator. */ private finalFieldBorrows = false; integerRanges: IntegerRanges = new Map(); + /** Number arrays of the current function whose every present element is + * an int32 (Int32[] contracts), or null when the module has none. */ + int32Elements: ((arr: IrExpr) => boolean) | null = null; /** Sums whose result only feeds 32-bit conversions (wrapping-sums.ts). */ wrappingSums = new Set(); private byteWindowsEnabled = true; @@ -1073,6 +1117,7 @@ export class LlEmitter { }), ); this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined); + this.mockTimers = moduleUsesMockTimers(mod); this.debug = options.debugSources === undefined ? null @@ -1147,6 +1192,10 @@ export class LlEmitter { this.mayThrow = mt.fns; this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; + // A stackless call's eager prefix borrows its caller's stack; the + // runtime moves deep prefix nests to a fresh stack using the guard, + // which Windows lacks. + this.stacklessAsyncAllowed = this.stacklessGenerators && mod.lib === undefined && !windows; this.publishedTypes = computePublishedTypes(mod); this.immutableFields = computeImmutableFields(mod); this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`)); @@ -1865,6 +1914,7 @@ export class LlEmitter { fnDefs.push(def); this.fnDefText.set(fn.name, def); } + this.selectStacklessAsync(fnDefs); const errorViews: string[] = []; for (const property of ["name", "message"]) if (this.mod.functions.some((fn) => fn.name === `%error.${property}.read`)) { @@ -2000,12 +2050,28 @@ export class LlEmitter { this.dyn.toDynHelper({ kind: "object", className: "%Writable" }), ] : null; - if (childStreamBoxes) { + // worker.stdout and worker.stderr box the stream unit's Readable the + // same way, and worker.stdin its Writable when the program has one + // (scr_worker_stdio_install, scr_worker_events.c). + const workerStdioBox = + this.mod.workers === true && usesStream && this.classMeta.has("%Readable") + ? (childStreamBoxes?.[0] ?? + this.dyn.toDynHelper({ kind: "object", className: "%Readable" })) + : null; + const workerStdinBox = + this.mod.workers === true && usesStream && this.classMeta.has("%Writable") + ? (childStreamBoxes?.[1] ?? + this.dyn.toDynHelper({ kind: "object", className: "%Writable" })) + : null; + if (childStreamBoxes || workerStdioBox || workerStdinBox) { this.declare( `@scr_stream_child_ops = external constant { ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr }`, ); - this.declare(`declare void @scr_child_dyn_streams_install(ptr, ptr, ptr)`); } + if (childStreamBoxes) + this.declare(`declare void @scr_child_dyn_streams_install(ptr, ptr, ptr)`); + if (workerStdioBox || workerStdinBox) + this.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`); if (usesFsWatch) this.declare(`declare void @scr_watch_install()`); if (this.ffiHasForeignCallback) this.declare(`declare void @scr_ffi_install()`); if (usesStream) this.declare(`declare void @scr_stream_install()`); @@ -2018,7 +2084,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()`); } @@ -2162,6 +2231,12 @@ export class LlEmitter { if (this.mod.lib.exports.some((e) => e.returns === "bytes")) { this.declare(`declare void @scr_library_bytes_out(ptr, ptr, ptr)`); } + if (this.mod.lib.exports.some((e) => e.params.includes("cstring"))) { + this.declare(`declare ptr @scr_library_cstr_in(ptr, ptr)`); + } + if (this.mod.lib.exports.some((e) => e.returns === "cstring")) { + this.declare(`declare ptr @scr_library_cstr_out(ptr)`); + } } // Helpers assemble BEFORE the declaration table flushes (they add // write/abort declarations). @@ -2595,6 +2670,11 @@ export class LlEmitter { ` call void @scr_child_dyn_streams_install(ptr @scr_stream_child_ops, ptr @${childStreamBoxes[0]}, ptr @${childStreamBoxes[1]})`, ] : []), + ...(workerStdioBox || workerStdinBox + ? [ + ` call void @scr_worker_stdio_install(ptr @scr_stream_child_ops, ptr ${workerStdioBox ? `@${workerStdioBox}` : "null"}, ptr ${workerStdinBox ? `@${workerStdinBox}` : "null"})`, + ] + : []), // fs.watch programs fill the loop's watch hooks the same way — // scr_watch.c links only when this line is emitted. ...(usesFsWatch ? [` call void @scr_watch_install()`] : []), @@ -2607,6 +2687,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)}()`]), @@ -2938,6 +3020,15 @@ export class LlEmitter { ``, ); } + if (e.inboundCstringTrap !== undefined) { + // The cstring-in helper's host-contract trap message (a NULL + // `const char *`): same assembled structured form and SC4012 code. + const trapBytes = Buffer.byteLength(e.inboundCstringTrap, "utf8"); + out.push( + `@sc_lib_cstr_trap_${e.symbol} = internal constant [${trapBytes + 1} x i8] c"${llStrBytes(e.inboundCstringTrap)}"`, + ``, + ); + } e.params.forEach((cls, i) => { switch (cls) { case "f64": @@ -2995,6 +3086,13 @@ export class LlEmitter { ); args.push(`ptr %c${i}`); break; + case "cstring": + params.push(`ptr %a${i}`); + body.push( + ` %c${i} = call ptr @scr_library_cstr_in(ptr %a${i}, ptr @sc_lib_cstr_trap_${e.symbol})`, + ); + args.push(`ptr %c${i}`); + break; } }); if (e.returns === "string" || e.returns === "bytes") { @@ -3059,6 +3157,16 @@ export class LlEmitter { ` ret void`, ); break; + case "cstring": + // The result's arena copy, returned as `const char *`. + retType = "ptr"; + body.push( + ` %r = call ptr ${target}(${callArgs})`, + ` call void @scr_library_check_exc()`, + ` %z = call ptr @scr_library_cstr_out(ptr %r)`, + ` ret ptr %z`, + ); + break; } out.push( `define ${retType} @${e.symbol}(${params.join(", ")}) ${FN_ATTRS} { ; library export ${e.fnName}`, @@ -3720,29 +3828,49 @@ export class LlEmitter { * cannot reorder execution undetected: the runtime traps when a context parks while a * fiberless body is on its stack. */ private fiberlessAsyncFunctions(): Set { + const parks = this.parkingFunctions(); + const eligible = parks === null ? new Set() : this.neverParkingAsync(parks); + for (const name of this.inlineAwaitCallees) + if (!eligible.has(name)) + throw new InternalCompilerError(`llvm emitter bug: inline await of suspending ${name}`); + return eligible; + } + + /** The async functions `parks` leaves fiberless (fiberlessAsyncFunctions). */ + private neverParkingAsync(parks: ReadonlySet): Set { const eligible = new Set(); - if (this.wasi || this.mod.workers === true) return eligible; - const suspends = - /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_)|sc_a[wd]_)/; - for (const decl of this.decls) if (/@scr_test_sub\b/.test(decl)) return eligible; - for (const text of this.fnDefText.values()) if (/@scr_test_sub\b/.test(text)) return eligible; - const byMangled = new Map(); for (const fn of this.mod.functions) { - byMangled.set(mangleFunction(fn.name), fn.name); - byMangled.set(mangleBorrowedFunction(fn.name), fn.name); + if (fn.async !== true || fn.generator !== undefined) continue; + if (fn.asyncCacheGlobal !== undefined || fn.asyncCycleCacheGlobal !== undefined) continue; + if (fn.returnType.kind === "dyn" || parks.has(fn.name)) continue; + if (!this.fnDefText.has(fn.name) || this.stacklessAsync.has(fn.name)) continue; + eligible.add(fn.name); } + return eligible; + } + + /** The module functions whose bodies may park their execution context: + * their emitted text mentions a suspension entry point (SUSPENDS), or + * they mention such a function (transitively). Null where async bodies + * must keep fibers regardless: wasm32 (its coroutine lowering owns async + * bodies), worker programs, and programs that use node:test subtests, + * whose `t.test()` awaits on the CURRENT fiber even from synchronous + * code. */ + private parkingFunctions(): Set | null { + if (this.wasi || this.mod.workers === true) return null; + for (const decl of this.decls) if (/@scr_test_sub\b/.test(decl)) return null; + for (const text of this.fnDefText.values()) if (/@scr_test_sub\b/.test(text)) return null; // Reverse mention edges, then propagate "may park" to every mentioner. const mentionedBy = new Map>(); const parks = new Set(); const work: string[] = []; for (const [name, text] of this.fnDefText) { - if (suspends.test(text)) { + if (SUSPENDS.test(text)) { parks.add(name); work.push(name); } - for (const match of text.matchAll(/@(sc_b?f_[A-Za-z0-9_]+)/g)) { - const callee = byMangled.get(match[1]!); - if (callee === undefined || callee === name) continue; + for (const callee of this.mentionedFunctions(text)) { + if (callee === name) continue; let callers = mentionedBy.get(callee); if (callers === undefined) mentionedBy.set(callee, (callers = new Set())); callers.add(name); @@ -3757,14 +3885,67 @@ export class LlEmitter { work.push(caller); } } - for (const fn of this.mod.functions) { - if (fn.async !== true || fn.generator !== undefined) continue; - if (fn.asyncCacheGlobal !== undefined || fn.asyncCycleCacheGlobal !== undefined) continue; - if (fn.returnType.kind === "dyn" || parks.has(fn.name)) continue; - if (!this.fnDefText.has(fn.name)) continue; - eligible.add(fn.name); + return parks; + } + + private mangledFunctions: Map | null = null; + + /** The module functions whose ordinary or borrowed body `text` mentions. */ + private *mentionedFunctions(text: string): Generator { + if (this.mangledFunctions === null) { + this.mangledFunctions = new Map(); + for (const fn of this.mod.functions) { + this.mangledFunctions.set(mangleFunction(fn.name), fn.name); + this.mangledFunctions.set(mangleBorrowedFunction(fn.name), fn.name); + } } - return eligible; + for (const match of text.matchAll(/@(sc_b?f_[A-Za-z0-9_]+)/g)) { + const callee = this.mangledFunctions.get(match[1]!); + if (callee !== undefined) yield callee; + } + } + + /** Chooses the native async functions whose bodies compile to switched + * coroutines instead of running on a fiber: a suspended call then keeps a + * heap frame of the values live across its awaits rather than a fiber and + * its stack. A body qualifies when its only suspension points are its own + * awaits, which the coroutine form suspends itself at (emitCoroAwait), so + * nothing it runs can park the stack it borrows. It is first emitted as + * usual: that text may suspend only through await runtime calls and + * await-call entries, which the coroutine form replaces, and may call no + * function that parks. The coroutine form is then emitted and must pass + * the same test with no exceptions (its frame bookkeeping excepted); + * otherwise the fiber form stays. Bodies that never suspend stay + * fiberless, and module evaluation bodies and `dyn` results keep fibers + * as they do there. The runtime traps if a stackless body ever parks. */ + private selectStacklessAsync(fnDefs: string[]): void { + if (!this.stacklessAsyncAllowed) return; + const parks = this.parkingFunctions(); + if (parks === null) return; + // Coroutine bodies emitted below are the only inline-await callers, and + // replacing their text changes no other function's parking. + this.inlineAwaitable = this.neverParkingAsync(parks); + const qualifies = (fn: IrFunction, text: string, suspends: RegExp): boolean => { + if (suspends.test(text)) return false; + for (const callee of this.mentionedFunctions(text)) + if (callee !== fn.name && parks.has(callee)) return false; + return true; + }; + this.mod.functions.forEach((fn, index) => { + if (fn.async !== true || fn.generator !== undefined) return; + if (fn.asyncCacheGlobal !== undefined || fn.asyncCycleCacheGlobal !== undefined) return; + if (fn.returnType.kind === "dyn" || !parks.has(fn.name)) return; + const fiberText = fnDefs[index]!; + if (!qualifies(fn, fiberText.replace(AWAIT_SUSPENSIONS, ""), SUSPENDS)) return; + this.stacklessAsync.add(fn.name); + const coroText = this.emitFunction(fn); + if (!qualifies(fn, coroText, COROUTINE_SUSPENDS)) { + this.stacklessAsync.delete(fn.name); + return; + } + fnDefs[index] = coroText; + this.fnDefText.set(fn.name, coroText); + }); } /** The spawn wrapper of a fiberless async function: runs the body on the @@ -3813,13 +3994,85 @@ export class LlEmitter { * entry's result is the awaited value of f's own body type; direct calls * never carry a lifted environment. */ awaitCallEligible(fnName: string): boolean { - if (this.wasi || this.mod.workers === true) return false; + // A coroutine body suspends itself at the await; the entry would park + // the stack it runs on. + if (this.wasi || this.mod.workers === true || this.currentCoro !== null) return false; const fn = this.fnByName.get(fnName); if (fn === undefined || fn.async !== true || fn.generator !== undefined) return false; if (fn.captures !== undefined || fn.returnType.kind === "dyn") return false; return fn.asyncCacheGlobal === undefined && fn.asyncCycleCacheGlobal === undefined; } + /** Whether a stackless coroutine body's `await f(...)` may run f's body in + * place (emitCoroInlineAwait): f never suspends, and its direct call is + * one an await-call entry could take. */ + inlineAwaitEligible(fnName: string): boolean { + if (this.wasi || this.currentCoro?.kind !== "async") return false; + const fn = this.fnByName.get(fnName); + if (fn === undefined || !this.inlineAwaitable.has(fnName)) return false; + return fn.captures === undefined; + } + + /** The inline await entry of a fiberless async function (`sc_ai_*`): its + * body on the caller's stack between scr_async_inline_enter/exit, as in + * its spawn wrapper, returning the result and leaving a throw pending for + * the awaiting coroutine (emitCoroInlineAwait). */ + private emitAsyncInlineAwaitCall(fn: IrFunction): string[] { + const retTy = this.llType(fn.returnType); + const params = fn.params.map((p, i) => `${this.llType(p.type)} %a${i}`); + const args = params.join(", "); + this.declare(`declare void @scr_async_inline_enter(ptr)`); + this.declare(`declare void @scr_async_inline_exit(ptr)`); + const bodyCall = `call ${retTy} @${mangleFunction(fn.name)}(${args})`; + return [ + `define internal ${retTy} @${mangleAsyncInlineAwaitCall(fn.name)}(${args}) ${FN_ATTRS} { ; inline await ${fn.name}`, + `entry:`, + ` %frame = alloca { ptr, ptr }`, + ` call void @scr_async_inline_enter(ptr %frame)`, + retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`, + ` call void @scr_async_inline_exit(ptr %frame)`, + retTy === "void" ? ` ret void` : ` ret ${retTy} %r`, + `}`, + ``, + ]; + } + + /** A stackless coroutine body's `await f(...)` of a fiberless f, the + * await-call entry's turns without its promise: the body runs in place, + * then a clean result takes the await's turn, and a throw becomes the + * rejected promise the await takes its turn on and re-throws, exactly as + * the spawn wrapper's promise would. An adopting await (`return f(...)`) + * first takes the adoption's then-job turn either way. */ + emitCoroInlineAwait(call: ExprOf<"call">, adopt: boolean): LlValue { + const B = this.B; + const value = emitCallExpr(this, call, "inline"); + const thrown = B.tmp(); + for (const line of this.pendingTestLines(thrown)) B.line(line.trim()); + if (adopt) this.emitCoroAwait(null); + const clean = B.newLabel("ai.clean"); + const rejected = B.newLabel("ai.rejected"); + const join = B.newLabel("ai.join"); + B.condBr(thrown, rejected, clean); + B.startBlock(clean); + this.emitCoroAwait(null); + B.br(join); + B.startBlock(rejected); + this.declare(`declare ptr @scr_promise_new()`); + this.declare(`declare void @scr_promise_reject_pending(ptr)`); + this.declare(`declare void @scr_coro_await_void(ptr)`); + this.declare(`declare void @scr_promise_release(ptr)`); + const promise = B.tmp(); + B.line(`${promise} = call ptr @scr_promise_new()`); + B.line(`call void @scr_promise_reject_pending(ptr ${promise})`); + this.emitCoroAwait(promise); + B.line(`call void @scr_coro_await_void(ptr ${promise})`); + B.line(`call void @scr_promise_release(ptr ${promise})`); + B.br(join); + B.startBlock(join); + this.emitPendingCheck(); + return value; + } + /** The await-call entry of an async function (`sc_aw_*`): one direct * `await f(...)` as a call returning the awaited value, so the call site * needs no promise of its own. A fiberless body runs on the caller's @@ -3884,6 +4137,21 @@ export class LlEmitter { ``, ]; } + if (argPackLines !== null && this.stacklessAsync.has(fn.name)) { + // A stackless body's eager prefix already runs on the awaiter's + // stack, which then awaits the promise like the two separate steps. + this.declare(`declare void @scr_promise_release(ptr)`); + return [ + head, + `entry:`, + ` %p = call ptr @${mangleAsyncSpawn(fn.name)}(${args})`, + awaitLine("%v", "%p"), + ` call void @scr_promise_release(ptr %p)`, + retLine("%v"), + `}`, + ``, + ]; + } if (argPackLines !== null) { const kind = ret.kind === "void" @@ -3984,6 +4252,9 @@ export class LlEmitter { if (fiberless.has(fn.name) && this.adoptCallees.has(fn.name)) { for (const line of this.emitAsyncAwaitCall(fn, null, true)) out.push(line); } + if (this.inlineAwaitCallees.has(fn.name)) { + for (const line of this.emitAsyncInlineAwaitCall(fn)) out.push(line); + } if (fiberless.has(fn.name)) { const fieldTys = [ ...(fn.captures !== undefined ? ["ptr"] : []), @@ -4002,10 +4273,12 @@ export class LlEmitter { if (this.adoptCallees.has(fn.name)) { for (const line of this.emitAsyncAwaitCall(fn, argPackLines, true)) out.push(line); } + const stackless = this.stacklessAsync.has(fn.name); + const spawn = stackless ? "scr_async_spawn_stackless" : "scr_async_spawn"; this.declare(`declare ptr @scr_fiber_promise(ptr)`); - this.declare(`declare ptr @scr_async_spawn(ptr, ptr)`); + this.declare(`declare ptr @${spawn}(ptr, ptr)`); this.needOom(); - if (this.wasi) { + if (this.wasi || stackless) { // The coroutine body settles its own promise and owns the retained // lifted environment until final suspension. Its initial call // returns here at the first suspend (or final suspend). @@ -4061,7 +4334,7 @@ export class LlEmitter { ...argPackLines, ]; sp.push( - ` %p = call ptr @scr_async_spawn(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`, + ` %p = call ptr @${spawn}(ptr @${mangleTrampoline(fn.name)}, ptr %ap)`, ...(cache !== null ? [ // The module loader owns this evaluation promise @@ -4105,10 +4378,12 @@ export class LlEmitter { } /** Whether `fn`'s body is emitted as an LLVM switched coroutine: every - * wasm32 async or generator body, and native synchronous generators. */ + * wasm32 async or generator body, native synchronous generators, and the + * native async bodies selectStacklessAsync chose. */ private coroLowered(fn: IrFunction): boolean { if (this.wasi) return fn.async === true || fn.generator !== undefined; - return this.stacklessGenerators && fn.generator !== undefined && fn.async !== true; + if (fn.async === true) return fn.generator === undefined && this.stacklessAsync.has(fn.name); + return this.stacklessGenerators && fn.generator !== undefined; } /** Per-generator-function machinery — the async scaffolding's lazy @@ -6129,6 +6404,53 @@ export class LlEmitter { this.emitCoroSuspendPrepared(); } + /** Awaits in the current body suspend its coroutine: every wasm32 async + * body, and native stackless async bodies (selectStacklessAsync). */ + get coroAwait(): boolean { + return this.wasi || this.currentCoro?.kind === "async"; + } + + /** Suspends the current coroutine body at an await of `promise` (null: a + * single microtask hop, as for a non-promise operand). A native body + * suspends unless the runtime continues it in place, where a fiber's + * hop would continue without switching (scr_coro_await_prepare). */ + emitCoroAwait(promise: string | null): void { + if (this.wasi) { + this.emitWasiSuspend(promise); + return; + } + const coro = this.currentCoro; + if (coro === null) + throw new InternalCompilerError("llvm emitter bug: await suspension outside a coroutine"); + const B = this.B; + const suspend = B.tmp(); + if (promise === null) { + this.declare(`declare zeroext i1 @scr_coro_hop_prepare(ptr)`); + B.line(`${suspend} = call zeroext i1 @scr_coro_hop_prepare(ptr ${coro.self})`); + } else { + this.declare(`declare zeroext i1 @scr_coro_await_prepare(ptr, ptr)`); + B.line( + `${suspend} = call zeroext i1 @scr_coro_await_prepare(ptr ${coro.self}, ptr ${promise})`, + ); + } + const park = B.newLabel("await.suspend"); + const ready = B.newLabel("await.ready"); + B.condBr(suspend, park, ready); + B.startBlock(park); + this.emitCoroSuspendPrepared(); + B.br(ready); + B.startBlock(ready); + } + + /** The runtime entry extracting an await's result of `kind`. A native + * coroutine has already suspended until the promise settled, so its + * extraction takes no further turn. */ + awaitResultFn(kind: "void" | "f64" | "bool" | "str" | "ref"): string { + return !this.wasi && this.currentCoro?.kind === "async" + ? `scr_coro_await_${kind}` + : `scr_await_${kind}`; + } + /** A native generator's OUT slot (scr_async.c's ScrGen.out, mirrored by * %ScrGenHead and %ScrGenSlot { kind, f64, b, payload, release_fn }). */ genOutSlot(gen: string): string { @@ -6405,7 +6727,9 @@ export class LlEmitter { this.localUnionStorageProofs = findLocalUnionStorage(fn, this.callLifetimes, this.unionSources); this.localUnionStorage = new Map(); this.walkBorrows = this.walkBorrowsByFunction.get(fn.name) ?? new Set(); - this.integerRanges = analyzeIntegerRanges(numericFn, this.int32Slots.facts(fn.name)); + const slotFacts = this.int32Slots.facts(fn.name); + this.integerRanges = analyzeIntegerRanges(numericFn, slotFacts); + this.int32Elements = slotFacts.elements ?? null; this.bytesBounds = findBytesBounds(numericFn, this.integerRanges); // Debug builds keep every number local a double for the debugger's // variable view. Lazily boxed captured parameters keep their double, @@ -6449,6 +6773,9 @@ export class LlEmitter { this.currentGenerator = fn.generator ?? null; this.currentAsyncGenerator = fn.async === true && fn.generator !== undefined; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); + this.lastUseMoves.clear(); + this.varRefCounts = null; + this.currentFunctionBody = fn.body; this.inConstructor = this.constructorFunctions.has(fn.name); this.currentCoro = null; this.logArgSlots = 0; @@ -6648,7 +6975,10 @@ export class LlEmitter { if (this.storeInt32Local(p.localId, incoming)) continue; B.line(`store ${this.llType(p.type)} %p_${mangleLocal(p.localId)}, ptr ${slot}`); this.storeIntegerView(p.localId, incoming); - if (isRefCounted(p.type) && !borrowed?.has(index)) fnScope.push({ slot, type: p.type }); + if (isRefCounted(p.type) && !borrowed?.has(index)) { + fnScope.push({ slot, type: p.type }); + this.noteParameterMove(fn, p.localId, slot); + } if (this.reboundByFunction.get(fn.name)?.has(p.localId) && borrowed?.has(index)) { // The caller's borrow stays in the parameter slot; values the body // assigns are owned here until rebinding or exit releases them. @@ -6707,8 +7037,9 @@ export class LlEmitter { this.declare(`declare void @${finish}(ptr)`); B.line(`call void @${finish}(ptr ${coro.self})`); } else { - this.declare(`declare void @scr_wasi_async_finish(ptr)`); - B.line(`call void @scr_wasi_async_finish(ptr ${coro.self})`); + const finish = this.wasi ? "scr_wasi_async_finish" : "scr_coro_async_finish"; + this.declare(`declare void @${finish}(ptr)`); + B.line(`call void @${finish}(ptr ${coro.self})`); } const finalState = B.tmp(); B.line(`${finalState} = call i8 @llvm.coro.suspend(token none, i1 true)`); @@ -7216,6 +7547,91 @@ export class LlEmitter { this.B.line(`call void @scr_async_gen_hop_done()`); } + /** Owned-binding reads that take over the binding's reference, by read + * node (the binding's slot). */ + private readonly lastUseMoves = new Map(); + /** How often each varRef node object occurs in the current function: + * lowering may share one node between several reads, and a move keyed by + * node identity must fire for exactly one of them. */ + private varRefCounts: Map | null = null; + private currentFunctionBody: IrStmt[] = []; + + private varRefCount(e: IrExpr): number { + if (this.varRefCounts === null) { + const counts = new Map(); + everyStmtList(this.currentFunctionBody, { + stmt: () => true, + expr: (node) => { + if (node.kind === "varRef") counts.set(node, (counts.get(node) ?? 0) + 1); + return true; + }, + }); + this.varRefCounts = counts; + } + return this.varRefCounts.get(e) ?? 0; + } + + /** Moves need plain slots that nothing else reads behind the binding's + * back: no debugger view, no coroutine frame, no borrowed (+0) return. */ + private lastUseMovesAllowed(): boolean { + return ( + this.debug === null && + !this.currentBorrowedReturn && + this.currentCoro === null && + this.currentGenerator === null + ); + } + + /** An owned local declared at the statement cursor whose last statement + * reads it once (lastUseRead): that read moves the reference. */ + private noteLastUseMove( + s: IrStmt & { kind: "varDecl" }, + b: ReturnType, + ): void { + const cursor = this.stmtCursor; + if ( + !this.lastUseMovesAllowed() || + cursor === null || + cursor.stmts[cursor.index] !== s || + b.kind !== "local" || + b.local === undefined || + b.local.boxed || + b.local.tdz || + this.integerViews.has(s.localId) + ) + return; + const read = lastUseRead(cursor.stmts, cursor.index, s.localId); + if (read !== null && this.varRefCount(read) === 1) this.lastUseMoves.set(read, b.slot); + } + + /** An owned parameter whose last statement in the body reads it once: + * that read moves the reference. Walk locals may borrow from an owned + * parameter's slot (they need it until the function returns), so bodies + * with walks keep their parameters, and locals rebound from the + * parameter count as derived from it. */ + private noteParameterMove(fn: IrFunction, localId: string, slot: string): void { + if ( + !this.lastUseMovesAllowed() || + fn.async || + fn.generator !== undefined || + (this.walkBorrowsByFunction.get(fn.name)?.size ?? 0) > 0 || + this.reboundByFunction.get(fn.name)?.has(localId) === true || + this.integerViews.has(localId) + ) + return; + const read = lastUseRead(fn.body, -1, localId, true); + if (read !== null && this.varRefCount(read) === 1) this.lastUseMoves.set(read, slot); + } + + /** Emits the move of a noted last read: the slot is cleared, so scope + * exits release nothing and the read's owned temp carries the reference. */ + takeLastUseMove(e: IrExpr, slot: string, type: IrType): boolean { + if (this.lastUseMoves.get(e) !== slot || !typeEquals(type, e.type)) return false; + this.lastUseMoves.delete(e); + this.B.line(`store ptr null, ptr ${slot}`); + return true; + } + /** `return x` of a local whose slot is an owned scope entry of the same * type: the return can take over the local's reference. */ private movableReturn( @@ -7584,6 +8000,7 @@ export class LlEmitter { this.storeIntegerView(s.localId, v, s.init); if (isRefCounted(b.type)) { this.scopes[this.scopes.length - 1]!.push({ slot: b.slot, type: b.type }); + this.noteLastUseMove(s, b); } break; } @@ -7736,6 +8153,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); @@ -8587,6 +9026,12 @@ export class LlEmitter { // own turn is the await (nothing runs in between). const genSettles = this.currentAsyncGenerator && (s.awaited === true || this.finallyStack.length > 0); + // A completion turn that awaits x replaces a return() request's + // already-taken await. + if (this.currentAsyncGenerator && !genSettles) { + this.declare(`declare void @scr_async_gen_return_awaits()`); + B.line(`call void @scr_async_gen_return_awaits()`); + } if (this.currentAsyncGenerator && s.awaited !== true && this.finallyStack.length > 0) { if (this.wasi) this.emitWasiSuspend(null); else { diff --git a/packages/compiler/src/backend/llvm/expr-async.ts b/packages/compiler/src/backend/llvm/expr-async.ts index 0075cedea..6731e13c9 100644 --- a/packages/compiler/src/backend/llvm/expr-async.ts +++ b/packages/compiler/src/backend/llvm/expr-async.ts @@ -338,8 +338,17 @@ export function emitSerializationExpr( // checked source can throw from the dyn walker: finish still runs // (frees the buffer, the partial string joins the frame and // releases on unwind), then the pending check unwinds — the - // runtime ABI’s contract exactly. - if (host.walkers.jsonWriteMayThrow(e.value.type)) host.emitPendingCheck(); + // runtime ABI’s contract exactly. Under %j the runtime first turns + // a circular-structure TypeError into the text "[Circular]". + if (host.walkers.jsonWriteMayThrow(e.value.type)) { + if (e.circular) { + host.declare(`declare ptr @scr_util_format_j_text(ptr)`); + const shown = B.tmp(); + B.line(`${shown} = call ptr @scr_util_format_j_text(ptr ${t})`); + compact = host.own({ name: shown, type: e.type }); + } + host.emitPendingCheck(); + } } // A pretty-print form (`stringify(v, null, 2)`): the frontend // resolved the space to a compile-time indent string (Node's @@ -892,6 +901,17 @@ export function emitAsyncExpr( // the callee's adopting entry. Past finally regions, the return has // already produced the promise (adoptedOperand). const adopted = e.adopt === true ? host.takeAdoptedOperand() : null; + // A stackless coroutine body runs a never-suspending callee in place + // and takes the await's turn itself. + if (adopted === null && e.value.kind === "call" && host.inlineAwaitEligible(e.value.callee)) { + const callee = host.fnByName.get(e.value.callee)!; + if ( + typeEquals(callee.returnType, e.type) && + !host.callLifetimes.borrowed.has(e.value.callee) + ) { + return host.emitCoroInlineAwait(e.value, e.adopt === true); + } + } if (adopted === null && e.value.kind === "call" && host.awaitCallEligible(e.value.callee)) { const callee = host.fnByName.get(e.value.callee)!; if ( @@ -903,32 +923,37 @@ export function emitAsyncExpr( } const pr = adopted ?? host.emitExpr(e.value); if (e.adopt === true) { - if (host.wasi) host.emitWasiSuspend(null); + if (host.coroAwait) host.emitCoroAwait(null); else { host.declare(`declare void @scr_await_hop()`); B.line(`call void @scr_await_hop()`); } } - if (host.wasi) host.emitWasiSuspend(pr.name); + if (host.coroAwait) host.emitCoroAwait(pr.name); if (e.type.kind === "void") { - host.declare(`declare void @scr_await_void(ptr)`); - B.line(`call void @scr_await_void(ptr ${pr.name})`); + const voidAwait = host.awaitResultFn("void"); + host.declare(`declare void @${voidAwait}(ptr)`); + B.line(`call void @${voidAwait}(ptr ${pr.name})`); host.emitPendingCheck(); return { name: "", type: e.type }; } const t = B.tmp(); if (e.type.kind === "f64" || e.type.kind === "date") { - host.declare(`declare double @scr_await_f64(ptr)`); - B.line(`${t} = call double @scr_await_f64(ptr ${pr.name})`); + const f64Await = host.awaitResultFn("f64"); + host.declare(`declare double @${f64Await}(ptr)`); + B.line(`${t} = call double @${f64Await}(ptr ${pr.name})`); } else if (e.type.kind === "bool") { - host.declare(`declare zeroext i1 @scr_await_bool(ptr)`); - B.line(`${t} = call zeroext i1 @scr_await_bool(ptr ${pr.name})`); + const boolAwait = host.awaitResultFn("bool"); + host.declare(`declare zeroext i1 @${boolAwait}(ptr)`); + B.line(`${t} = call zeroext i1 @${boolAwait}(ptr ${pr.name})`); } else if (e.type.kind === "string") { - host.declare(`declare ptr @scr_await_str(ptr)`); - B.line(`${t} = call ptr @scr_await_str(ptr ${pr.name})`); + const strAwait = host.awaitResultFn("str"); + host.declare(`declare ptr @${strAwait}(ptr)`); + B.line(`${t} = call ptr @${strAwait}(ptr ${pr.name})`); } else { - host.declare(`declare ptr @scr_await_ref(ptr)`); - B.line(`${t} = call ptr @scr_await_ref(ptr ${pr.name})`); + const refAwait = host.awaitResultFn("ref"); + host.declare(`declare ptr @${refAwait}(ptr)`); + B.line(`${t} = call ptr @${refAwait}(ptr ${pr.name})`); } const out = host.own({ name: t, type: e.type }); host.emitPendingCheck(); @@ -960,15 +985,16 @@ export function emitAsyncExpr( const lj = B.newLabel("au.j"); B.condBr(isP, lp, lh); B.startBlock(lp); - host.declare(`declare void @scr_await_void(ptr)`); + const voidAwait = host.awaitResultFn("void"); + host.declare(`declare void @${voidAwait}(ptr)`); { const promise = host.unionPeek(u.name, def.id); - if (host.wasi) host.emitWasiSuspend(promise); - B.line(`call void @scr_await_void(ptr ${promise})`); + if (host.coroAwait) host.emitCoroAwait(promise); + B.line(`call void @${voidAwait}(ptr ${promise})`); } B.br(lj); B.startBlock(lh); - if (host.wasi) host.emitWasiSuspend(null); + if (host.coroAwait) host.emitCoroAwait(null); else B.line(`call void @scr_await_hop()`); B.br(lj); B.startBlock(lj); @@ -1000,33 +1026,37 @@ export function emitAsyncExpr( B.condBr(isP, lp, lh); B.startBlock(lp); const peek = host.unionPeek(u.name, def.id); - if (host.wasi) host.emitWasiSuspend(peek); + if (host.coroAwait) host.emitCoroAwait(peek); let awaited: LlValue; if (inner.kind === "f64" || inner.kind === "date") { - host.declare(`declare double @scr_await_f64(ptr)`); + const f64Await = host.awaitResultFn("f64"); + host.declare(`declare double @${f64Await}(ptr)`); const x = B.tmp(); - B.line(`${x} = call double @scr_await_f64(ptr ${peek})`); + B.line(`${x} = call double @${f64Await}(ptr ${peek})`); awaited = { name: x, type: inner }; } else if (inner.kind === "bool") { - host.declare(`declare zeroext i1 @scr_await_bool(ptr)`); + const boolAwait = host.awaitResultFn("bool"); + host.declare(`declare zeroext i1 @${boolAwait}(ptr)`); const x = B.tmp(); - B.line(`${x} = call zeroext i1 @scr_await_bool(ptr ${peek})`); + B.line(`${x} = call zeroext i1 @${boolAwait}(ptr ${peek})`); awaited = { name: x, type: inner }; } else if (inner.kind === "string") { - host.declare(`declare ptr @scr_await_str(ptr)`); + const strAwait = host.awaitResultFn("str"); + host.declare(`declare ptr @${strAwait}(ptr)`); const x = B.tmp(); - B.line(`${x} = call ptr @scr_await_str(ptr ${peek})`); + B.line(`${x} = call ptr @${strAwait}(ptr ${peek})`); awaited = { name: x, type: inner }; } else { - host.declare(`declare ptr @scr_await_ref(ptr)`); + const refAwait = host.awaitResultFn("ref"); + host.declare(`declare ptr @${refAwait}(ptr)`); const x = B.tmp(); - B.line(`${x} = call ptr @scr_await_ref(ptr ${peek})`); + B.line(`${x} = call ptr @${refAwait}(ptr ${peek})`); awaited = { name: x, type: inner }; } B.line(`store ptr ${host.unionNewOwned(resUnionId, innerTag, awaited)}, ptr ${slot}`); B.br(lj); B.startBlock(lh); - if (host.wasi) host.emitWasiSuspend(null); + if (host.coroAwait) host.emitCoroAwait(null); else B.line(`call void @scr_await_hop()`); const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); if (unitTags.length === 1) { diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 33c24fd54..ac025df09 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -7,6 +7,7 @@ import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js"; import { exactInteger, widenInteger } from "./integer-values.js"; import { byteNumberAccess } from "../../ir/byte-numbers.js"; import { emitByteNumber } from "./byte-numbers.js"; +import { integerOrNaNRange } from "./wrapping-sums.js"; const BYTES_NUM_KIND: Record = { u8: { kind: 0, le: false }, @@ -43,6 +44,7 @@ const DV_GET_KIND: Record = { dvGetFloat64: 7, dvGetBigUint64Number: 8, dvGetBigInt64Number: 9, + dvGetFloat16: 10, }; const DV_SET_KIND: Record = { @@ -54,6 +56,7 @@ const DV_SET_KIND: Record = { dvSetInt32: 5, dvSetFloat32: 6, dvSetFloat64: 7, + dvSetFloat16: 10, }; export function emitIntegerLoopIndex(host: LlvmEmitterContext, expr: IrExpr): string | null { @@ -272,7 +275,7 @@ function emitSharedOrLocalLoad( idx: string, ): LlValue { const B = host.B; - const floating = elem === "f32" || elem === "f64"; + const floating = elem === "f16" || elem === "f32" || elem === "f64"; const fn = floating ? "scr_bytes_shared_get_f64" : "scr_bytes_shared_get_int"; const ret = floating ? "double" : "i32"; host.declare(`declare ${ret} @${fn}(ptr, ${host.sizeType})`); @@ -313,6 +316,16 @@ function emitBytesLoadAt( const B = host.B; const data = host.emitBytesData(receiver); const p = B.tmp(); + if (elem === "f16") { + // Half-precision bits widen exactly; the runtime converts them. + host.declare(`declare double @scr_f16_to_f64(i16 zeroext)`); + const raw = B.tmp(); + const out = B.tmp(); + B.line(`${p} = getelementptr inbounds i16, ptr ${data}, ${host.sizeType} ${idx}`); + B.line(`${raw} = load i16, ptr ${p}, align 1`); + B.line(`${out} = call double @scr_f16_to_f64(i16 zeroext ${raw})`); + return { name: out, type: F64 }; + } if (BYTES_ELEMENT_SIZE[elem] < 4) { const bits = BYTES_ELEMENT_SIZE[elem] * 8; const signed = elem === "i8" || elem === "i16"; @@ -362,6 +375,20 @@ export function emitToUint32( B.line(`${out} = trunc i64 ${integer} to i32`); return out; } + if (expr && integerOrNaNRange(host, expr)) { + // An exact integer or NaN (an Int32[] or typed-array element read that + // may miss, `charCodeAt`): the integer's low 32 bits, or 0 for NaN. The + // conversion of NaN is poison, but the select then picks 0. + const integer = B.tmp(); + const low = B.tmp(); + const ordered = B.tmp(); + const out = B.tmp(); + B.line(`${integer} = fptosi double ${value} to i64`); + B.line(`${low} = trunc i64 ${integer} to i32`); + B.line(`${ordered} = fcmp ord double ${value}, 0.0`); + B.line(`${out} = select i1 ${ordered}, i32 ${low}, i32 0`); + return out; + } const aboveMin = B.tmp(); const belowMax = B.tmp(); const fast = B.tmp(); @@ -457,10 +484,19 @@ export function emitBytesSet( B.line(`${stored} = fptoui double ${clamped} to i32`); } else stored = - elem === "f32" || elem === "f64" ? null : host.emitToUint32(value.name, expr, value.uint32); + elem === "f16" || elem === "f32" || elem === "f64" + ? null + : host.emitToUint32(value.name, expr, value.uint32); const data = host.emitBytesData(receiver); const p = B.tmp(); - if (BYTES_ELEMENT_SIZE[elem] < 4) { + if (elem === "f16") { + // One round-to-nearest-even step from the double, in the runtime. + host.declare(`declare zeroext i16 @scr_f16_from_f64(double)`); + const narrowed = B.tmp(); + B.line(`${narrowed} = call zeroext i16 @scr_f16_from_f64(double ${value.name})`); + B.line(`${p} = getelementptr inbounds i16, ptr ${data}, ${host.sizeType} ${idx}`); + B.line(`store i16 ${narrowed}, ptr ${p}, align 1`); + } else if (BYTES_ELEMENT_SIZE[elem] < 4) { const bits = BYTES_ELEMENT_SIZE[elem] * 8; const byte = B.tmp(); B.line(`${byte} = trunc i32 ${stored!} to i${bits}`); @@ -867,6 +903,7 @@ export function emitBytesIntrinsic( case "dvGetInt16": case "dvGetUint32": case "dvGetInt32": + case "dvGetFloat16": case "dvGetFloat32": case "dvGetFloat64": case "dvGetBigUint64Number": @@ -897,6 +934,7 @@ export function emitBytesIntrinsic( case "dvSetInt16": case "dvSetUint32": case "dvSetInt32": + case "dvSetFloat16": case "dvSetFloat32": case "dvSetFloat64": { // DataView setters: [offset, value, littleEndian?] — void; throw diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 8c748cc93..6e8b8caf4 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -20,6 +20,7 @@ import { collectFfiRetainedOps, parseFfiCallbackKey } from "../ffi-callbacks.js" import { mangleAsyncAdoptCall, mangleAsyncAwaitCall, + mangleAsyncInlineAwaitCall, mangleBorrowedFunction, mangleBorrowingWrapper, mangleClassNew, @@ -70,7 +71,7 @@ export function emitCallExpr( | "instanceOf" | "virtualCall" >, - awaitEntry: false | "await" | "adopt" = false, + awaitEntry: false | "await" | "adopt" | "inline" = false, /** A projection consumes a scalar union result (Int32Abi.union): answer * a private stack box instead of a heap box. */ stackResult = false, @@ -171,6 +172,21 @@ export function emitCallExpr( : `${host.llType(callee.params[i]!.type)} ${a.name}`, ) .join(", "); + if (awaitEntry === "inline") { + // A stackless coroutine's await of a fiberless callee: the entry + // runs the body in place and leaves a throw pending; the enclosing + // await takes the turn and tests (emitCoroInlineAwait). + host.inlineAwaitCallees.add(e.callee); + const awaited = callee.returnType; + const entry = `@${mangleAsyncInlineAwaitCall(e.callee)}`; + if (awaited.kind === "void") { + B.line(`call void ${entry}(${argList})`); + return { name: "", type: awaited }; + } + const t = B.tmp(); + B.line(`${t} = call ${host.llType(awaited)} ${entry}(${argList})`); + return host.own({ name: t, type: awaited }); + } if (awaitEntry) { // The enclosing await routes this call through the callee's // await-call entry: the result is the awaited value, and the @@ -317,6 +333,16 @@ export function emitCallExpr( natArgs.push(`ptr ${data}`, `${host.sizeType} ${len}`); break; } + case "cstring": { + // The string's own NUL-terminated data, after the check that + // it holds no U+0000 (a C string cannot carry one). + host.declare(`declare ptr @scr_library_cstr_arg(ptr)`); + const data = B.tmp(); + B.line(`${data} = call ptr @scr_library_cstr_arg(ptr ${arg.name})`); + natTypes.push("ptr"); + natArgs.push(`ptr ${data}`); + break; + } case "bytes": { const lenPtr = B.tmp(); const len = B.tmp(); 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/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 016ae0e7c..aaae4c35c 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -120,6 +120,8 @@ export function emitLiteralExpr( // An immortal Immutable constant: nothing to count. if (b.kind === "global" && host.immortalGlobals.has(e.localId)) return host.markImmortal({ name: t, type: e.type }); + // The binding's last read takes over its reference (lastUseRead). + if (host.takeLastUseMove(e, b.slot, b.type)) return host.own({ name: t, type: e.type }); return host.own({ name: host.retainValue(t, e.type), type: e.type }); } return { name: t, type: e.type }; @@ -309,7 +311,11 @@ export function emitOperatorExpr( } if (e.op === "%") { const constant = constantDivisor(e.right, ri); - const power = constant === null ? null : emitPowerOfTwoRemainder(host, l, constant); + // Sums, differences and products are usually integers. + const computed = + e.left.kind === "bin" && (e.left.op === "+" || e.left.op === "-" || e.left.op === "*"); + const power = + constant === null ? null : emitPowerOfTwoRemainder(host, l, constant, li, computed); if (power) return power; // A fractional constant divisor never passes the integer check. const checked = diff --git a/packages/compiler/src/backend/llvm/integer-remainder.ts b/packages/compiler/src/backend/llvm/integer-remainder.ts index 144695a45..a877bc625 100644 --- a/packages/compiler/src/backend/llvm/integer-remainder.ts +++ b/packages/compiler/src/backend/llvm/integer-remainder.ts @@ -188,43 +188,204 @@ function isPowerOfTwo(value: number): boolean { return power === value; } +/** `x - trunc(x / c) * c` for a power of two c of at least 1, which is + * exact for every double x. When |x| < c the quotient's magnitude is below + * 1 and truncates to zero, so the result is x. Otherwise x / c is a normal + * double and exact, and the truncated quotient times c is a multiple of c + * of magnitude at most |x|, also exact. When c is below x's ulp, x is + * itself a multiple of c and the difference is zero; otherwise both terms + * are multiples of x's ulp and the difference, smaller than c, is exact. + * ±Infinity gives the default NaN of `Infinity - Infinity`, the NaN fmod + * also returns for it, and a NaN dividend propagates. A zero difference + * takes the dividend's sign (`-4 % 2` and `-0 % 1` are -0). */ +function emitPowerOfTwoDouble(host: LlvmEmitterContext, left: string, magnitude: number): string { + const B = host.B; + const c = f64Lit(magnitude); + const quotient = B.tmp(); + B.line(`${quotient} = fdiv double ${left}, ${c}`); + const whole = emitTruncF64(host, quotient); + const multiple = B.tmp(), + difference = B.tmp(); + B.line(`${multiple} = fmul double ${whole}, ${c}`); + B.line(`${difference} = fsub double ${left}, ${multiple}`); + return signDividendZero(host, left, difference); +} + +/** `value`, or a zero with the dividend's sign when `value` is zero. */ +function signDividendZero(host: LlvmEmitterContext, left: string, value: string): string { + const B = host.B; + const zero = B.tmp(), + signedZero = B.tmp(), + result = B.tmp(); + host.declare("declare double @llvm.copysign.f64(double, double)"); + B.line(`${zero} = fcmp oeq double ${value}, ${f64Lit(0)}`); + B.line(`${signedZero} = call double @llvm.copysign.f64(double ${f64Lit(0)}, double ${left})`); + B.line(`${result} = select i1 ${zero}, double ${signedZero}, double ${value}`); + return result; +} + +/** 2^52: the largest power-of-two divisor the integer path takes. */ +const INTEGER_DIVISOR_LIMIT = 2 ** 52; + +/** 2^62 as a double: the bound on dividends the integer path accepts. */ +const DOUBLE_DIVIDEND_LIMIT = 2 ** 62; + /** JS `%` by a constant divisor whose magnitude c is a power of two of at - * least 1, as `x - trunc(x / c) * c`, which is exact for every double x. - * When |x| < c the quotient's magnitude is below 1 and truncates to zero, - * so the result is x. Otherwise x / c is a normal double and exact, and - * the truncated quotient times c is a multiple of c of magnitude at most - * |x|, also exact. When c is below x's ulp, x is itself a multiple of c and - * the difference is zero; otherwise both terms are multiples of x's ulp and - * the difference, smaller than c, is exact. ±Infinity gives the default - * NaN of `Infinity - Infinity`, the NaN fmod also returns for it, and a NaN - * dividend propagates. A zero difference takes the dividend's sign - * (`-4 % 2` and `-0 % 1` are -0). The divisor's sign does not change the - * result. Returns null for any other divisor; a power of two below 1 keeps - * frem, because x / c can overflow. */ + * least 1. The divisor's sign does not change the result. Returns null for + * any other divisor; a power of two below 1 keeps frem, because x / c can + * overflow. + * + * Where truncation is itself an integer round trip (x86-64 without SSE4.1, + * see trunc.ts), `x - trunc(x / c) * c` puts two multiplications, the round + * trip and a sign fix on the dividend's dependency chain. In a loop that + * feeds the result back, such as `s = (s + x) % 65536`, that costs more + * than the integer remainder. There a proven integer dividend takes the i64 + * remainder directly, and other dividends of magnitude below 2^62 take + * integer paths: + * + * - A computed dividend (`+`, `-` or `*`, as in hashes, generators and + * accumulators) is usually an integer. When it is one, the result is the + * i64 remainder (shifts, since c is a constant power of two), as in + * emitCheckedRemainder. Otherwise, when |x| < 2c (as in a phase + * accumulator reduced by 1), the result is x itself if |x| < c and + * x - copysign(c, x) if not, exact because x and c are then within a + * factor of two, and never zero because x is not an integer. Larger + * quotients take the path below, whose cost does not grow with the + * quotient as fmod's does. + * - Any other dividend takes i = trunc(x) as an i64 and adds back x's + * fraction without a branch on whether x is an integer, which stays fast + * when integers and fractions alternate unpredictably. Because c is an + * integer, trunc(x / c) = trunc(i / c), so + * x % c = (i - trunc(i / c) * c) + (x - i) = (i srem c) + (x - i). + * Both terms carry x's sign or are zero; x - i is exact, and the sum, of + * magnitude below c and a multiple of x's ulp (an integer when x is one), + * is exact too. It is zero exactly when both terms are. + * + * A zero result takes the dividend's sign. Larger, infinite and NaN + * dividends take the double sequence, as do targets with a rounding + * instruction and divisors above 2^52. */ export function emitPowerOfTwoRemainder( host: LlvmEmitterContext, left: LlValue, divisor: number, + dividend: LlInteger | null, + computed: boolean, ): LlValue | null { const magnitude = Math.abs(divisor); if (!isPowerOfTwo(magnitude)) return null; + if (!host.inlineTrunc || magnitude > INTEGER_DIVISOR_LIMIT) + return { name: emitPowerOfTwoDouble(host, left.name, magnitude), type: F64 }; + if (dividend) + return emitSignedIntegerRemainder(host, left, dividend, { + name: String(magnitude), + type: "i64", + signed: false, + range: { min: magnitude, max: magnitude }, + }); const B = host.B; - const c = f64Lit(magnitude); - const quotient = B.tmp(); - B.line(`${quotient} = fdiv double ${left.name}, ${c}`); - const whole = emitTruncF64(host, quotient); - const multiple = B.tmp(), - difference = B.tmp(), + host.declare("declare double @llvm.fabs.f64(double)"); + host.declare("declare double @llvm.copysign.f64(double, double)"); + host.declare("declare i1 @llvm.expect.i1(i1, i1)"); + const raw = B.tmp(), + integer = B.tmp(), + back = B.tmp(), + size = B.tmp(), + inRange = B.tmp(); + B.line(`${raw} = fptosi double ${left.name} to i64`); + // Poison when out of range or NaN; no path below uses it then. + B.line(`${integer} = freeze i64 ${raw}`); + B.line(`${back} = sitofp i64 ${integer} to double`); + B.line(`${size} = call double @llvm.fabs.f64(double ${left.name})`); + // False for NaN. + B.line(`${inRange} = fcmp olt double ${size}, ${f64Lit(DOUBLE_DIVIDEND_LIMIT)}`); + const join = B.newLabel("rem.pow2.join"); + const incoming: string[] = []; + const branch = (condition: string, likely: string, unlikely: string): void => { + const expected = B.tmp(); + B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${condition}, i1 true)`); + B.condBr(expected, likely, unlikely); + }; + if (computed) { + const exact = B.tmp(), + integral = B.tmp(); + B.line(`${exact} = fcmp oeq double ${back}, ${left.name}`); + B.line(`${integral} = and i1 ${inRange}, ${exact}`); + const whole = B.newLabel("rem.pow2.int"), + other = B.newLabel("rem.pow2.other"); + branch(integral, whole, other); + B.startBlock(whole); + const remainder = B.tmp(), + number = B.tmp(), + zero = B.tmp(), + signedZero = B.tmp(), + integerValue = B.tmp(); + B.line(`${remainder} = srem i64 ${integer}, ${magnitude}`); + B.line(`${number} = sitofp i64 ${remainder} to double`); + B.line(`${zero} = icmp eq i64 ${remainder}, 0`); + B.line(`${signedZero} = fmul double ${left.name}, ${f64Lit(0)}`); + B.line(`${integerValue} = select i1 ${zero}, double ${signedZero}, double ${number}`); + incoming.push(`[ ${integerValue}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(other); + const near = B.tmp(); + // False for NaN. + B.line(`${near} = fcmp olt double ${size}, ${f64Lit(2 * magnitude)}`); + const small = B.newLabel("rem.pow2.small"), + large = B.newLabel("rem.pow2.large"); + branch(near, small, large); + B.startBlock(small); + const c = f64Lit(magnitude); + const below = B.tmp(); + B.line(`${below} = fcmp olt double ${size}, ${c}`); + // A branch rather than a select keeps the usual |x| < c case, where the + // result is x, off the dividend's dependency chain. + const keep = B.newLabel("rem.pow2.keep"), + wrap = B.newLabel("rem.pow2.wrap"); + branch(below, keep, wrap); + B.startBlock(keep); + incoming.push(`[ ${left.name}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(wrap); + const step = B.tmp(), + reduced = B.tmp(); + B.line(`${step} = call double @llvm.copysign.f64(double ${c}, double ${left.name})`); + B.line(`${reduced} = fsub double ${left.name}, ${step}`); + incoming.push(`[ ${reduced}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(large); + } + const fractional = B.newLabel("rem.pow2.frac"), + huge = B.newLabel("rem.pow2.float"); + branch(inRange, fractional, huge); + B.startBlock(fractional); + const remainder = B.tmp(), + whole = B.tmp(), + fraction = B.tmp(), + sum = B.tmp(), + wholeZero = B.tmp(), + fractionZero = B.tmp(), zero = B.tmp(), signedZero = B.tmp(), - result = B.tmp(); - host.declare("declare double @llvm.copysign.f64(double, double)"); - B.line(`${multiple} = fmul double ${whole}, ${c}`); - B.line(`${difference} = fsub double ${left.name}, ${multiple}`); - B.line(`${zero} = fcmp oeq double ${difference}, ${f64Lit(0)}`); + value = B.tmp(); + B.line(`${remainder} = srem i64 ${integer}, ${magnitude}`); + B.line(`${whole} = sitofp i64 ${remainder} to double`); + B.line(`${fraction} = fsub double ${left.name}, ${back}`); + B.line(`${sum} = fadd double ${whole}, ${fraction}`); + B.line(`${wholeZero} = icmp eq i64 ${remainder}, 0`); + B.line(`${fractionZero} = fcmp oeq double ${fraction}, ${f64Lit(0)}`); + B.line(`${zero} = and i1 ${wholeZero}, ${fractionZero}`); B.line( `${signedZero} = call double @llvm.copysign.f64(double ${f64Lit(0)}, double ${left.name})`, ); - B.line(`${result} = select i1 ${zero}, double ${signedZero}, double ${difference}`); + B.line(`${value} = select i1 ${zero}, double ${signedZero}, double ${sum}`); + incoming.push(`[ ${value}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(huge); + const floating = emitPowerOfTwoDouble(host, left.name, magnitude); + incoming.push(`[ ${floating}, %${B.currentLabel()} ]`); + B.br(join); + B.startBlock(join); + const result = B.tmp(); + B.line(`${result} = phi double ${incoming.join(", ")}`); return { name: result, type: F64 }; } diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 3ec42605b..4a9f4017a 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"; @@ -184,8 +189,8 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; - if (host.wasi && e.fn === "async.hop") { - host.emitWasiSuspend(null); + if (host.coroAwait && e.fn === "async.hop") { + host.emitCoroAwait(null); return { name: "", type: e.type }; } if (host.wasi && e.fn === "async.awaitDyn") { @@ -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) @@ -680,6 +685,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "events": case "blob": case "record": + case "disposable": return host.emitGenericLibCall(e); case "test": return emitTestLibCall(host, e); diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index bcb65c8fa..74278789c 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -746,6 +746,8 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll const dict = B.tmp(); B.line(`${dict} = call ptr @${mangleRecordNew(e.type.shapeId)}()`); const out = host.own({ name: dict, type: e.type }); + // Node builds the result with a null prototype. + B.line(`store i8 1, ptr ${host.recordMapFlagsPtr(dict, e.type.shapeId)}`); const ovf = host.recordOvfPtr(dict, e.type.shapeId); B.line( `call void @scr_qs_parse_into(ptr ${ovf}, ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${args[2]!.name}, double ${args[3]!.name}, i32 ${strTag}, i32 ${arrTag})`, @@ -1034,6 +1036,16 @@ export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): B.line(`${t} = sitofp i32 ${product} to double`); return { name: t, type: e.type, uint32: product }; } + if (e.fn === "math.f16round") { + const v = host.emitExpr(e.args[0]!); + host.declare(`declare zeroext i16 @scr_f16_from_f64(double)`); + host.declare(`declare double @scr_f16_to_f64(i16 zeroext)`); + const narrowed = B.tmp(); + const t = B.tmp(); + B.line(`${narrowed} = call zeroext i16 @scr_f16_from_f64(double ${v.name})`); + B.line(`${t} = call double @scr_f16_to_f64(i16 zeroext ${narrowed})`); + return { name: t, type: e.type }; + } if (e.fn === "math.fround") { const v = host.emitExpr(e.args[0]!); const narrowed = B.tmp(); 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 168625f92..6a85a8eb3 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -37,7 +37,7 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap = new Ma export const LIB_FN_SYMS: Record = { "assert.expectsErrDyn": "scr_assert_expects_err_dyn", - "insp.jsonDyn": "scr_dyn_format_j", + "insp.jsonDyn": "scr_util_format_j", "http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw", "intl.segmenterNew": "scr_intl_segmenter_new", "weakMap.is": "scr_weak_map_is", @@ -65,6 +65,7 @@ export const LIB_FN_SYMS: Record = { "arrayBuffer.viewU8": "scr_array_buffer_view_u8", "arrayBuffer.viewU32": "scr_array_buffer_view_u32", "arrayBuffer.viewI32": "scr_array_buffer_view_i32", + "arrayBuffer.viewF16": "scr_array_buffer_view_f16", "arrayBuffer.viewF32": "scr_array_buffer_view_f32", "arrayBuffer.viewF64": "scr_array_buffer_view_f64", "arrayBuffer.viewDV": "scr_array_buffer_view_dv", @@ -188,6 +189,7 @@ export const LIB_FN_SYMS: Record = { "os.uptime": "scr_os_uptime", "os.loadavg": "scr_os_loadavg", "os.getPriority": "scr_os_get_priority", + "os.setPriority": "scr_os_set_priority", "os.cpusTake": "scr_os_cpus_take", "os.cpusModel": "scr_os_cpus_model", "os.cpusField": "scr_os_cpus_field", @@ -198,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", @@ -477,6 +480,7 @@ export const LIB_FN_SYMS: Record = { "strdec.write": "scr_strdec_write", "strdec.next": "scr_strdec_next", "strdec.end": "scr_strdec_end", + "strdec.encoding": "scr_strdec_encoding", "stats.isFile": "scr_stats_is_file", "stats.isDirectory": "scr_stats_is_dir", "stats.isSymbolicLink": "scr_stats_is_symlink", @@ -575,14 +579,17 @@ export const LIB_FN_SYMS: Record = { "process.stdoutWriteBytes": "scr_process_stdout_write_bytes", "process.stderrWriteBytes": "scr_process_stderr_write_bytes", "insp.buffer": "scr_insp_buffer", + "insp.typedArray": "scr_insp_typed_array", // util.inspect (scr_inspect.c): all args borrowed; string results +1; // the begin/entry/key/moreItems/end accumulator drives the emitted // container walks. None of these throw. "insp.f64": "scr_insp_f64", "insp.str": "scr_insp_str", "insp.regex": "scr_insp_regex", + "insp.date": "scr_insp_date", "insp.error": "scr_insp_error", "insp.begin": "scr_insp_begin", + "insp.layout": "scr_insp_layout", "insp.entry": "scr_insp_entry", "insp.key": "scr_insp_key", "insp.moreItems": "scr_insp_more_items", @@ -894,6 +901,7 @@ export const LIB_FN_SYMS: Record = { "date.checkedValue": "scr_dyn_native_date_value", "dyn.nativeRegexIs": "scr_dyn_native_regex_is", "dyn.toString": "scr_dyn_to_string_argument", + "dyn.valueOf": "scr_dyn_value_of", "dyn.dataViewIs": "scr_dyn_data_view_is", "dyn.construct": "scr_dyn_construct", "dyn.this": "scr_dyn_this_get", @@ -930,8 +938,18 @@ export const LIB_FN_SYMS: Record = { "worker.root": "scr_worker_root", "worker.data": "scr_worker_data", "worker.parentPort": "scr_worker_parent_port", + "worker.resourceLimits": "scr_worker_resource_limits", + "worker.threadName": "scr_worker_thread_name_current", "worker.channel": "scr_worker_message_channel", "worker.receiveMessageOnPort": "scr_worker_receive_message_on_port", + "worker.setEnvironmentData": "scr_worker_set_environment_data", + "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", "process.stdinDestroy": "scr_process_stdin_destroy", "process.stdinSetRawMode": "scr_process_stdin_set_raw_mode", // node:events EventEmitter (scr_events_emitter.c): receivers borrowed, @@ -1091,6 +1109,12 @@ export const LIB_FN_SYMS: Record = { "net.serverMaxConnectionsSet": "scr_net_server_max_connections_set", "net.serverDropMaxConnectionGet": "scr_net_server_drop_max_connection_get", "net.serverDropMaxConnectionSet": "scr_net_server_drop_max_connection_set", + "net.socketAddressNew": "scr_net_socket_address_new", + "net.socketAddressParse": "scr_net_socket_address_parse", + "net.isSocketAddress": "scr_net_is_socket_address", + "net.blockListNew": "scr_net_block_list_new", + "net.isBlockList": "scr_net_is_block_list", + "net.blockListPrivateRanges": "scr_net_block_list_private_ranges", "net.serverOnDrop": "scr_net_server_on_drop", "net.serverGetConnections": "scr_net_server_get_connections", "net.sockReadable": "scr_net_sock_readable", @@ -1216,9 +1240,14 @@ 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). "sp.finished": "scr_sp_finished", @@ -1315,6 +1344,9 @@ export const LIB_FN_SYMS: Record = { "dyn.reflectGet": "scr_dyn_reflect_get", "dyn.usingMethod": "scr_dyn_using_method", "dyn.usingDispose": "scr_dyn_using_dispose", + "disposable.stackNew": "scr_disposable_stack_new", + "disposable.stackIs": "scr_disposable_stack_is", + "disposable.stackUseCallback": "scr_disposable_stack_use_callback", "dyn.reflectSet": "scr_dyn_reflect_set", "dyn.reflectDefine": "scr_dyn_reflect_define", "dyn.abstractEq": "scr_dyn_abstract_eq", @@ -1367,6 +1399,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. @@ -1384,6 +1450,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "worker.parentPort", "worker.channel", "worker.receiveMessageOnPort", + "worker.broadcastChannel", "process.stdio", "fetch.start", "fetch.input", diff --git a/packages/compiler/src/backend/llvm/lib-test.ts b/packages/compiler/src/backend/llvm/lib-test.ts index 79590cd9d..d0d9a9bfc 100644 --- a/packages/compiler/src/backend/llvm/lib-test.ts +++ b/packages/compiler/src/backend/llvm/lib-test.ts @@ -66,6 +66,34 @@ 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": + 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/backend/llvm/local-extents.test.ts b/packages/compiler/src/backend/llvm/local-extents.test.ts new file mode 100644 index 000000000..ae3b976e2 --- /dev/null +++ b/packages/compiler/src/backend/llvm/local-extents.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, arrayOf, type IrExpr, type IrStmt, type IrType } from "../../ir/ir.js"; +import { lastUseRead, storedLastUseRead } from "./local-extents.js"; + +const loc = { file: "extents.ts", start: 0, end: 0 }; +const cell: IrType = { kind: "object", className: "Cell" }; +const cells = arrayOf(cell); +const ref = (localId: string, type: IrType = cell): IrExpr => ({ + kind: "varRef", + localId, + type, + loc, +}); +const decl = (localId: string, init: IrExpr): IrStmt => ({ kind: "varDecl", localId, init, loc }); +const push = (arr: string, value: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: ref(arr, cells), + args: [value], + type: F64, + loc, + }, + loc, +}); +const field = (obj: string, name: string): IrExpr => ({ + kind: "fieldGet", + obj: ref(obj), + className: "Cell", + field: name, + type: cell, + loc, +}); +const use = (value: IrExpr): IrStmt => ({ + kind: "exprStmt", + expr: { kind: "call", callee: "use", args: [value], type: F64, loc }, + loc, +}); +const fresh: IrExpr = { kind: "new", className: "Cell", args: [], type: cell, loc }; + +test("the only read in the last statement of a binding's extent moves", () => { + const read = ref("row"); + const stmts = [decl("row", fresh), use(field("row", "next")), push("out", read)]; + expect(lastUseRead(stmts, 0, "row")).toBe(read); + expect(storedLastUseRead(stmts, 0, "row")).toBe(read); + // A call argument is a last use but not a store. + const arg = ref("row"); + const called = [decl("row", fresh), use(arg)]; + expect(lastUseRead(called, 0, "row")).toBe(arg); + expect(storedLastUseRead(called, 0, "row")).toBeNull(); +}); + +test("a derived alias extends the extent past the move", () => { + const stmts = [decl("row", fresh), decl("next", field("row", "next")), push("out", ref("row"))]; + // The alias is never mentioned later: the push is still the last use. + expect(lastUseRead(stmts, 0, "row")).not.toBeNull(); + stmts.push(use(ref("next"))); + expect(lastUseRead(stmts, 0, "row")).toBeNull(); +}); + +test("repeated reads, loops and try statements keep the reference", () => { + const twice: IrStmt[] = [ + decl("row", fresh), + { + kind: "exprStmt", + expr: { kind: "call", callee: "pair", args: [ref("row"), ref("row")], type: F64, loc }, + loc, + }, + ]; + expect(lastUseRead(twice, 0, "row")).toBeNull(); + const looped: IrStmt[] = [ + decl("row", fresh), + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [push("out", ref("row"))], + loc, + }, + ]; + expect(lastUseRead(looped, 0, "row")).toBeNull(); +}); + +test("parameters count rebound locals as derived only when asked", () => { + // let n = head; n = n.next; this.head = head; use(n) + const body: IrStmt[] = [ + decl("n", ref("head")), + { kind: "assign", localId: "n", value: field("n", "next"), loc }, + push("out", ref("head")), + use(ref("n")), + ]; + // `n` is assigned, so the declaration alone does not derive it... + expect(lastUseRead(body, -1, "head")).not.toBeNull(); + // ...but a walk could borrow from the parameter through it. + expect(lastUseRead(body, -1, "head", true)).toBeNull(); +}); diff --git a/packages/compiler/src/backend/llvm/local-extents.ts b/packages/compiler/src/backend/llvm/local-extents.ts new file mode 100644 index 000000000..7706a9c0a --- /dev/null +++ b/packages/compiler/src/backend/llvm/local-extents.ts @@ -0,0 +1,207 @@ +import type { IrExpr, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** The only read of an owned binding in the last statement of its extent, + * when that read can take over the binding's reference: the statement reads + * the binding exactly once, outside any loop or try statement nested in it, + * assigns it nowhere and mentions no binding derived from it (such a + * borrowed alias could depend on the moved reference). After the read no + * statement observes the binding again, so the slot can be cleared instead + * of retaining the value (scope releases tolerate NULL). + * + * The binding is declared at `stmts[index]`, or before the list starts when + * `index` is -1 (a parameter and its function body). Callers exclude boxed + * bindings and must also check that the read node occurs once in the + * function: lowering may share one varRef node between several reads. + * `rebound` also counts rebound locals as derived (walk locals can borrow + * from a parameter while rebinding). */ +export function lastUseRead( + stmts: readonly IrStmt[], + index: number, + localId: string, + rebound = false, +): IrExpr | null { + return lastUse(stmts, index, localId, rebound)?.read ?? null; +} + +/** Stores that keep a moved value: the read is the stored value of a field, + * record or element store, a record literal field, or a pushed element. */ +export function storedLastUseRead( + stmts: readonly IrStmt[], + index: number, + localId: string, + rebound = false, +): IrExpr | null { + const use = lastUse(stmts, index, localId, rebound); + return use !== null && use.stored ? use.read : null; +} + +function lastUse( + stmts: readonly IrStmt[], + index: number, + localId: string, + rebound: boolean, +): { read: IrExpr; stored: boolean } | null { + const { extent, derived } = extentOf(stmts, index, localId, rebound); + const last = extent[extent.length - 1]; + if (last === undefined) return null; + const state: { read: IrExpr | null; stored: boolean; ok: boolean } = { + read: null, + stored: false, + ok: true, + }; + const isRead = (e: IrExpr): boolean => e.kind === "varRef" && e.localId === localId; + const stmt = (s: IrStmt): boolean => { + if (!state.ok) return false; + if ((s.kind === "varDecl" || s.kind === "assign") && derived.has(s.localId)) { + state.ok = false; + return false; + } + if ( + s.kind === "for" || + s.kind === "forOf" || + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "tryCatch" + ) { + if (!absentStmt(s, derived)) state.ok = false; + return state.ok; + } + if ( + (s.kind === "fieldSet" || s.kind === "recordSet" || s.kind === "arraySet") && + isRead(s.value) + ) + state.stored = true; + return everyStmtChild(s, expr, stmt); + }; + const expr = (e: IrExpr): boolean => { + if (!state.ok) return false; + if (isRead(e)) { + if (state.read !== null) state.ok = false; + else state.read = e; + return state.ok; + } + if (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") { + if (derived.has(e.localId)) state.ok = false; + } else if (e.kind === "closure" || e.kind === "classRef") { + for (const id of e.captures ?? []) if (derived.has(id)) state.ok = false; + } else if (e.kind === "recordLit") { + if (e.fields.some((f) => isRead(f.value))) state.stored = true; + } else if (e.kind === "arrIntrinsic" && e.method === "push") { + if (e.args.some(isRead)) state.stored = true; + } + if (!state.ok) return false; + return everyExprChild(e, expr, stmt); + }; + stmt(last); + return state.ok && state.read !== null ? { read: state.read, stored: state.stored } : null; +} + +/** The statements of a block during which a binding declared at + * `stmts[index]` can still be observed: from the declaration through the + * last later statement that mentions the binding or any binding derived + * from it. A binding is derived when its declaration (or loop binding) + * mentions the binding or another derived one: a borrowed projection of a + * borrowed binding (`const b = a.f`) stays valid only while the first + * binding's own source keeps its value. Closure captures count as + * mentions. */ +function extentOf( + stmts: readonly IrStmt[], + index: number, + localId: string, + rebound: boolean, +): { extent: IrStmt[]; derived: ReadonlySet } { + const rest = stmts.slice(index + 1); + const derived = new Set([localId]); + const assigned = new Set(); + if (!rebound) for (const s of rest) collectAssignedStmt(s, assigned); + // Declarations of other bindings that mention the derived set join it; + // repeat until nothing is added (loops can declare backwards). + for (;;) { + const before = derived.size; + for (const s of rest) collectDerivedStmt(s, derived, assigned); + if (derived.size === before) break; + } + let last = -1; + for (let i = 0; i < rest.length; i++) if (!absentStmt(rest[i]!, derived)) last = i; + return { extent: rest.slice(0, last + 1), derived }; +} + +/** Only bindings that are never assigned can hold a borrowed pointer + * derived from another local: an assigned local owns its value (walk + * locals root only at parameters, which own nothing to move). */ +function collectDerivedStmt( + s: IrStmt, + derived: Set, + assigned: ReadonlySet, +): boolean { + if ( + (s.kind === "varDecl" || s.kind === "forOf" || s.kind === "assign") && + !derived.has(s.localId) && + !assigned.has(s.localId) + ) { + const value = s.kind === "varDecl" ? s.init : s.kind === "assign" ? s.value : s.iterable; + if (value !== null && !absentExpr(value, derived)) derived.add(s.localId); + } + return everyStmtChild( + s, + (e) => collectDerivedExpr(e, derived, assigned), + (c) => collectDerivedStmt(c, derived, assigned), + ); +} + +function collectDerivedExpr( + e: IrExpr, + derived: Set, + assigned: ReadonlySet, +): boolean { + if (e.kind === "assignExpr" && !derived.has(e.localId) && !assigned.has(e.localId)) { + if (!absentExpr(e.value, derived)) derived.add(e.localId); + } + return everyExprChild( + e, + (c) => collectDerivedExpr(c, derived, assigned), + (s) => collectDerivedStmt(s, derived, assigned), + ); +} + +function collectAssignedStmt(s: IrStmt, assigned: Set): boolean { + if (s.kind === "assign") assigned.add(s.localId); + return everyStmtChild( + s, + (e) => collectAssignedExpr(e, assigned), + (c) => collectAssignedStmt(c, assigned), + ); +} + +function collectAssignedExpr(e: IrExpr, assigned: Set): boolean { + if (e.kind === "assignExpr" || e.kind === "incDec") assigned.add(e.localId); + return everyExprChild( + e, + (c) => collectAssignedExpr(c, assigned), + (s) => collectAssignedStmt(s, assigned), + ); +} + +/** True while no node of the tree mentions a derived binding. */ +function absentExpr(e: IrExpr, derived: ReadonlySet): boolean { + if (e.kind === "varRef" || e.kind === "assignExpr" || e.kind === "incDec") { + if (derived.has(e.localId)) return false; + } else if (e.kind === "closure" || e.kind === "classRef") { + for (const id of e.captures ?? []) if (derived.has(id)) return false; + } + return everyExprChild( + e, + (c) => absentExpr(c, derived), + (s) => absentStmt(s, derived), + ); +} + +function absentStmt(s: IrStmt, derived: ReadonlySet): boolean { + if ((s.kind === "varDecl" || s.kind === "assign") && derived.has(s.localId)) return false; + return everyStmtChild( + s, + (e) => absentExpr(e, derived), + (c) => absentStmt(c, derived), + ); +} diff --git a/packages/compiler/src/backend/llvm/reference-writes.test.ts b/packages/compiler/src/backend/llvm/reference-writes.test.ts new file mode 100644 index 000000000..cbbe49d3b --- /dev/null +++ b/packages/compiler/src/backend/llvm/reference-writes.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "vitest"; +import { + F64, + arrayOf, + type IrExpr, + type IrFunction, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { ReferenceWrites } from "./reference-writes.js"; + +const loc = { file: "writes.ts", start: 0, end: 0 }; +const cell: IrType = { kind: "object", className: "Cell" }; +const cells = arrayOf(cell); +const ref = (localId: string, type: IrType = cells): IrExpr => ({ + kind: "varRef", + localId, + type, + loc, +}); +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const element = (arr: string): IrExpr => ({ + kind: "arrayGet", + arr: ref(arr), + index: num(0), + type: cell, + loc, +}); +const store = (arr: string, value: IrExpr): IrStmt => ({ + kind: "arraySet", + arr: ref(arr), + index: { kind: "arrIntrinsic", method: "length", receiver: ref(arr), args: [], type: F64, loc }, + value, + loc, +}); + +/** fill(input): const out = []; out[out.length] = input[0]; ...; return out */ +function fill(name: string, extra: IrStmt[]): IrFunction { + return { + name, + loc, + returnType: cells, + params: [{ localId: "input", name: "input", type: cells }], + locals: [ + { id: "input", name: "input", type: cells, mutable: false }, + { id: "out", name: "out", type: cells, mutable: false }, + { id: "x", name: "x", type: cell, mutable: false }, + ], + body: [ + { + kind: "varDecl", + localId: "out", + init: { kind: "arrayLit", elems: [], type: cells, loc }, + loc, + }, + store("out", element("input")), + ...extra, + { kind: "return", value: ref("out"), loc }, + ], + }; +} + +function writes(fn: IrFunction): ReferenceWrites { + return new ReferenceWrites(new Map([[fn.name, fn]]), [], () => false, new Set()); +} + +test("stores into a function's own unreached array write no shared location", () => { + const fn = fill("fill", []); + const rw = writes(fn); + expect(rw.summary("fill")).toBeNull(); + rw.enter(fn); + expect(rw.stmts(fn.body)).toBeNull(); +}); + +test("an array that is read back or escapes keeps its element writes", () => { + const readBack = fill("readBack", [{ kind: "varDecl", localId: "x", init: element("out"), loc }]); + const rw = writes(readBack); + expect(rw.summary("readBack")).not.toBeNull(); + expect(rw.conflicts(["a"], rw.summary("readBack"))).toBe(true); + // Storing the array somewhere else lets another path reach its slots. + const escaped = fill("escaped", [ + { + kind: "exprStmt", + expr: { + kind: "arrIntrinsic", + method: "push", + receiver: ref("input"), + args: [ref("out")], + type: F64, + loc, + }, + loc, + }, + ]); + expect(writes(escaped).conflicts(["a"], writes(escaped).summary("escaped"))).toBe(true); + // Writing the input array is never private. + const input = fill("input", [store("input", element("input"))]); + expect(writes(input).conflicts(["a"], writes(input).summary("input"))).toBe(true); +}); diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index dd151de01..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", @@ -191,6 +192,7 @@ export class ReferenceWrites { private current: IrFunction | null = null; private currentBoxed = new Set(); private currentLocals = new Set(); + private currentPrivateArrays: ReadonlySet = new Set(); private constructors: ReadonlySet = new Set(); private inConstructor = false; @@ -278,6 +280,7 @@ export class ReferenceWrites { } const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null; const boxed = boxedLocals(fn); + const privates = privateArrays(fn, boxed); const sink: WriteSink = { add: (k) => { // Interned for queries; a callee's locals are private to it. @@ -311,7 +314,7 @@ export class ReferenceWrites { const stmt = (s: IrStmt): boolean => { if (thisId !== null && s.kind === "fieldSet" && isThisRef(s.obj, thisId)) { // A store into the fresh `this`: no outside borrow can reach it. - } else this.stmtWrites(s, sink, boxed); + } else this.stmtWrites(s, sink, boxed, privates); return everyStmtChild(s, expr, stmt); }; fn.body.every(stmt); @@ -574,7 +577,12 @@ export class ReferenceWrites { } /** Writes of one statement node itself. */ - private stmtWrites(s: IrStmt, sink: WriteSink, boxed: ReadonlySet): void { + private stmtWrites( + s: IrStmt, + sink: WriteSink, + boxed: ReadonlySet, + privates: ReadonlySet, + ): void { switch (s.kind) { case "assign": if (isRefCounted(s.value.type)) sink.add(this.localKey(s.localId, boxed)); @@ -593,7 +601,7 @@ export class ReferenceWrites { case "arraySetUndefined": case "arrayDelete": case "arraySetLength": - sink.add(ARRAY_KEY); + if (!privateReceiver(s.arr, privates)) sink.add(ARRAY_KEY); return; case "varDecl": case "exprStmt": @@ -701,6 +709,7 @@ export class ReferenceWrites { enter(fn: IrFunction): void { this.current = fn; this.currentBoxed = boxedLocals(fn); + this.currentPrivateArrays = privateArrays(fn, this.currentBoxed); this.currentLocals = new Set(fn.locals.map((local) => local.id)); this.inConstructor = this.constructors.has(fn.name); for (const p of fn.params) this.currentLocals.add(p.localId); @@ -780,7 +789,7 @@ export class ReferenceWrites { const known = this.stmtCache.get(s); if (known !== undefined) return known; const parts: Writes[] = []; - this.stmtWrites(s, this.querySink(parts), this.currentBoxed); + this.stmtWrites(s, this.querySink(parts), this.currentBoxed, this.currentPrivateArrays); everyStmtChild( s, (child) => (parts.push(this.expr(child)), true), @@ -915,6 +924,96 @@ function isThisRef(e: IrExpr, thisId: string): boolean { return false; } +/** A write whose receiver is a private array of the current function. */ +function privateReceiver(arr: IrExpr, privates: ReadonlySet): boolean { + return privates.size > 0 && arr.kind === "varRef" && privates.has(arr.localId); +} + +/** Array methods a private array may receive: they neither read an + * element out nor let the array escape. */ +const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]); + +/** Array locals whose storage the function allocated itself and that + * nothing else can reach before the function returns them: declared once + * from an array literal or `new Array(n)`, never rebound, unboxed, and + * otherwise only the receiver of element stores, length updates, `length`, + * `push` and presence tests, or the value of a `return`. No code reads an + * element of such an array or reaches its storage through another path, so + * its element stores cannot release a value that a borrow depends on: + * a borrow's own location stays unwritten, and the array is not one. The + * stores are private to the activation, like writes of its locals, and + * stay out of the function's summary (a caller's borrows predate the + * array). The literal map, filter and fused-pipeline helpers fill their + * results this way. */ +function privateArrays(fn: IrFunction, boxed: ReadonlySet): ReadonlySet { + if (fn.async || fn.generator) return new Set(); + const candidates = new Set(); + for (const local of fn.locals) + if (local.type.kind === "array" && !local.boxed && !boxed.has(local.id)) + candidates.add(local.id); + if (candidates.size === 0) return candidates; + const declarations = new Map(); + const rejected = new Set(); + // Permitted positions per varRef node (lowering may share one node + // between several positions, so each visit consumes one permission). + const permitted = new Map(); + const permit = (e: IrExpr): void => { + if (e.kind === "varRef" && candidates.has(e.localId)) + permitted.set(e, (permitted.get(e) ?? 0) + 1); + }; + const expr = (e: IrExpr): boolean => { + switch (e.kind) { + case "varRef": { + const left = permitted.get(e) ?? 0; + if (left === 0) rejected.add(e.localId); + else permitted.set(e, left - 1); + break; + } + case "assignExpr": + case "incDec": + rejected.add(e.localId); + break; + case "closure": + case "classRef": + for (const id of e.captures ?? []) rejected.add(id); + break; + case "arrIntrinsic": + if (PRIVATE_ARRAY_METHODS.has(e.method)) permit(e.receiver); + break; + case "arrayHas": + permit(e.arr); + break; + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "varDecl": + declarations.set(s.localId, (declarations.get(s.localId) ?? 0) + 1); + if (s.init === null || (s.init.kind !== "arrayLit" && s.init.kind !== "arrayNewLen")) + rejected.add(s.localId); + break; + case "assign": + rejected.add(s.localId); + break; + case "arraySet": + case "arraySetUndefined": + case "arrayDelete": + case "arraySetLength": + permit(s.arr); + break; + case "return": + if (s.value !== null) permit(s.value); + break; + } + return everyStmtChild(s, expr, stmt); + }; + fn.body.every(stmt); + const out = new Set(); + for (const id of candidates) if (!rejected.has(id) && declarations.get(id) === 1) out.add(id); + return out; +} + /** Locals living in boxes: declared boxed or captured from an enclosing * function. */ function boxedLocals(fn: IrFunction): Set { diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index d0593313f..2f69dd5ec 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; @@ -324,7 +321,11 @@ export class LlWalkers { ); } const byName = new Map(shape.fields.map((f) => [f.name, f])); - const emitFields = order.map((n) => byName.get(n)).filter((f) => f !== undefined); + // Function-valued properties are omitted, as JSON.stringify omits them. + const emitFields = order + .map((n) => byName.get(n)) + .filter((f) => f !== undefined) + .filter((f) => f.type.kind !== "func"); const droppable = emitFields.some((f) => undefinedArmTag(f.type, this.host.unionsById) >= 0) || !!shape.indexValue; @@ -451,7 +452,8 @@ export class LlWalkers { order.forEach((fieldName, i) => { B.startBlock(labels[i]!); const f = byName.get(fieldName)!; - if (fieldName.startsWith("%")) { + // Internal fields stay hidden; function-valued properties are omitted. + if (fieldName.startsWith("%") || f.type.kind === "func") { B.terminate("ret i1 false"); return; } @@ -491,10 +493,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": @@ -534,6 +539,15 @@ export class LlWalkers { `${ovfp} = getelementptr inbounds %${mangleRecordStruct(shape.id)}, ptr %v, i64 0, i32 ${shape.fields.length + this.host.nullableUnions.fieldBase(shape.id)}`, ); 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)`); @@ -573,23 +587,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/src/backend/llvm/wrapping-sums.ts b/packages/compiler/src/backend/llvm/wrapping-sums.ts index ab61da938..b00b61cbb 100644 --- a/packages/compiler/src/backend/llvm/wrapping-sums.ts +++ b/packages/compiler/src/backend/llvm/wrapping-sums.ts @@ -18,9 +18,10 @@ export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrEx /** The values an f64 expression can have when each one is an exact integer * (never -0) or NaN: proven ranges, safe integer literals, NaN-coded reads - * of integer typed-array elements, `charCodeAt`, and sums and differences of those whose - * every integer result is still exact in a double. */ -function integerOrNaNRange(host: LlvmEmitterContext, e: IrExpr): IntegerRange | null { + * of integer typed-array elements and of Int32[] elements, `charCodeAt`, + * and sums and differences of those whose every integer result is still + * exact in a double. */ +export function integerOrNaNRange(host: LlvmEmitterContext, e: IrExpr): IntegerRange | null { if (e.type.kind !== "f64") return null; const range = host.integerRanges.get(e); if (range) return range; @@ -35,6 +36,15 @@ function integerOrNaNRange(host: LlvmEmitterContext, e: IrExpr): IntegerRange | e.receiver.type.kind === "bytes" ) return ELEMENT_RANGES[e.receiver.type.elem] ?? null; + // ToNumber of an Int32[] element read: the stored int32, or NaN for a + // hole, a present undefined or a missing index. + if ( + e.kind === "arrIntrinsic" && + e.method === "getNumber" && + host.int32Elements !== null && + host.int32Elements(e.receiver) + ) + return ELEMENT_RANGES.i32!; // A UTF-16 code unit, or NaN past either end. if (e.kind === "strIntrinsic" && e.method === "charCodeAt") return { min: 0, max: 65535 }; if (e.kind === "bin" && (e.op === "+" || e.op === "-")) { diff --git a/packages/compiler/src/backend/mangle.ts b/packages/compiler/src/backend/mangle.ts index 1934cc6da..0bc428964 100644 --- a/packages/compiler/src/backend/mangle.ts +++ b/packages/compiler/src/backend/mangle.ts @@ -182,6 +182,12 @@ export function mangleAsyncAwaitCall(fnName: string): string { export function mangleAsyncAdoptCall(fnName: string): string { return `sc_ad_${sanitize(fnName)}`; } +/** A stackless coroutine body's `await f(...)` of a fiberless async f: the + * body run in place, returning its result with any throw left pending; the + * coroutine takes the await's turn itself. */ +export function mangleAsyncInlineAwaitCall(fnName: string): string { + return `sc_ai_${sanitize(fnName)}`; +} export function mangleTrampoline(fnName: string): string { return `sc_tr_${sanitize(fnName)}`; } diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 68465a792..039ecc209 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -93,6 +93,7 @@ export class RuntimePackError extends Error { constructor( message: string, readonly code: "missing" | "invalid" | "unsupported", + readonly hint?: string, ) { super(message); this.name = "RuntimePackError"; diff --git a/packages/compiler/src/backend/runtime-pack-native.test.ts b/packages/compiler/src/backend/runtime-pack-native.test.ts new file mode 100644 index 000000000..20214818f --- /dev/null +++ b/packages/compiler/src/backend/runtime-pack-native.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { nativeCodegenDiag } from "../diagnostics/diagnostic.js"; +import type { NativeLinkFeatures } from "./native-link-info.js"; +import { RuntimePackError } from "./runtime-pack-core.js"; +import { selectNativeRuntimePack } from "./runtime-pack-native.js"; +import { WASM32_WASI_TARGET } from "./targets.js"; + +const dirs: string[] = []; +afterEach(async () => + Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))), +); + +test("a missing cross-target runtime pack names its package in the diagnostic", async () => { + const root = await mkdtemp(join(tmpdir(), "scriptc-runtime-pack-native-")); + dirs.push(root); + let error: unknown; + try { + selectNativeRuntimePack( + join(root, "runtime-wasm32-wasi"), + WASM32_WASI_TARGET, + "0.3.5", + {} as NativeLinkFeatures, + "release", + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(RuntimePackError); + const pack = error as RuntimePackError; + expect(pack.code).toBe("missing"); + const diagnostic = nativeCodegenDiag("SC3003", pack.message, "main.ts", pack.hint); + expect(diagnostic.message).toBe( + "runtime pack for wasm32-wasi is not installed; install @scriptc/runtime-wasm32-wasi@0.3.5 in your project or set SCRIPTC_RUNTIME_PACK to its directory", + ); + expect(diagnostic.hint).toBe( + "scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project", + ); +}); + +test("a missing native helper keeps the reinstall hint", () => { + expect(nativeCodegenDiag("SC3003", "helper missing", "main.ts").hint).toBe( + "reinstall scriptc with optional dependencies enabled for this host", + ); +}); diff --git a/packages/compiler/src/backend/runtime-pack-native.ts b/packages/compiler/src/backend/runtime-pack-native.ts index cbea1ec67..bd38e2ef0 100644 --- a/packages/compiler/src/backend/runtime-pack-native.ts +++ b/packages/compiler/src/backend/runtime-pack-native.ts @@ -53,6 +53,7 @@ export function selectNativeRuntimePack( throw new RuntimePackError( `runtime pack for ${target.name} is not installed; install ${target.runtimePackPackage}@${compilerVersion} in your project or set SCRIPTC_RUNTIME_PACK to its directory`, "missing", + "scriptc includes the runtime pack for its own platform only; other targets need their runtime pack package in the project", ); } const packageText = readFileSync(join(root, "package.json"), "utf8"); diff --git a/packages/compiler/src/backend/target-diagnostics.ts b/packages/compiler/src/backend/target-diagnostics.ts index 65d954fb5..365d5e913 100644 --- a/packages/compiler/src/backend/target-diagnostics.ts +++ b/packages/compiler/src/backend/target-diagnostics.ts @@ -129,6 +129,7 @@ const WASI_UNAVAILABLE_HOST_FACTS: ReadonlyMap = new Map([ ["os.loadavg", "system load averages (WASI Preview 1 has no load API)"], ["os.uptime", "system uptime (WASI Preview 1 has no boot-time API)"], ["os.getPriority", "process scheduling priority (WASI Preview 1 has no priority API)"], + ["os.setPriority", "process scheduling priority (WASI Preview 1 has no priority API)"], ["process.memoryUsageTake", "process memory statistics (WASI Preview 1 has no memory API)"], ["process.rss", "process memory statistics (WASI Preview 1 has no memory API)"], ["process.ppid", "the parent process id (WASI Preview 1 has no process API)"], diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index 21fb9f097..3c25c9b1b 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -11,6 +11,9 @@ * proven by the same integer range analysis the int32 storage uses, * assuming every Int32 slot holds an int32 (sound by induction: every * write is checked under that assumption). + * - Int32[]: every element stored into an array that can reach the slot is + * an int32 (contracts/int32-elements.ts), so present element reads + * through slots only such arrays reach are int32s. * - Acyclic: the class or object type cannot lie on a reference cycle, by * the cycle analysis that decides tracing (backend/cycle-analysis.ts), or * the diagnostic shows the cycle. @@ -57,6 +60,7 @@ import { type CycleStep, } from "../backend/cycle-analysis.js"; import { SourceLocations } from "../backend/source-locations.js"; +import { Int32ElementContracts } from "./int32-elements.js"; import { contractLocKey, type ContractAnnotations, @@ -251,7 +255,9 @@ class ContractChecker { this.checkProgramLifetime(); this.checkNoThrow(); this.checkAcyclic(); - const int32 = new Int32Contracts(this); + const elements = new Int32ElementContracts(this); + elements.analyze(); + const int32 = new Int32Contracts(this, elements); int32.run(); const immutable = new ImmutableContracts(this); immutable.run(); @@ -604,19 +610,24 @@ class Int32Contracts { private readonly returns = new Map(); // fn private readonly locals = new Map(); // fn\0localId or \0globalId - constructor(private readonly c: ContractChecker) { + constructor( + private readonly c: ContractChecker, + private readonly elements: Int32ElementContracts, + ) { this.slots = new Int32Slots(c.classes); } run(): void { if (!this.bind()) return; - if (this.fields.size + this.params.size + this.returns.size + this.locals.size === 0) return; + const scalars = this.fields.size + this.params.size + this.returns.size + this.locals.size; + if (scalars === 0 && !this.elements.active) return; this.structural(); this.writes(); this.c.record("int32Fields", [...this.fields.keys()]); this.c.record("int32Params", [...this.params.keys()]); this.c.record("int32Returns", [...this.returns.keys()]); this.c.record("int32Locals", [...this.locals.keys()]); + this.c.record("int32Elements", [...this.elements.int32Slots]); } private refuse(note: ContractNote, why: string, hint: string): void { @@ -632,7 +643,7 @@ class Int32Contracts { const c = this.c; for (const [key, list] of c.notes.functions) for (const note of list) { - if (note.marker !== "Int32") continue; + if (note.marker !== "Int32" || note.elements === true) continue; for (const fn of c.functionsAt(key)) { if (fn.async === true || fn.generator !== undefined) { this.refuse( @@ -654,7 +665,7 @@ class Int32Contracts { } } for (const [key, note] of c.notes.bindings) { - if (note.marker !== "Int32") continue; + if (note.marker !== "Int32" || note.elements === true) continue; for (const b of c.bindingsOf(key)) { const type = b.fn ? b.fn.locals.find((l) => l.id === b.id)?.type @@ -677,7 +688,7 @@ class Int32Contracts { } } for (const [key, note] of c.notes.fields) { - if (note.marker !== "Int32") continue; + if (note.marker !== "Int32" || note.elements === true) continue; const cut = key.lastIndexOf("\0"); const field = key.slice(cut + 1); for (const cls of c.classesAt(key.slice(0, cut))) { @@ -821,6 +832,7 @@ class Int32Contracts { this.locals.has(`${fnName}\0${localId}`) || this.locals.has(`\0${localId}`) ? INT32_RANGE : null, + elements: (arr) => this.elements.readsInt32(fnName, arr), }; } @@ -841,9 +853,14 @@ class Int32Contracts { : "make the int32 conversion explicit with '| 0', so Node computes the same value", ); }; + const elementFunctions = this.elements.functionsWithWrites(); + const elementRanges = new Map(); for (const fn of c.mod.functions) { const numeric = withInitializerBindings(fn, bindings.get(fn.name) ?? []); const ranges = analyzeIntegerRanges(numeric, this.int32Facts(fn.name)); + if (elementFunctions.has(fn.name)) elementRanges.set(fn.name, ranges); + // Scalar Int32 slots accept exactly what the backend's int32 storage + // proves (int32-slots.ts stores pinned slots from proven writes). const int32 = (e: IrExpr): boolean => e.type.kind === "f64" && withinInt32(ranges.get(e)); const localNote = (id: string): ContractNote | undefined => this.locals.get(`${fn.name}\0${id}`) ?? this.locals.get(`\0${id}`); @@ -930,6 +947,11 @@ class Int32Contracts { }, }); } + this.elements.checkWrites((fn, value) => { + const proven = (e: IrExpr): boolean => + e.type.kind === "f64" && withinInt32(elementRanges.get(fn.name)?.get(e)); + return proven(value) || this.elements.provenElement(fn, value, proven); + }); } } diff --git a/packages/compiler/src/contracts/int32-elements.ts b/packages/compiler/src/contracts/int32-elements.ts new file mode 100644 index 000000000..8284d40ab --- /dev/null +++ b/packages/compiler/src/contracts/int32-elements.ts @@ -0,0 +1,1271 @@ +/* Int32[] contracts: every present element of an array that can reach an + * `Int32[]` slot is an int32. + * + * The contract is about arrays, not slots: whoever writes an array that is + * (or later becomes) the value of an `Int32[]` slot must store int32s, + * through any reference to it. The check therefore works on the flow of + * array references through the whole program. Nodes are the places an + * array can live, keyed by IR names like int32-slots.ts keys: + * + * l:\0 locals and parameters (captured bindings link both ways) + * g: module globals + * r: function results + * f: class field families + * s:\0 record fields (`*`: the index signature's entries) + * e:/mk:/mv:/se: elements, map keys and values, set + * elements of every container of that type + * + * plus one node per allocation (an array literal, a length-only array) and + * one per opaque origin (a caller scriptc cannot see, dynamic code, a + * library result). Every IR store adds an edge from the stored value's + * nodes to the slot; an operation that copies elements from one array into + * another (spreads, `concat`, `slice`, splice items) links their nodes as + * if both were the same array, which over-approximates both directions. + * + * - The allocations that can reach an annotated slot are constrained: every + * element store into an array from one of them, wherever it happens, must + * be an int32 by construction (the same integer range proofs as Int32, + * with the elements of other Int32[] arrays assumed int32: sound by + * induction). An opaque origin that can reach an annotated slot refuses + * the contract (SC7003): its arrays were filled by code scriptc does not + * check. + * - A slot that can hold a constrained array is checked; such a value may + * not flow anywhere the IR does not show (dynamic code, function values, + * library calls that are not read-only, throws, promises) (SC7003). + * - A slot that no unconstrained allocation and no opaque origin can reach + * holds only constrained arrays: an element read through it that finds a + * value reads an int32. Those slots are recorded as + * IrContracts.int32Elements. + * + * Holes, present undefined, out-of-range reads and `length` changes keep + * their JavaScript behavior: the contract constrains the values stored, not + * the array's shape. */ +import type { ContractNote, ContractAnnotations } from "../frontend/contract-types.js"; +import type { + IrClassDef, + IrExpr, + IrFunction, + IrModule, + IrRecordShape, + IrStmt, + IrType, + SrcLoc, +} from "../ir/ir.js"; +import { typeKey } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { functionCallFacts, int32ElementSlot, Int32Slots } from "../ir/int32-slots.js"; + +/** What the Int32[] check needs from the contract checker. */ +export interface ElementContractHost { + readonly mod: IrModule; + readonly notes: ContractAnnotations; + readonly functions: ReadonlyMap; + readonly classes: ReadonlyMap; + report(code: `SC${number}`, loc: SrcLoc, message: string, hint: string): void; + text(loc: SrcLoc, max?: number): string; + where(loc: SrcLoc | null): string; + displayName(fnName: string): string; + bindingsOf(key: string): { fn: IrFunction | null; id: string }[]; + functionsAt(key: string): readonly IrFunction[]; + classesAt(key: string): readonly IrClassDef[]; +} + +/** An element store into a number array, checked once ranges are known. */ +interface ElementWrite { + fn: IrFunction; + /** The nodes of the array written. */ + target: string[]; + /** The stored number. */ + value: IrExpr; + /** Source text of the written array, for messages. */ + arrayText: string; + /** An element of an array literal. */ + literal?: true; + /** The written array expression (element stores and methods). */ + arr?: IrExpr; +} + +/** A value leaving the IR's sight, with the nodes it can hold. */ +interface Escape { + nodes: string[]; + loc: SrcLoc; + shown: string; + what: string; +} + +/** Array methods that store their numeric arguments as elements, by the + * argument positions they store. */ +const ELEMENT_ARGS: ReadonlyMap = new Map([ + ["push", "all"], + ["unshift", "all"], + ["fill", 0], + ["with", 1], +]); + +const NO_NODES: ReadonlySet = new Set(); + +/** Array methods whose result is a new array holding copies of the + * receiver's (and array arguments') elements. */ +const COPIES = new Set([ + "slice", + "splice", + "concatSpread", + "toSpliced", + "toReversed", + "toSortedPrimitive", + "with", + "withUndefined", +]); + +/** Array methods that copy the elements of an array argument into the + * receiver. */ +const COPY_IN = new Set(["pushSpread", "unshiftSpread", "spliceInsert"]); + +/** Library calls that only read array arguments. */ +const READ_ONLY_LIB = /^(console\.|inspect\.|json\.stringify|math\.|insp\.)/; + +/** Library calls that always throw (the value they stand for never exists). */ +const THROWING_LIB = /^error\.\w*Throw$/; + +/** Expression kinds whose array result is one of their operands. */ +const PASS_THROUGH = new Set([ + "upcast", + "downcast", + "unionWrap", + "unionRetype", + "unionNarrow", + "ternary", + "logical", + "nullish", + "orDefault", + "seqExpr", + "chainRecv", + "optChain", + "assignExpr", +]); + +/** Kinds that only inspect an array operand (identity, truthiness, text). */ +const OBSERVE = new Set([ + "toBool", + "strEq", + "unionEq", + "unionFuncEq", + "unionIsTag", + "unionDisc", + "instanceOf", + "instanceOfValue", + "dynScalarEq", + "fieldAbsent", + "recordHas", + "recordOvfHas", + "recordNullProto", + "arrayHas", + "arrayState", + "jsonStringify", + "toString", + "strConcat", + "bin", + "unary", + "unionKeyGet", + "recordKeyGet", + "recordOvfKeys", + "fieldGet", + "recordGet", + "arrayGet", +]); + +export class Int32ElementContracts { + /** Annotated slots and their notes. */ + private readonly annotated = new Map(); + private readonly succ = new Map>(); + private readonly pred = new Map>(); + /** Element copies: the nodes of the arrays whose elements are copied, + * and the nodes of the array they are copied into. A copy holds none of + * its source's references, but the source of a copy into a constrained + * array must be constrained too. */ + private readonly copies: { from: string[]; to: string[] }[] = []; + /** Allocation nodes (`a:`) and opaque origins (`o:`). */ + private readonly allocs = new Set(); + private readonly opaque = new Map(); + private readonly writes: ElementWrite[] = []; + private readonly escapes: Escape[] = []; + private readonly slots: Int32Slots; + private nextNode = 0; + /** Nodes that may hold a constrained array, with the note it reaches. */ + private readonly constrainedReach = new Map(); + /** Nodes that may hold an unconstrained or opaque array. */ + private readonly unconstrainedReach = new Set(); + private readonly containedMemo = new Map(); + private readonly unions: ReadonlyMap; + private readonly records: ReadonlyMap; + private readonly methodImpls = new Map(); + private readonly subclasses = new Map(); + /** Functions whose callers are not all visible. */ + private valueEscaped: ReadonlySet = new Set(); + private runtimeVirtual: (name: string) => boolean = () => false; + + constructor(private readonly c: ElementContractHost) { + this.slots = new Int32Slots(c.classes); + this.unions = new Map((c.mod.unions ?? []).map((u) => [u.id, u.arms])); + this.records = new Map((c.mod.records ?? []).map((r) => [r.id, r])); + for (const cls of c.classes.values()) { + for (const m of cls.methods ?? []) push(this.methodImpls, m, `%${cls.name}.${m}`); + if (cls.base) push(this.subclasses, cls.base, cls.name); + } + } + + /** Whether any Int32[] annotation exists. */ + get active(): boolean { + return this.annotated.size > 0; + } + + /** Bind the annotations, build the flow graph and compute which slots + * hold constrained arrays. Reports structural refusals. */ + analyze(): void { + this.bind(); + if (this.annotated.size === 0) return; + const calls = functionCallFacts(this.c.mod, this.c.classes, (name) => this.subtree(name)); + this.valueEscaped = calls.valueEscaped; + this.runtimeVirtual = calls.runtimeVirtual; + for (const fn of this.c.mod.functions) this.scanFunction(fn); + this.solve(); + } + + /** Whether reads of `arr` inside `fnName` find only int32 elements. */ + readsInt32(fnName: string, arr: IrExpr): boolean { + const key = int32ElementSlot(fnName, arr, (cls, field) => this.slots.family(cls, field)); + return key !== null && this.int32Slots.has(key); + } + + /** Slots recorded as IrContracts.int32Elements. */ + readonly int32Slots = new Set(); + + private readonly readers = new Map(); + private readonly localWrites = new Map>(); + + /** Whether the number `e` is an element an Int32[] read found: the number + * arm of an ordinary `xs[i]` read (the lowering's element-read helper + * returns `number | undefined`, and the read narrows it, throwing for + * undefined), directly or through a local holding such reads. `int32` + * answers the range proof for any other number. */ + provenElement(fn: IrFunction, e: IrExpr, int32: (e: IrExpr) => boolean): boolean { + if (this.int32Slots.size === 0) return false; + const value = (x: IrExpr, depth: number): boolean => { + if (int32(x)) return true; + if (depth > 8) return false; + if (x.kind === "ternary") return arm(x.then, depth + 1) && arm(x.else_, depth + 1); + if (x.kind === "unionNarrow") return holder(x.value, depth + 1); + return false; + }; + const arm = (x: IrExpr, depth: number): boolean => + (x.kind === "libCall" && THROWING_LIB.test(x.fn)) || value(x, depth); + const holder = (x: IrExpr, depth: number): boolean => { + if (depth > 8) return false; + switch (x.kind) { + case "call": { + const k = this.elementReader(x.callee); + const arr = k === null ? undefined : x.args[k]; + return arr !== undefined && this.readsInt32(fn.name, arr); + } + case "varRef": { + const writes = this.writesOf(fn).get(x.localId); + return ( + writes !== undefined && + writes !== null && + writes.length > 0 && + writes.every((w) => holder(w, depth + 1)) + ); + } + case "unionWrap": + return x.value.kind === "unitLit" || value(x.value, depth + 1); + case "unitLit": + return true; + case "ternary": + return holder(x.then, depth + 1) && holder(x.else_, depth + 1); + default: + return false; + } + }; + return value(e, 0); + } + + /** Whether `value` is an element of the written array itself, or of a + * copy of it the function took (the lowered `sort` moves elements + * through such a snapshot): storing it adds no new value to the array, + * whatever its contents. Both must be locals the function never + * reassigns. */ + private movedWithin(fn: IrFunction, arr: IrExpr, value: IrExpr): boolean { + if (arr.kind !== "varRef" || value.kind !== "arrayGet" || value.arr.kind !== "varRef") + return false; + const writes = this.writesOf(fn); + const target = arr.localId; + const source = value.arr.localId; + const fixed = (id: string): boolean => { + const list = writes.get(id); + if (list === null || !fn.locals.some((l) => l.id === id)) return false; + const isParam = fn.params.some((p) => p.localId === id); + return isParam ? list === undefined : list !== undefined && list.length === 1; + }; + if (!fixed(target)) return false; + if (source === target) return true; + if (!fixed(source)) return false; + const init = writes.get(source)?.[0]; + return ( + init !== undefined && + init.kind === "arrIntrinsic" && + init.method === "slice" && + init.receiver.kind === "varRef" && + init.receiver.localId === target && + this.onlyMoves(fn, source, target, init) + ); + } + + /** Whether the function uses the snapshot local `source` only to read, + * reorder and move its own elements (or the target's): then every value + * it holds is one of the target's elements. */ + private onlyMoves(fn: IrFunction, source: string, target: string, init: IrExpr): boolean { + const allowed = new Set(); + const isRef = (e: IrExpr, id: string): boolean => e.kind === "varRef" && e.localId === id; + const moved = (v: IrExpr): boolean => + v.kind === "arrayGet" && (isRef(v.arr, source) || isRef(v.arr, target)); + let ok = true; + const expr = (e: IrExpr): boolean => { + if ( + (e.kind === "arrayGet" || e.kind === "arrayState" || e.kind === "arrayHas") && + isRef(e.arr, source) + ) + allowed.add(e.arr); + else if ( + e.kind === "arrIntrinsic" && + isRef(e.receiver, source) && + ["length", "nextPresent", "sortValues", "sortDense", "reverse", "copyWithin"].includes( + e.method, + ) + ) + allowed.add(e.receiver); + else if (e.kind === "varRef" && e.localId === source && !allowed.has(e)) ok = false; + return ok && everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => { + if ( + (st.kind === "arraySet" && isRef(st.arr, source) && moved(st.value)) || + ((st.kind === "arraySetUndefined" || st.kind === "arraySetLength") && isRef(st.arr, source)) + ) + allowed.add(st.arr); + else if (st.kind === "varDecl" && st.localId === source && st.init !== init) ok = false; + return ok && everyStmtChild(st, expr, stmt); + }; + for (const st of fn.body) if (!stmt(st)) break; + return ok; + } + + /** The parameter whose element every return of `fnName` reads (or + * undefined): the lowering's `xs[i]` helper. Null for other functions. */ + private elementReader(fnName: string): number | null { + const known = this.readers.get(fnName); + if (known !== undefined) return known; + this.readers.set(fnName, null); + const fn = this.c.functions.get(fnName); + if (!fn || fn.captures?.length || fn.async || fn.generator) return null; + let result: number | null = null; + for (let k = 0; k < fn.params.length && result === null; k++) { + const id = fn.params[k]!.localId; + if (this.writesOf(fn).get(id) !== undefined) continue; + const element = (x: IrExpr): boolean => { + switch (x.kind) { + case "ternary": + return element(x.then) && element(x.else_); + case "arrayGet": + return x.arr.kind === "varRef" && x.arr.localId === id; + case "unitLit": + return true; + case "unionWrap": + case "upcast": + return element(x.value); + default: + return false; + } + }; + let returns = 0; + let ok = true; + const visit = (st: IrStmt): boolean => { + if (st.kind === "return") { + returns++; + if (!st.value || !element(st.value)) ok = false; + } + return everyStmtChild(st, () => true, visit); + }; + for (const st of fn.body) visit(st); + if (ok && returns > 0) result = k; + } + this.readers.set(fnName, result); + return result; + } + + /** Every value written to each local of `fn` (null: a write whose value + * is not an expression, such as `++` or a for-of binding). */ + private writesOf(fn: IrFunction): Map { + const known = this.localWrites.get(fn.name); + if (known) return known; + const out = new Map(); + const add = (id: string, value: IrExpr | null): void => { + const list = out.get(id); + if (list === null) return; + if (value === null) out.set(id, null); + else if (list) list.push(value); + else out.set(id, [value]); + }; + for (const l of fn.locals) if (l.boxed) out.set(l.id, null); + for (const cap of [...(fn.captures ?? []), ...(fn.classCaptures ?? [])]) + out.set(cap.localId, null); + const expr = (e: IrExpr): boolean => { + if (e.kind === "assignExpr") add(e.localId, e.value); + else if (e.kind === "incDec") add(e.localId, null); + return everyExprChild(e, expr, stmt); + }; + const stmt = (st: IrStmt): boolean => { + if (st.kind === "varDecl" && st.init) add(st.localId, st.init); + else if (st.kind === "assign") add(st.localId, st.value); + else if (st.kind === "forOf") add(st.localId, null); + else if (st.kind === "tryCatch" && st.catchLocalId !== null) add(st.catchLocalId, null); + return everyStmtChild(st, expr, stmt); + }; + for (const st of fn.body) stmt(st); + this.localWrites.set(fn.name, out); + return out; + } + + /** Check every element store into a constrained array, given an int32 + * test for the stored value inside its function. */ + checkWrites(int32: (fn: IrFunction, value: IrExpr) => boolean): void { + for (const w of this.writes) { + const note = this.reachingNote(w.target); + if (note === undefined || int32(w.fn, w.value)) continue; + if (w.arr !== undefined && this.movedWithin(w.fn, w.arr, w.value)) continue; + const at = this.displayed(w.fn, w.value); + const shown = this.c.text(at); + // A store inside a lowered array method (`map`, `from`) carries the + // method call's location for both the array and the value. + const builtIn = shown !== "" && shown === w.arrayText; + const what = w.literal + ? `the array literal element${shown ? ` '${shown}'` : ""} is not provably an int32` + : builtIn + ? `the elements '${shown}' stores are not provably int32s` + : `the element stored into ${w.arrayText ? `'${w.arrayText}'` : "an array"} is not provably an int32${shown ? `: '${shown}'` : ""}`; + this.c.report( + "SC7002", + at, + `${what} (the array can reach '${note.name}', declared Int32[])`, + builtIn + ? "callback results are not followed; build the array with a loop that stores 'value | 0'" + : shown + ? `make the int32 conversion explicit, for example '${orZero(shown)}', so Node stores the same value` + : "make the int32 conversion explicit with '| 0', so Node stores the same value", + ); + } + } + + /** The source location that shows a stored value. An element read + * stored as is (`a[i] = b[j]`) lowers to a narrowing of a temporary that + * carries the whole statement's location; show the read instead. */ + private displayed(fn: IrFunction, value: IrExpr): SrcLoc { + let e = value; + for (let depth = 0; depth < 4; depth++) { + if (e.kind === "ternary") e = e.then; + else if (e.kind === "unionNarrow") e = e.value; + else if (e.kind === "varRef") { + const writes = this.writesOf(fn).get(e.localId); + if (!writes || writes.length !== 1) break; + e = writes[0]!; + } else break; + if (e.loc.start > value.loc.start || e.loc.end < value.loc.end) return e.loc; + } + return value.loc; + } + + /** Functions with element stores to check. */ + functionsWithWrites(): Set { + const out = new Set(); + for (const w of this.writes) if (this.reachingNote(w.target) !== undefined) out.add(w.fn.name); + return out; + } + + /* ── binding ── */ + + private bind(): void { + this.bindAnnotations(); + // Messages name the first annotation an array reaches: prefer module + // bindings and fields over parameters and results. + const rank = (slot: string): number => + slot.startsWith("g:") ? 0 : slot.startsWith("f:") ? 1 : slot.startsWith("l:") ? 2 : 3; + const ordered = [...this.annotated].sort((a, b) => rank(a[0]) - rank(b[0])); + this.annotated.clear(); + for (const [slot, note] of ordered) this.annotated.set(slot, note); + } + + private bindAnnotations(): void { + const c = this.c; + const refuse = (note: ContractNote, why: string, hint: string): void => + c.report( + "SC7003", + note.loc, + `the Int32[] contract on '${note.name}' cannot be checked: ${why}`, + hint, + ); + const numberArray = (t: IrType | undefined): boolean => + t !== undefined && t.kind === "array" && t.elem.kind === "f64"; + for (const [key, list] of c.notes.functions) + for (const note of list) { + if (note.marker !== "Int32" || note.elements !== true) continue; + for (const fn of c.functionsAt(key)) { + if (fn.async === true || fn.generator !== undefined) { + refuse( + note, + "async functions and generators return promises or iterators", + "annotate the awaited value where it is stored instead", + ); + continue; + } + if (!numberArray(fn.returnType)) { + refuse(note, "the result is not a plain number array", "declare it as Int32[]"); + continue; + } + this.annotated.set(`r:${fn.name}`, note); + } + } + for (const [key, note] of c.notes.bindings) { + if (note.marker !== "Int32" || note.elements !== true) continue; + for (const b of c.bindingsOf(key)) { + const type = b.fn + ? b.fn.locals.find((l) => l.id === b.id)?.type + : c.mod.globals?.find((g) => g.id === b.id)?.type; + if (!numberArray(type)) { + refuse( + note, + "the binding is not a plain number array (optional and union types are not checked)", + "declare it as a required Int32[]", + ); + continue; + } + this.annotated.set(b.fn ? `l:${b.fn.name}\0${b.id}` : `g:${b.id}`, note); + } + } + for (const [key, note] of c.notes.fields) { + if (note.marker !== "Int32" || note.elements !== true) continue; + const cut = key.lastIndexOf("\0"); + const field = key.slice(cut + 1); + for (const cls of c.classesAt(key.slice(0, cut))) { + const def = cls.fields.find((f) => f.name === field); + const family = this.slots.family(cls.name, field); + if (!def || family === null) { + refuse( + note, + `the compiled class has no field '${field}'`, + "annotate an ordinary instance field", + ); + continue; + } + if (!numberArray(def.type)) { + refuse( + note, + "the field is not a plain number array", + "declare the field as a required Int32[]", + ); + continue; + } + this.annotated.set(`f:${family}`, note); + } + } + } + + private subtree(name: string): string[] { + const out: string[] = []; + const stack = [name]; + while (stack.length) { + const next = stack.pop()!; + out.push(next); + for (const child of this.subclasses.get(next) ?? []) stack.push(child); + } + return out; + } + + /* ── types ── */ + + /** A number array, or a union with a number-array arm: values whose + * identity the graph follows. */ + private direct(t: IrType): boolean { + if (t.kind === "array") return t.elem.kind === "f64"; + if (t.kind === "union") + return (this.unions.get(t.unionId) ?? []).some( + (a) => a.kind === "array" && a.elem.kind === "f64", + ); + return false; + } + + /** The container slots holding number arrays that a value of type `t` + * can reach through fields and elements. */ + private contained(t: IrType): string[] { + const key = typeKey(t); + const memo = this.containedMemo.get(key); + if (memo) return memo; + const out = new Set(); + this.containedMemo.set(key, []); + const add = (slot: string, inner: IrType): void => { + if (this.direct(inner)) out.add(slot); + for (const s of this.contained(inner)) out.add(s); + }; + switch (t.kind) { + case "array": + add(`e:${key}`, t.elem); + break; + case "set": + add(`se:${key}`, t.elem); + break; + case "map": + add(`mk:${key}`, t.key); + add(`mv:${key}`, t.value); + break; + case "promise": + add(`p:${key}`, t.inner); + break; + case "union": + for (const arm of this.unions.get(t.unionId) ?? []) + for (const s of this.contained(arm)) out.add(s); + break; + case "record": { + const shape = this.records.get(t.shapeId); + for (const f of shape?.fields ?? []) add(`s:${t.shapeId}\0${f.name}`, f.type); + if (shape?.indexValue) add(`s:${t.shapeId}\0*`, shape.indexValue); + break; + } + case "object": + for (const name of this.subtree(t.className)) { + const cls = this.c.classes.get(name); + for (const f of cls?.fields ?? []) { + const family = this.slots.family(name, f.name); + if (family !== null) add(`f:${family}`, f.type); + } + } + break; + default: + break; + } + const list = [...out]; + this.containedMemo.set(key, list); + return list; + } + + private holds(t: IrType): boolean { + return this.direct(t) || this.contained(t).length > 0; + } + + /* ── graph ── */ + + private edge(from: string, to: string): void { + if (from === to) return; + let s = this.succ.get(from); + if (!s) this.succ.set(from, (s = new Set())); + s.add(to); + let p = this.pred.get(to); + if (!p) this.pred.set(to, (p = new Set())); + p.add(from); + } + + private copy(from: readonly string[], to: readonly string[]): void { + if (from.length > 0 && to.length > 0) this.copies.push({ from: [...from], to: [...to] }); + } + + private newOpaque(loc: SrcLoc, why: string): string { + const node = `o:${this.nextNode++}`; + this.opaque.set(node, { loc, why }); + return node; + } + + /** An origin the IR does not show produces a value of type `t`. */ + private opaqueValue(t: IrType, loc: SrcLoc, why: string): string[] { + const node = this.newOpaque(loc, why); + for (const slot of this.contained(t)) this.edge(node, slot); + return this.direct(t) ? [node] : []; + } + + private store(sources: readonly string[], slot: string): void { + for (const s of sources) this.edge(s, slot); + } + + private localSlot(fn: IrFunction, id: string): string { + return id.startsWith("%g.") ? `g:${id}` : `l:${fn.name}\0${id}`; + } + + private scanFunction(fn: IrFunction): void { + const c = this.c; + const chains = new Map(); + const memo = new Map(); + // Optional-chain receivers, by chain id, before any read of one. + const findChains = (e: IrExpr): boolean => { + if (e.kind === "optChain") chains.set(e.id, e.receiver); + return everyExprChild(e, findChains, findChainsIn); + }; + const findChainsIn = (st: IrStmt): boolean => everyStmtChild(st, findChains, findChainsIn); + for (const st of fn.body) findChainsIn(st); + // Parameters whose callers are not all visible hold opaque arrays. + if (this.valueEscaped.has(fn.name) || this.runtimeVirtual(fn.name)) + for (const p of fn.params) + if (this.holds(p.type)) { + const why = this.valueEscaped.has(fn.name) + ? `the callers of '${c.displayName(fn.name)}', which is used as a value (its callers are not all visible)` + : `the runtime, which calls '${c.displayName(fn.name)}'`; + for (const node of this.opaqueValue(p.type, fn.loc, why)) + this.edge(node, this.localSlot(fn, p.localId)); + } + + /** The nodes an expression's array value can come from. */ + const sources = (e: IrExpr): string[] => { + if (!this.direct(e.type)) return []; + const known = memo.get(e); + if (known) return known; + memo.set(e, []); + const out = sourcesOf(e); + memo.set(e, out); + return out; + }; + const elementSlot = (container: IrExpr): string[] => { + const t = container.type; + if (t.kind === "array" && this.direct(t.elem)) return [`e:${typeKey(t)}`]; + return []; + }; + const sourcesOf = (e: IrExpr): string[] => { + switch (e.kind) { + case "varRef": + return [this.localSlot(fn, e.localId)]; + case "fieldGet": { + const family = this.slots.family(e.className, e.field); + return family === null + ? this.opaqueValue(e.type, e.loc, "a field scriptc cannot follow") + : [`f:${family}`]; + } + case "recordGet": + return [`s:${e.shapeId}\0${e.field}`]; + case "recordKeyGet": { + const t = e.obj.type; + if (t.kind !== "record") return this.opaqueValue(e.type, e.loc, "a dynamic property"); + const shape = this.records.get(t.shapeId); + const out = [`s:${t.shapeId}\0*`]; + for (const f of shape?.fields ?? []) + if (this.direct(f.type)) out.push(`s:${t.shapeId}\0${f.name}`); + return out; + } + case "arrayGet": + return elementSlot(e.arr); + case "mapIntrinsic": + if (e.method === "get" || e.method === "iterValue") + return [`mv:${typeKey(e.receiver.type)}`]; + if (e.method === "iterKey") return [`mk:${typeKey(e.receiver.type)}`]; + // Any other result is a stored key or value, or a fresh array. + return [ + `mv:${typeKey(e.receiver.type)}`, + `mk:${typeKey(e.receiver.type)}`, + ...this.opaqueValue(e.type, e.loc, `Map.${e.method}`), + ]; + case "setIntrinsic": + if (e.method === "iterKey") return [`se:${typeKey(e.receiver.type)}`]; + return [ + `se:${typeKey(e.receiver.type)}`, + ...this.opaqueValue(e.type, e.loc, `Set.${e.method}`), + ]; + case "call": + return c.functions.has(e.callee) + ? [`r:${e.callee}`] + : this.opaqueValue(e.type, e.loc, `a call to '${e.callee}'`); + case "virtualCall": { + const impls = this.methodImpls.get(e.method) ?? []; + return impls.length > 0 ? impls.map((impl) => `r:${impl}`) : unknown(e); + } + case "arrayLit": + case "arrayNewLen": + return [alloc()]; + case "arrIntrinsic": { + if (COPIES.has(e.method)) { + // A new array of copied elements. + const node = alloc(); + for (const part of [e.receiver, ...e.args]) + this.copy([...sources(part), ...elementSlot(part)], [node]); + return [node]; + } + if (e.method === "flatCopy" || e.method === "flatOne") { + // Copies (or flattens) the receiver into the supplied array, + // which it returns. + const into = e.args.flatMap((a) => sources(a)); + this.copy([...sources(e.receiver), ...elementSlot(e.receiver)], into); + return into; + } + // The receiver itself (fill, reverse, sort) or one of its elements. + return [...sources(e.receiver), ...elementSlot(e.receiver)]; + } + case "upcast": + case "downcast": + case "unionWrap": + case "unionRetype": + case "unionNarrow": + case "assignExpr": + return sources(e.value); + case "ternary": + return [...sources(e.then), ...sources(e.else_)]; + case "logical": + case "nullish": + case "orDefault": + return [...sources(e.left), ...sources(e.right)]; + case "seqExpr": + return sources(e.result); + case "optChain": + return sources(e.body); + case "chainRecv": { + const receiver = chains.get(e.id); + return receiver + ? sources(receiver) + : this.opaqueValue(e.type, e.loc, "an optional chain"); + } + case "unitLit": + return []; + default: + return unknown(e); + } + }; + const alloc = (): string => { + const node = `a:${this.nextNode++}`; + this.allocs.add(node); + return node; + }; + /** An operation scriptc does not follow: a fresh array of unknown + * contents, or any array its operands hold or reach. */ + const unknown = (e: IrExpr): string[] => { + const out = this.opaqueValue(e.type, e.loc, describe(e)); + everyExprChild( + e, + (child) => { + out.push(...sources(child), ...this.contained(child.type)); + return true; + }, + () => true, + ); + return out; + }; + + const escape = (value: IrExpr, at: SrcLoc, what: string): void => { + if (!this.holds(value.type)) return; + const nodes = [...sources(value), ...this.contained(value.type)]; + if (nodes.length > 0) this.escapes.push({ nodes, loc: at, shown: c.text(value.loc), what }); + }; + const write = (arr: IrExpr, value: IrExpr): void => { + this.writes.push({ fn, target: sources(arr), value, arrayText: c.text(arr.loc), arr }); + }; + const args = (callee: string, list: IrExpr[], offset: number, at: SrcLoc): void => { + const target = c.functions.get(callee); + list.forEach((arg, i) => { + if (!this.holds(arg.type)) return; + const param = target?.params[i + offset]; + if (!target || !param) { + escape(arg, at, `a call to '${c.displayName(callee)}'`); + return; + } + this.store(sources(arg), this.localSlot(target, param.localId)); + }); + }; + const isNumberArray = (t: IrType): boolean => t.kind === "array" && t.elem.kind === "f64"; + + const expr = (e: IrExpr): void => { + switch (e.kind) { + case "assignExpr": + this.store(sources(e.value), this.localSlot(fn, e.localId)); + break; + case "call": + args(e.callee, e.args, 0, e.loc); + break; + case "new": + args(`%${e.className}.constructor`, e.args, 1, e.loc); + break; + case "virtualCall": + for (const impl of this.methodImpls.get(e.method) ?? []) args(impl, e.args, 0, e.loc); + if (!this.methodImpls.has(e.method)) + for (const a of e.args) escape(a, e.loc, `the method '${e.method}'`); + break; + case "arrayLit": { + const node = sources(e)[0]; + const spreads = new Set(e.spreads ?? []); + e.elems.forEach((x, i) => { + if (spreads.has(i)) { + // Spread elements copy into the literal. + if (node !== undefined) this.copy(sources(x), [node]); + if (e.type.kind === "array" && this.direct(e.type.elem)) + this.store(elementSlot(x), `e:${typeKey(e.type)}`); + } else if (isNumberArray(e.type) && node !== undefined) + this.writes.push({ fn, target: [node], value: x, arrayText: "", literal: true }); + else if (e.type.kind === "array" && this.holds(x.type)) + this.store(sources(x), `e:${typeKey(e.type)}`); + }); + break; + } + case "arrIntrinsic": { + const r = e.receiver; + if (isNumberArray(r.type)) { + const positions = ELEMENT_ARGS.get(e.method); + if (positions !== undefined) + e.args.forEach((a, i) => { + if (a.type.kind === "f64" && (positions === "all" || positions === i)) + write(e.method === "with" ? e : r, a); + }); + // Elements copied in from array arguments (push(...src), + // splice items). + if (COPY_IN.has(e.method)) + for (const a of e.args) if (isNumberArray(a.type)) this.copy(sources(a), sources(r)); + } else if (r.type.kind === "array" && this.holds(r.type.elem)) { + const slot = `e:${typeKey(r.type)}`; + for (const a of e.args) { + if (this.direct(a.type)) this.store(sources(a), slot); + else if (a.type.kind === "array" && typeKey(a.type) !== typeKey(r.type)) + escape(a, e.loc, `Array.${e.method}`); + } + } else for (const a of e.args) escape(a, e.loc, `Array.${e.method}`); + break; + } + case "mapIntrinsic": + if (e.method === "set") { + const t = typeKey(e.receiver.type); + if (e.args[0]) this.store(sources(e.args[0]), `mk:${t}`); + if (e.args[1]) this.store(sources(e.args[1]), `mv:${t}`); + } else + for (const a of e.args) + if (e.method !== "get" && e.method !== "has" && e.method !== "delete") + escape(a, e.loc, `Map.${e.method}`); + break; + case "setIntrinsic": + if (e.method === "add" && e.args[0]) + this.store(sources(e.args[0]), `se:${typeKey(e.receiver.type)}`); + break; + case "mapNew": + for (const p of e.seed ?? []) { + this.store(sources(p.key), `mk:${typeKey(e.type)}`); + this.store(sources(p.value), `mv:${typeKey(e.type)}`); + } + break; + case "setNew": + if (e.seed && e.seed.type.kind === "array") + this.store(elementSlot(e.seed), `se:${typeKey(e.type)}`); + break; + case "recordLit": + if (e.type.kind === "record") { + const shape = this.records.get(e.type.shapeId); + for (const f of e.fields) { + const declared = shape?.fields.some((d) => d.name === f.name) === true; + this.store(sources(f.value), `s:${e.type.shapeId}\0${declared ? f.name : "*"}`); + } + } else for (const f of e.fields) escape(f.value, e.loc, "an object literal"); + break; + case "recordClone": + if (e.type.kind === "record" && e.source.type.kind === "record") { + const from = e.source.type.shapeId; + const shape = this.records.get(e.type.shapeId); + for (const f of shape?.fields ?? []) + if (this.direct(f.type)) + this.edge(`s:${from}\0${f.name}`, `s:${e.type.shapeId}\0${f.name}`); + for (const o of e.overrides) + this.store(sources(o.value), `s:${e.type.shapeId}\0${o.name}`); + } + break; + case "closure": { + const lifted = c.functions.get(e.fnName); + e.captures.forEach((id, i) => { + const inner = lifted?.captures?.[i]; + if (!inner || !lifted) return; + const outer = this.localSlot(fn, id); + const innerSlot = this.localSlot(lifted, inner.localId); + this.edge(outer, innerSlot); + this.edge(innerSlot, outer); + }); + break; + } + case "classRef": + if (e.captures) + for (const method of c.mod.functions) { + if (!method.name.startsWith(`%${e.className}.`)) continue; + for (const cc of method.classCaptures ?? []) { + const id = e.captures[cc.slot]; + if (id === undefined) continue; + const outer = this.localSlot(fn, id); + const inner = this.localSlot(method, cc.localId); + this.edge(outer, inner); + this.edge(inner, outer); + } + } + break; + case "libCall": + if (!READ_ONLY_LIB.test(e.fn)) + for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`); + break; + case "callValue": + for (const a of e.args) escape(a, a.loc, "a call through a function value"); + if (e.receiver) escape(e.receiver, e.receiver.loc, "a call through a function value"); + break; + case "dynFrom": + // Plain arrays and records cross into dynamic code as deep copies; + // class instances and live references stay connected. + if (e.liveRef === true || this.containsObject(e.value.type, new Set())) + escape(e.value, e.loc, "dynamic (any or unknown) code"); + break; + default: + if (PASS_THROUGH.has(e.kind) || OBSERVE.has(e.kind)) break; + everyExprChild( + e, + (child) => { + escape(child, child.loc, describe(e)); + return true; + }, + () => true, + ); + } + everyExprChild( + e, + (child) => { + expr(child); + return true; + }, + (child) => { + stmt(child); + return true; + }, + ); + }; + + const stmt = (s: IrStmt): void => { + switch (s.kind) { + case "varDecl": + if (s.init) this.store(sources(s.init), this.localSlot(fn, s.localId)); + break; + case "assign": + this.store(sources(s.value), this.localSlot(fn, s.localId)); + break; + case "return": + if (s.value) { + if (fn.async === true || fn.generator !== undefined) + escape(s.value, s.loc, "the result of an async function or generator"); + else this.store(sources(s.value), `r:${fn.name}`); + } + break; + case "fieldSet": { + const family = this.slots.family(s.className, s.field); + if (family !== null) this.store(sources(s.value), `f:${family}`); + else escape(s.value, s.loc, "a field scriptc cannot follow"); + break; + } + case "recordSet": + this.store(sources(s.value), `s:${s.shapeId}\0${s.field}`); + break; + case "recordKeySet": { + const t = s.obj.type; + if (t.kind === "record") { + const shape = this.records.get(t.shapeId); + const src = sources(s.value); + this.store(src, `s:${t.shapeId}\0*`); + for (const f of shape?.fields ?? []) + if (this.direct(f.type)) this.store(src, `s:${t.shapeId}\0${f.name}`); + } else escape(s.value, s.loc, "a dynamically keyed property"); + break; + } + case "arraySet": + if (isNumberArray(s.arr.type)) write(s.arr, s.value); + else if (s.arr.type.kind === "array" && this.holds(s.arr.type.elem)) + this.store(sources(s.value), `e:${typeKey(s.arr.type)}`); + break; + case "forOf": + if (this.holds(fn.locals.find((l) => l.id === s.localId)?.type ?? s.iterable.type)) { + const slot = this.localSlot(fn, s.localId); + const t = s.iterable.type; + if (t.kind === "array") this.store(elementSlot(s.iterable), slot); + else { + // Another iterable yields stored values or unknown ones. + this.store(this.contained(t), slot); + for (const node of this.opaqueValue( + fn.locals.find((l) => l.id === s.localId)?.type ?? t, + s.loc, + "an iterator", + )) + this.edge(node, slot); + } + } + break; + case "tryCatch": + if (s.catchLocalId !== null) { + const type = fn.locals.find((l) => l.id === s.catchLocalId)?.type; + if (type && this.holds(type)) + for (const node of this.opaqueValue(type, s.loc, "a caught value")) + this.edge(node, this.localSlot(fn, s.catchLocalId)); + } + break; + case "throw": + escape(s.value, s.loc, "a throw"); + break; + default: + break; + } + everyStmtChild( + s, + (child) => { + expr(child); + return true; + }, + (child) => { + stmt(child); + return true; + }, + ); + }; + for (const s of fn.body) stmt(s); + } + + /** Whether a value of type `t` can reach a class instance. */ + private containsObject(t: IrType, seen: Set): boolean { + const key = typeKey(t); + if (seen.has(key)) return false; + seen.add(key); + switch (t.kind) { + case "object": + return true; + case "array": + case "set": + return this.containsObject(t.elem, seen); + case "map": + return this.containsObject(t.key, seen) || this.containsObject(t.value, seen); + case "union": + return (this.unions.get(t.unionId) ?? []).some((a) => this.containsObject(a, seen)); + case "record": { + const shape = this.records.get(t.shapeId); + return ( + (shape?.fields.some((f) => this.containsObject(f.type, seen)) ?? false) || + (shape?.indexValue !== undefined && this.containsObject(shape.indexValue, seen)) + ); + } + default: + return false; + } + } + + /* ── solving ── */ + + private solve(): void { + const c = this.c; + // Backward from every annotated slot: the allocations and origins whose + // arrays can end up in it are constrained. Forward from those: the + // nodes that may hold a constrained array. An array whose elements are + // copied into one that may be constrained must be constrained as well, + // which can constrain more arrays: repeat until nothing changes. + const constrained = new Map(); + const expanded = new Set(); + const roots: [string, ContractNote][] = [...this.annotated]; + const forward: string[] = []; + while (roots.length > 0) { + for (const [root, note] of roots.splice(0)) { + if (expanded.has(root)) continue; + expanded.add(root); + const queue = [root]; + while (queue.length) { + const node = queue.pop()!; + if (this.allocs.has(node) && !constrained.has(node)) { + constrained.set(node, note); + if (!this.constrainedReach.has(node)) { + this.constrainedReach.set(node, note); + forward.push(node); + } + } + const origin = this.opaque.get(node); + if (origin) + c.report( + "SC7003", + note.loc, + `the Int32[] contract on '${note.name}' cannot be checked: the array can come from ${origin.why} (${c.where(origin.loc)}), whose element stores scriptc does not see`, + "build the array in typed code (a literal, `new Array(n).fill(0)` or `push`) and pass it along, or remove Int32[]", + ); + for (const p of this.pred.get(node) ?? NO_NODES) + if (!expanded.has(p)) { + expanded.add(p); + queue.push(p); + } + } + } + while (forward.length) { + const node = forward.pop()!; + const note = this.constrainedReach.get(node)!; + for (const next of this.succ.get(node) ?? NO_NODES) + if (!this.constrainedReach.has(next)) { + this.constrainedReach.set(next, note); + forward.push(next); + } + } + for (const copy of this.copies) { + const note = this.reachingNote(copy.to); + if (note === undefined) continue; + for (const from of copy.from) if (!expanded.has(from)) roots.push([from, note]); + } + } + const queue: string[] = []; + for (const node of [...this.allocs, ...this.opaque.keys()]) + if (!constrained.has(node)) { + this.unconstrainedReach.add(node); + queue.push(node); + } + while (queue.length) { + const node = queue.pop()!; + for (const next of this.succ.get(node) ?? NO_NODES) + if (!this.unconstrainedReach.has(next)) { + this.unconstrainedReach.add(next); + queue.push(next); + } + } + for (const e of this.escapes) { + const note = this.reachingNote(e.nodes); + if (note === undefined) continue; + c.report( + "SC7003", + e.loc, + `an array that can reach '${note.name}' (Int32[]) escapes to ${e.what}, where its element stores are not checked${e.shown ? `: '${e.shown}'` : ""}`, + "keep Int32[] arrays in typed code, or pass a copy (`[...xs]`) that no Int32[] slot holds", + ); + } + for (const slot of new Set([...this.annotated.keys(), ...this.constrainedReach.keys()])) + if (!this.unconstrainedReach.has(slot) && /^[lgfrs]:/.test(slot)) this.int32Slots.add(slot); + } + + private reachingNote(nodes: readonly string[]): ContractNote | undefined { + for (const n of nodes) { + const note = this.constrainedReach.get(n); + if (note) return note; + } + return undefined; + } +} + +function push(map: Map, key: K, value: V): void { + const list = map.get(key); + if (list) list.push(value); + else map.set(key, [value]); +} + +function orZero(text: string): string { + return /^[\w.$]+(\[[^\]]*\])?$/.test(text) ? `${text} | 0` : `(${text}) | 0`; +} + +function describe(e: IrExpr): string { + switch (e.kind) { + case "dynFrom": + case "dynObjLit": + case "dynArrLit": + case "dynCheck": + return "dynamic (any or unknown) code"; + case "intrinsic": + return `the runtime (${e.name})`; + case "libCall": + return `the library call ${e.fn}`; + case "awaitExpr": + return "an awaited promise"; + default: + return `an operation scriptc does not follow (${e.kind})`; + } +} diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 0a97b07c2..eaae1b531 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -97,14 +97,18 @@ export function nativeCodegenDiag( code: "SC3002" | "SC3003" | "SC3004", message: string, file: string, + hint?: string, ): ScrDiagnostic { + const shown = + hint ?? + (code === "SC3003" + ? "reinstall scriptc with optional dependencies enabled for this host" + : undefined); return { code, message, loc: { file, start: 0, end: 0 }, - ...(code === "SC3003" - ? { hint: "reinstall scriptc with optional dependencies enabled for this host" } - : {}), + ...(shown !== undefined ? { hint: shown } : {}), }; } @@ -407,7 +411,7 @@ export const UNSUPPORTED: Record = { SC1120: { feature: "this regex feature", milestone: "later", - hint: "supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \\k), .source/.flags, and string replace/replaceAll/split with string replacement templates", + hint: "supported: literal regexes with the g/i/m/s/u/v/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \\k), .source/.flags, and string replace/replaceAll/split with string replacement templates", }, // SC1121 (stateful g/y regex tests) shipped — code retired, do not reuse. }; diff --git a/packages/compiler/src/diagnostics/render.test.ts b/packages/compiler/src/diagnostics/render.test.ts new file mode 100644 index 000000000..c5078dd0e --- /dev/null +++ b/packages/compiler/src/diagnostics/render.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "vitest"; +import type { ScrDiagnostic } from "./diagnostic.js"; +import { renderDiagnostic, renderDiagnostics } from "./render.js"; + +function diag(file: string, start: number, end: number): ScrDiagnostic { + return { code: "SC2020", message: "not supported", loc: { file, start, end } }; +} + +test("a short line renders whole", () => { + const text = "const a = 1;\nconst b = a.c;\nconst d = 2;"; + const start = text.indexOf("a.c"); + expect(renderDiagnostic(diag("m.ts", start, start + 3), { text })).toBe( + [ + "m.ts:2:11 - error SC2020: not supported", + "", + " 1 | const a = 1;", + " 2 | const b = a.c;", + " | ^~~", + " 3 | const d = 2;", + ].join("\n"), + ); +}); + +test("a long line renders a window around the column", () => { + const line = "x".repeat(1000) + "bad" + "y".repeat(1000); + const text = "first\n" + line + "\nlast"; + const start = text.indexOf("bad"); + const out = renderDiagnostic(diag("min.js", start, start + 3), { text }); + const lines = out.split("\n"); + expect(lines[0]).toBe("min.js:2:1001 - error SC2020: not supported"); + // the window starts 120 characters before the column + expect(lines[3]).toBe(` 2 | …${"x".repeat(120)}bad${"y".repeat(277)}…`); + expect(lines[4]).toBe(` | ${" ".repeat(121)}^~~`); + // context lines share the window: "first" and "last" start before it + expect(lines[2]).toBe(" 1 | …"); + expect(lines[5]).toBe(" 3 | …"); +}); + +test("a window at the line's end keeps the last characters", () => { + const line = "a".repeat(600) + "end"; + const start = line.indexOf("end"); + const out = renderDiagnostic(diag("e.js", start, start + 3), { text: line }); + const lines = out.split("\n"); + expect(lines[2]).toBe(` 1 | …${"a".repeat(397)}end`); + expect(lines[3]).toBe(` | ${" ".repeat(398)}^~~`); +}); + +test("thousands of diagnostics on a minified line render without exhausting string length", () => { + // one 2 MB line with 20,000 diagnostics: rendering every frame line whole + // would need tens of gigabytes + const text = "a;".repeat(1_000_000); + const diags = Array.from({ length: 20_000 }, (_, i) => diag("bundle.js", i * 100, i * 100 + 1)); + const out = renderDiagnostics(diags, new Map([["bundle.js", text]])); + expect(out.split(" - error SC2020: ").length - 1).toBe(20_000); + expect(out.length).toBeLessThan(20_000 * 1_000); +}); diff --git a/packages/compiler/src/diagnostics/render.ts b/packages/compiler/src/diagnostics/render.ts index 7fd68910f..e5a75c6bc 100644 --- a/packages/compiler/src/diagnostics/render.ts +++ b/packages/compiler/src/diagnostics/render.ts @@ -1,6 +1,8 @@ /* Terminal rendering: file:line:col, a hand-rolled code frame with ±1 line * of context and a ^~~~ underline sized to the span, optional hint line, - * and the optional attributed profile-note line after it. + * and the optional attributed profile-note line after it. A source line + * longer than FRAME_MAX_WIDTH (minified sources) shows a window of that + * width around the diagnostic's column, with "…" where text is cut. */ import type { ScrDiagnostic } from "./diagnostic.js"; @@ -11,6 +13,27 @@ export interface RenderOptions { interface SourceLookup { /** Full text of the file the diagnostic points into. */ text: string; + /** The text's lines and their start offsets; computed from `text` when + * absent. renderDiagnostics computes them once per file. */ + index?: LineIndex; +} + +interface LineIndex { + lines: string[]; + lineStarts: number[]; +} + +/** Frame lines longer than this many characters show a window of it. */ +const FRAME_MAX_WIDTH = 400; +/** Characters a windowed frame keeps before the diagnostic's column. */ +const FRAME_LEAD = 120; + +function lineIndex(text: string): LineIndex { + const lineStarts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") lineStarts.push(i + 1); + } + return { lines: text.split("\n"), lineStarts }; } const RED = "\x1b[31m"; @@ -33,32 +56,52 @@ export function renderDiagnostic( const frame: string[] = []; if (source) { - const lineStarts = [0]; - for (let i = 0; i < source.text.length; i++) { - if (source.text[i] === "\n") lineStarts.push(i + 1); - } + const { lines, lineStarts } = source.index ?? lineIndex(source.text); + // the last line starting at or before the diagnostic let lo = 0; - while (lo + 1 < lineStarts.length && lineStarts[lo + 1]! <= diag.loc.start) lo++; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid]! <= diag.loc.start) lo = mid; + else hi = mid - 1; + } lineNum = lo + 1; colNum = diag.loc.start - lineStarts[lo]! + 1; - const lines = source.text.split("\n"); + const lineText = lines[lineNum - 1] ?? ""; + // The window every frame line shows: the whole line, or for a long + // line FRAME_MAX_WIDTH characters starting FRAME_LEAD before the column. + const windowStart = + lineText.length > FRAME_MAX_WIDTH + ? Math.max(0, Math.min(colNum - 1 - FRAME_LEAD, lineText.length - FRAME_MAX_WIDTH)) + : 0; + const shown = (text: string): string => { + if (windowStart === 0 && text.length <= FRAME_MAX_WIDTH) return text; + const end = windowStart + FRAME_MAX_WIDTH; + return ( + (windowStart > 0 ? "…" : "") + text.slice(windowStart, end) + (text.length > end ? "…" : "") + ); + }; const gutterWidth = String(Math.min(lineNum + 1, lines.length)).length; const emitLine = (n: number) => { const text = lines[n - 1]; if (text === undefined) return; - frame.push(c(DIM, ` ${String(n).padStart(gutterWidth)} | `) + text); + frame.push(c(DIM, ` ${String(n).padStart(gutterWidth)} | `) + shown(text)); }; emitLine(lineNum - 1); emitLine(lineNum); - const lineText = lines[lineNum - 1] ?? ""; + const caretCol = colNum - 1 - windowStart + (windowStart > 0 ? 1 : 0); + const visibleEnd = + windowStart === 0 && lineText.length <= FRAME_MAX_WIDTH + ? lineText.length + : Math.min(lineText.length, windowStart + FRAME_MAX_WIDTH); const spanOnLine = Math.max( 1, - Math.min(diag.loc.end - diag.loc.start, lineText.length - (colNum - 1)), + Math.min(diag.loc.end - diag.loc.start, visibleEnd - (colNum - 1)), ); frame.push( c(DIM, ` ${" ".repeat(gutterWidth)} | `) + - " ".repeat(colNum - 1) + + " ".repeat(caretCol) + c(/^SC6\d{3}$/.test(diag.code) ? YELLOW : RED, "^" + "~".repeat(spanOnLine - 1)), ); emitLine(lineNum + 1); @@ -94,10 +137,17 @@ export function renderDiagnostics( const sorted = [...diags].sort( (a, b) => a.loc.file.localeCompare(b.loc.file) || a.loc.start - b.loc.start, ); + const indexes = new Map(); return sorted .map((d) => { const text = sourceTextByFile.get(d.loc.file); - return renderDiagnostic(d, text === undefined ? undefined : { text }, opts); + if (text === undefined) return renderDiagnostic(d, undefined, opts); + let index = indexes.get(d.loc.file); + if (index === undefined) { + index = lineIndex(text); + indexes.set(d.loc.file, index); + } + return renderDiagnostic(d, { text, index }, opts); }) .join("\n\n"); } diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts index bd396dacd..24462a2ce 100644 --- a/packages/compiler/src/frontend/contract-types.ts +++ b/packages/compiler/src/frontend/contract-types.ts @@ -30,6 +30,9 @@ export interface ContractNote { loc: SrcLoc; /** The annotated declaration's name, for messages. */ name: string; + /** `Int32[]` (also `readonly Int32[]`, `Array`, `ReadonlyArray`): + * the contract covers the elements of the annotated array. */ + elements?: true; } /** An Acyclic type: a class (by its declaration's location key, like @@ -202,7 +205,7 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa marker === "NoThrow" ? "NoThrow wraps a function's written return type" : marker === "Int32" - ? "Int32 is checked on parameters, variables, class instance fields and function results" + ? "Int32 and Int32[] are checked on parameters, variables, class instance fields and function results" : marker === "ProgramLifetime" ? "ProgramLifetime is checked in the implements clause of a class declaration" : "Immutable is checked on parameters, variables, class instance fields and function results"; @@ -215,7 +218,9 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa ? "write it as the function's return type annotation: function f(): NoThrow" : marker === "ProgramLifetime" ? "declare it on the class itself: class Node implements ProgramLifetime { ... }" - : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`, + : marker === "Int32" + ? "write Int32 or Int32[] directly as the annotation of the declaration; type aliases, interface members and types nested in other types (unions, nested arrays, other type arguments) are not followed" + : `write ${marker} directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed`, ), ); } @@ -299,16 +304,47 @@ function place( ): void { let node: ts.Node = ref; let parent = ref.parent; + // Int32[] (readonly Int32[], Array, ReadonlyArray): the + // contract covers the elements of the annotated array. + let elements = false; + if (marker === "Int32" && parent !== undefined) { + if (ts.isArrayTypeNode(parent) && parent.elementType === ref) { + node = parent; + parent = parent.parent; + elements = true; + if ( + parent !== undefined && + ts.isTypeOperatorNode(parent) && + parent.operator === ts.SyntaxKind.ReadonlyKeyword + ) { + node = parent; + parent = parent.parent; + } + } else if ( + ts.isTypeReferenceNode(parent) && + ts.isIdentifier(parent.typeName) && + (parent.typeName.text === "Array" || parent.typeName.text === "ReadonlyArray") && + parent.typeArguments?.length === 1 && + parent.typeArguments[0] === ref + ) { + node = parent; + parent = parent.parent; + elements = true; + } + } // NoThrow / NoThrow>: the inner marker is the result // contract of the function the NoThrow annotates. if (marker !== "NoThrow" && parent !== undefined && ts.isTypeReferenceNode(parent)) { const wrapper = markerOf(checker, parent); - if (wrapper === "NoThrow" && parent.typeArguments?.[0] === ref) { + if (wrapper === "NoThrow" && parent.typeArguments?.[0] === node) { node = parent; parent = parent.parent; } } - const note = (name: string): ContractNote => ({ marker, loc: locOf(ref), name }); + const note = (name: string): ContractNote => + elements + ? { marker, loc: locOf(ref), name, elements: true } + : { marker, loc: locOf(ref), name }; if (parent === undefined) return unsupported(out, ref, marker); if (ts.isFunctionLike(parent) && parent.type === node) { if (!ts.isFunctionLikeDeclaration(parent)) return unsupported(out, ref, marker); diff --git a/packages/compiler/src/frontend/lowering/binding-analysis.ts b/packages/compiler/src/frontend/lowering/binding-analysis.ts index e6a06151d..816294e08 100644 --- a/packages/compiler/src/frontend/lowering/binding-analysis.ts +++ b/packages/compiler/src/frontend/lowering/binding-analysis.ts @@ -2,6 +2,7 @@ import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; import { isJsSourceFile } from "../program.js"; import { dynFallbackType } from "./lowerer.js"; +import { isGenericCallableMemberType } from "../type-mapper.js"; /** True when nothing in `sym`'s DECLARING FILE ever writes it after the * initializer: assignments (plain and compound, destructuring targets @@ -199,7 +200,8 @@ export function nullishGenericBindingUnitOf( // (a query can even panic upstream — the 1e999 checker bug). const unit = nullishValueUnitOf(lowerer, sym); if (unit === null) return null; - const mapped = lowerer.mapTypeOf(lowerer.checker.getTypeOfSymbol(sym)); + const declared = lowerer.checker.getTypeOfSymbol(sym); + const mapped = lowerer.mapTypeOf(declared); if (mapped !== null) { // Only the EMPTY interned shape qualifies among record mappings — // the all-generic-signature interface (`I`) whose struct has @@ -210,9 +212,16 @@ export function nullishGenericBindingUnitOf( // so claiming the binding would fence working programs. if (mapped.kind !== "record") return null; const shape = lowerer.shapes.get(mapped.shapeId); + // Erased generic members are function slots, not data: an interface + // of generic signatures only keeps the no-storage story. + const onlyGenericMembers = lowerer.checker + .getPropertiesOfType(declared) + .every((property) => + isGenericCallableMemberType(lowerer.checker.getTypeOfSymbol(property), lowerer.checker), + ); if ( !shape || - shape.fields.length > 0 || + (shape.fields.length > 0 && !onlyGenericMembers) || shape.tuple !== undefined || shape.indexValue !== undefined ) { diff --git a/packages/compiler/src/frontend/lowering/builtin-calls.ts b/packages/compiler/src/frontend/lowering/builtin-calls.ts index 810fd6d2f..037b3e5a7 100644 --- a/packages/compiler/src/frontend/lowering/builtin-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtin-calls.ts @@ -1,9 +1,14 @@ import { lowerReadlineCursorCall } from "./builtins/readline.js"; -import { lowerReceiveMessageOnPort } from "./builtins/workers.js"; +import { + lowerEnvironmentData, + lowerReceiveMessageOnPort, + lowerUncloneable, + lowerUntransferable, +} from "./builtins/workers.js"; import * as ts from "../ts7/adapter.js"; import { dynUndefinedExpr } from "../../ir/build.js"; import { DYN, type IrExpr, type SrcLoc } from "../../ir/ir.js"; -import { lowerEventsOnce } from "./lower-event-emitter.js"; +import { lowerEventsEmitterHelper, lowerEventsOnce } from "./lower-event-emitter.js"; import type { Lowerer } from "./lowerer.js"; import { lowerTimersMemberCall } from "./lower-timers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; @@ -64,6 +69,10 @@ export function lowerBuiltinCall( return lowerAddAbortListener(lowerer, expr, loc); } if (bi.module === "events" && bi.member === "once") return lowerEventsOnce(lowerer, expr, loc); + if (bi.module === "events") { + const helper = lowerEventsEmitterHelper(lowerer, expr, bi.member, loc); + if (helper) return helper; + } if (bi.module === "timers") { const timersServed = lowerTimersMemberCall(lowerer, expr, bi.member, loc); if (timersServed) return timersServed; @@ -97,6 +106,21 @@ export function lowerBuiltinCall( if (bi.module === "worker_threads" && bi.member === "receiveMessageOnPort") { return lowerReceiveMessageOnPort(lowerer, expr); } + if ( + bi.module === "worker_threads" && + (bi.member === "setEnvironmentData" || bi.member === "getEnvironmentData") + ) { + return lowerEnvironmentData(lowerer, expr, bi.member); + } + if ( + bi.module === "worker_threads" && + (bi.member === "markAsUntransferable" || bi.member === "isMarkedAsUntransferable") + ) { + 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/dates.ts b/packages/compiler/src/frontend/lowering/builtins/dates.ts index 023d9abf2..fa880bdc9 100644 --- a/packages/compiler/src/frontend/lowering/builtins/dates.ts +++ b/packages/compiler/src/frontend/lowering/builtins/dates.ts @@ -1,7 +1,17 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer, own } from "../lowerer.js"; import { locOf } from "../../program.js"; -import { F64, type IrExpr, type IrLibFn, STRING } from "../../../ir/ir.js"; +import { + BOOL, + F64, + type IrExpr, + type IrLibFn, + type IrStmt, + type IrType, + NULL_T, + STRING, + typeKey, +} from "../../../ir/ir.js"; const DATE_GETTER_FNS: Readonly> = { getFullYear: "date.getFullYear", @@ -24,7 +34,7 @@ const DATE_GETTER_FNS: Readonly> = { }; const DATE_METHOD_HINT = - "getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering"; + "getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering"; /** The Date slice: statics plus read-only Date values backed by one * TimeClip'd millisecond scalar. Construction lives in lowerNew; @@ -80,6 +90,7 @@ export function lowerDateCall( if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "date") return null; if (!lowerer.isStdlibMember(access)) return null; const name = access.name.text; + if (name === "toJSON") return lowerDateToJson(lowerer, call, access); if (call.arguments.length !== 0) { lowerer.noLowering(`Date.prototype.${name} with arguments`, call, DATE_METHOD_HINT); } @@ -113,3 +124,77 @@ export function lowerDateCall( lowerer.checker.getSymbolAtLocation(access.name), ); } + +/** `d.toJSON(key?)`: Node answers null for an invalid date and the + * `toISOString()` string otherwise, as `string | null` (the lib types say + * `string`). The argument is evaluated and ignored, as the built-in does. */ +function lowerDateToJson( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr { + const loc = locOf(call); + if (call.arguments.length > 1 || call.arguments.some((a) => ts.isSpreadElement(a))) + lowerer.noLowering(`Date.prototype.toJSON with ${call.arguments.length} arguments`, call); + const raw = lowerer.lowerExpr(access.expression); + const receiver: IrExpr = + raw.type.kind === "dyn" + ? { kind: "libCall", fn: "date.checkedValue", args: [raw], type: { kind: "date" }, loc } + : raw; + if (receiver.type.kind !== "date") + lowerer.badType(access.expression, lowerer.typeOf(access.expression)); + const date = lowerer.declareHiddenLocal("%date", receiver.type); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: date.id, init: receiver, loc }]; + const key = call.arguments[0]; + if (key) stmts.push({ kind: "exprStmt", expr: lowerer.lowerExpr(key), loc }); + const value: IrExpr = { kind: "varRef", localId: date.id, type: receiver.type, loc }; + const time: IrExpr = { kind: "libCall", fn: "date.getTime", args: [value], type: F64, loc }; + const type = dateJsonType(lowerer); + return { + kind: "seqExpr", + stmts, + result: { + kind: "ternary", + // An invalid date's time value is NaN, the one value unequal to itself. + cond: { kind: "bin", op: "!==", left: time, right: time, type: BOOL, loc }, + then: lowerer.coerceToExpected({ kind: "unitLit", unit: "null", type: NULL_T, loc }, type), + else_: lowerer.coerceToExpected( + { kind: "libCall", fn: "date.toISOStringValue", args: [value], type: STRING, loc }, + type, + ), + type, + loc, + }, + type, + loc, + }; +} + +/** `string | null`, the type of a Date's `toJSON()` result. */ +export function dateJsonType(lowerer: Lowerer): IrType { + const arms = [STRING, NULL_T].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: lowerer.unions.intern(arms) }; +} + +/** A Date `toJSON()` call, or a binding initialized by one + * (Lowerer.dateJsonBindings): its value can be null where the lib types + * say string. */ +export function isDateJsonSource(lowerer: Lowerer, node: ts.Node): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (ts.isIdentifier(node)) { + const symbol = + node.parent && ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node + ? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent) + : lowerer.checker.getSymbolAtLocation(node); + return symbol !== undefined && lowerer.dateJsonBindings.has(symbol); + } + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return false; + const access = node.expression; + return ( + access.name.text === "toJSON" && + !node.questionDotToken && + !access.questionDotToken && + lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind === "date" && + lowerer.isStdlibMember(access) + ); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts b/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts new file mode 100644 index 000000000..469a66ace --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/disposable-stack.ts @@ -0,0 +1,136 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { locOf } from "../../program.js"; +import { newFnCtx } from "../function-context.js"; +import { BOOL, DYN, VOID, type IrExpr, type IrType } from "../../../ir/ir.js"; +import { varRef } from "../../../ir/build.js"; + +/** Whether `node`'s static type is the standard library's DisposableStack. */ +function isStdlibDisposableStack(lowerer: Lowerer, node: ts.Expression): boolean { + const type = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(node)); + for (const symbol of [type.getSymbol(), type.getAliasSymbol()]) { + if (symbol?.name === "DisposableStack" && lowerer.isStdlibSymbol(symbol)) return true; + } + return false; +} + +/** `new DisposableStack()`: a native stack handle (scr_symbol.c). Its + * members are function values and getters of the handle, reached through + * the checked-dynamic method and property paths. Null for other + * constructors. */ +export function lowerDisposableStackNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + if (!ts.isIdentifier(expr.expression) || expr.expression.text !== "DisposableStack") return null; + const symbol = lowerer.resolveValueSymbol(expr.expression); + if (!symbol || !lowerer.isStdlibSymbol(symbol)) return null; + if ((expr.arguments ?? []).length !== 0) return null; + return { kind: "libCall", fn: "disposable.stackNew", args: [], type: DYN, loc: locOf(expr) }; +} + +/** `x instanceof DisposableStack`, or null when the right side is not the + * standard library's DisposableStack. */ +export function lowerDisposableStackInstanceOf( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + if (!lowerer.isStdlibGlobal(expr.right, "DisposableStack")) return null; + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { kind: "libCall", fn: "disposable.stackIs", args: [value], type: BOOL, loc: locOf(expr) }; +} + +/** `stack.use(value)` where `value` is a class instance. The dyn view of a + * class instance has no methods, so the stack receives a function that + * calls the class's [Symbol.dispose] method on the value, and the call + * answers the value at its static type. Other values take the + * checked-dynamic method path, which reads the value's [Symbol.dispose]. + * Null when the call is not that form. */ +export function lowerDisposableStackUseCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + if (access.name.text !== "use" || call.arguments.length !== 1) return null; + const argNode = call.arguments[0]!; + if (ts.isSpreadElement(argNode)) return null; + if (!isStdlibDisposableStack(lowerer, access.expression)) return null; + const valueT = lowerer.mapTypeOf(lowerer.typeOf(argNode)); + if (valueT?.kind === "union") { + const arms = lowerer.unions.get(valueT.unionId)?.arms ?? []; + if (arms.some((arm) => arm.kind === "object")) { + lowerer.unsupported( + "SC1090", + argNode, + "DisposableStack.use on a union with class instances (narrow the value to the class first)", + ); + } + } + if (valueT?.kind !== "object") return null; + const loc = locOf(call); + const className = valueT.className; + const info = lowerer.classes.get(className); + if (!info) lowerer.flushDeferredClass(className); + const found = info ? lowerer.findMethodOn(info, "sym:dispose") : null; + if (!found) return null; + if (found.sig.params.length !== 0) { + lowerer.unsupported("SC1090", argNode, "[Symbol.dispose] methods with parameters"); + } + const stack = lowerer.lowerExpr(access.expression); + const value = lowerer.lowerExpr(argNode); + const key = `disposableStack.use:${className}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%disposableStack.use.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const context = newFnCtx(false, null, null, VOID); + lowerer.fnStack.push(context); + try { + const param = lowerer.declareHiddenLocal("value", valueT); + const self = varRef(param.id, valueT, loc); + lowerer.liftedFns.push({ + name: helper, + params: [{ localId: param.id, name: "value", type: valueT }], + returnType: VOID, + locals: context.locals, + body: [ + { + kind: "exprStmt", + expr: lowerer.accessorCall(className, "sym:dispose", self, [], found.sig.ret, loc), + loc, + }, + ], + loc, + }); + } finally { + lowerer.fnStack.pop(); + } + } + const stackLocal = lowerer.declareHiddenLocal("%disposableStack", DYN); + const valueLocal = lowerer.declareHiddenLocal("%disposableValue", valueT); + const fnT: IrType = { kind: "func", params: [valueT], ret: VOID }; + const dispose: IrExpr = { kind: "closure", fnName: helper, captures: [], type: fnT, loc }; + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: stackLocal.id, init: stack, loc }, + { kind: "varDecl", localId: valueLocal.id, init: value, loc }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "disposable.stackUseCallback", + args: [ + varRef(stackLocal.id, DYN, loc), + lowerer.coerceToExpected(varRef(valueLocal.id, valueT, loc), DYN), + lowerer.coerceToExpected(dispose, DYN), + ], + type: BOOL, + loc, + }, + loc, + }, + ], + result: varRef(valueLocal.id, valueT, loc), + type: valueT, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 3bc04ce07..5947f3714 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -1,7 +1,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; -import { DYN, F64, type IrExpr, STRING } from "../../../ir/ir.js"; +import { BOOL, CAUGHT, DYN, F64, type IrExpr, type IrType, STRING } from "../../../ir/ir.js"; const ASSERTION_ERROR_PROPERTIES: ReadonlySet = new Set([ "actual", @@ -158,3 +158,107 @@ export function lowerErrorCodeProperty( loc: locOf(expr), }; } + +/** Whether values of `type` are errors: class instances whose class is + * %Error or extends it. A subclass of a class that does not extend Error + * cannot be an error either, so the answer depends on the static class + * alone. */ +function isErrorClassType(lowerer: Lowerer, type: IrType): boolean { + return ( + type.kind === "object" && + (type.className === "%Error" || lowerer.isSubclassOf(type.className, "%Error")) + ); +} + +/** `Error.isError(value)`. A catch binding tests its payload against the + * Error class, an `unknown` value tests the checked-dynamic tree's error + * encoding, and a union tests its runtime tag against the arms whose class + * extends Error. Every other type answers statically: true for class + * instances whose class extends Error, false otherwise. The argument is + * evaluated in every case. Null when the callee is not the standard + * library's Error.isError. */ +export function lowerErrorIsErrorCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (call.questionDotToken || access.questionDotToken) return null; + if (lowerer.stdlibGlobalMember(access, "Error") !== "isError") return null; + if (call.arguments.length !== 1) return null; + const argNode = call.arguments[0]!; + const loc = locOf(call); + const caughtLocal = lowerer.caughtLocalOf(argNode); + if (caughtLocal) { + return { + kind: "caughtTest", + value: { kind: "varRef", localId: caughtLocal.id, type: CAUGHT, loc }, + test: "instanceof", + className: "%Error", + type: BOOL, + loc, + }; + } + const arg = lowerer.lowerExpr(argNode); + if (arg.type.kind === "dyn") { + return { kind: "dynTest", test: "error", value: arg, type: BOOL, loc }; + } + if (arg.type.kind === "union") { + const union = lowerer.unions.get(arg.type.unionId); + if (!union || union.arms.some((a) => a.kind === "dyn" || a.kind === "caught")) { + lowerer.unsupported( + "SC1090", + argNode, + `Error.isError on '${lowerer.fmt(arg.type)}' values (narrow first, or pass the value as 'unknown')`, + ); + } + const local = lowerer.declareHiddenLocal("%isErrorValue", arg.type); + const value: IrExpr = { kind: "varRef", localId: local.id, type: arg.type, loc }; + let result: IrExpr = { kind: "boolLit", value: false, type: BOOL, loc }; + union.arms.forEach((arm, tag) => { + if (!isErrorClassType(lowerer, arm)) return; + const test: IrExpr = { + kind: "unionIsTag", + unionId: union.id, + tag, + negated: false, + value, + type: BOOL, + loc, + }; + result = + result.kind === "boolLit" + ? test + : { kind: "logical", op: "||", left: result, right: test, type: BOOL, loc }; + }); + return { + kind: "seqExpr", + stmts: [{ kind: "varDecl", localId: local.id, init: arg, loc }], + result, + type: BOOL, + loc, + }; + } + const result: IrExpr = { + kind: "boolLit", + value: isErrorClassType(lowerer, arg.type), + type: BOOL, + loc, + }; + // Literals and local reads have no effects to keep, and a bare unit + // literal is not a statement. + if ( + arg.kind === "varRef" || + arg.kind === "unitLit" || + arg.kind === "numLit" || + arg.kind === "strLit" || + arg.kind === "boolLit" + ) + return result; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: arg, loc }], + result, + type: BOOL, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/json.ts b/packages/compiler/src/frontend/lowering/builtins/json.ts index 475cfd51c..a73c9d5e7 100644 --- a/packages/compiler/src/frontend/lowering/builtins/json.ts +++ b/packages/compiler/src/frontend/lowering/builtins/json.ts @@ -1,3 +1,4 @@ +import { isDateJsonSource } from "./dates.js"; import { dynUndefinedExpr, varRef } from "../../../ir/build.js"; import { InternalCompilerError } from "../../../errors.js"; import * as ts from "../../ts7/adapter.js"; @@ -82,7 +83,12 @@ export function lowerJsonMethodCall( // conversion so a stale capture cannot read an impossible payload. // Optional strings have a serializer that preserves a missing root; // an unchecked array read can still be absent despite its checker type. - if (value.type.kind === "union" && optionalStringTags(lowerer, value.type) === null) { + // A Date's toJSON() result keeps its null: the lib types say string. + if ( + value.type.kind === "union" && + optionalStringTags(lowerer, value.type) === null && + !isDateJsonSource(lowerer, argNode) + ) { const narrowed = lowerer.mapTypeOf(lowerer.typeOf(argNode)); if (narrowed && !isUnitType(narrowed) && narrowed.kind !== "void") { const helper = diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index dc141045f..58ac85aba 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -40,6 +40,7 @@ import { lowerForkCall } from "./child-process.js"; import { lowerOsCpusCall, lowerOsGetPriorityCall, + lowerOsSetPriorityCall, lowerOsNetworkInterfacesCall, lowerOsUserInfoCall, lowerPathFormatCall, @@ -163,6 +164,9 @@ export function lowerBuiltinModuleCall( if (bi.module === "os" && bi.member === "cpus") { return lowerOsCpusCall(lowerer, expr, loc); } + if (bi.module === "os" && bi.member === "setPriority") { + return lowerOsSetPriorityCall(lowerer, expr, loc); + } if (bi.module === "os" && bi.member === "getPriority") { return lowerOsGetPriorityCall(lowerer, expr, loc); } diff --git a/packages/compiler/src/frontend/lowering/builtins/net-address.ts b/packages/compiler/src/frontend/lowering/builtins/net-address.ts new file mode 100644 index 000000000..b49546d8c --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/net-address.ts @@ -0,0 +1,168 @@ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { locOf } from "../../program.js"; +import { dynUndefinedExpr } from "../../../ir/build.js"; +import { BOOL, DYN, type IrExpr, type IrLibFn } from "../../../ir/ir.js"; +import { lowerDynObjectLiteral } from "../expressions/object-literals.js"; +import { builtinImportOf } from "./module-bindings.js"; + +/* node:net's SocketAddress and BlockList are native checked-dynamic + * handles (scr_net.c): both types map to dyn, their getters are handle + * property reads, their methods dispatch through the handle, and these + * helpers lower the class's uses that a dyn value cannot answer by + * itself (construction, the static members and instanceof). */ + +type NetAddressClass = "SocketAddress" | "BlockList"; + +/** The SocketAddress or BlockList class in any node:net spelling. */ +export function netAddressClassOf(lowerer: Lowerer, node: ts.Expression): NetAddressClass | null { + const builtin = ts.isIdentifier(node) + ? builtinImportOf(lowerer, node) + : ts.isPropertyAccessExpression(node) && !node.questionDotToken + ? lowerer.builtinMemberOf(node) + : null; + if (builtin?.module !== "net") return null; + return builtin.member === "SocketAddress" || builtin.member === "BlockList" + ? builtin.member + : null; +} + +/** The class of a stdlib SocketAddress- or BlockList-typed value. */ +function netAddressValueClass(lowerer: Lowerer, node: ts.Expression): NetAddressClass | null { + const type = lowerer.typeOf(node); + const sym = type.getAliasSymbol() ?? type.getSymbol(); + if (!sym || (sym.name !== "SocketAddress" && sym.name !== "BlockList")) return null; + return lowerer.isStdlibSymbol(sym) ? sym.name : null; +} + +/** `new SocketAddress(options?)` and `new BlockList()`: the argument + * vector goes to the runtime, which validates it in Node's order. */ +export function lowerNetAddressNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null { + const cls = netAddressClassOf(lowerer, expr.expression); + if (cls === null) return null; + const args = expr.arguments ?? []; + if (args.some(ts.isSpreadElement)) return null; + const loc = locOf(expr); + const values = args.map((arg) => + ts.isObjectLiteralExpression(arg) + ? lowerDynObjectLiteral(lowerer, arg) + : lowerer.lowerExprExpecting(arg, DYN), + ); + return { + kind: "libCall", + fn: cls === "SocketAddress" ? "net.socketAddressNew" : "net.blockListNew", + args: [{ kind: "dynArrLit", elems: values, type: DYN, loc }], + type: DYN, + loc, + }; +} + +const STATIC_CALLS: Record< + string, + { cls: NetAddressClass; fn: IrLibFn; result: typeof DYN | typeof BOOL } +> = { + parse: { cls: "SocketAddress", fn: "net.socketAddressParse", result: DYN }, + isSocketAddress: { cls: "SocketAddress", fn: "net.isSocketAddress", result: BOOL }, + isBlockList: { cls: "BlockList", fn: "net.isBlockList", result: BOOL }, +}; + +const INSTANCE_METHODS: Record> = { + SocketAddress: new Set(["toJSON"]), + BlockList: new Set([ + "addAddress", + "addAddresses", + "addRange", + "addSubnet", + "addCIDR", + "addCIDRs", + "removeAddress", + "removeRange", + "removeSubnet", + "removeCIDR", + "clear", + "check", + "toJSON", + "fromJSON", + ]), +}; + +/** SocketAddress.parse(), SocketAddress.isSocketAddress() and + * BlockList.isBlockList(); and the instance methods, which the handle + * answers as methods, so they dispatch through dynInvoke rather than as + * a member read and a call. */ +export function lowerNetAddressCall(lowerer: Lowerer, call: ts.CallExpression): IrExpr | null { + if (call.questionDotToken || !ts.isPropertyAccessExpression(call.expression)) return null; + const access = call.expression; + if (access.questionDotToken || call.arguments.some(ts.isSpreadElement)) return null; + const name = access.name.text; + const loc = locOf(call); + const statics = Object.hasOwn(STATIC_CALLS, name) ? STATIC_CALLS[name]! : null; + if (statics !== null && netAddressClassOf(lowerer, access.expression) === statics.cls) { + // The member takes one value; any others still evaluate, in order. + const args = call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)); + const first: IrExpr = args[0] ?? dynUndefinedExpr(loc); + const result: IrExpr = { + kind: "libCall", + fn: statics.fn, + args: [first], + type: statics.result, + loc, + }; + if (args.length <= 1) return result; + const local = lowerer.declareHiddenLocal("%netAddressResult", statics.result); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: local.id, init: result, loc }, + ...args.slice(1).map((arg) => ({ kind: "exprStmt" as const, expr: arg, loc: arg.loc })), + ], + result: { kind: "varRef", localId: local.id, type: statics.result, loc }, + type: statics.result, + loc, + }; + } + const cls = netAddressValueClass(lowerer, access.expression); + if (cls === null || !INSTANCE_METHODS[cls].has(name)) return null; + return { + kind: "dynInvoke", + recv: lowerer.lowerExprExpecting(access.expression, DYN), + method: name, + calleeName: access.getText(), + args: call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; +} + +/** `BlockList.PRIVATE_RANGES`: one frozen array of CIDR strings. */ +export function lowerNetAddressStaticRead( + lowerer: Lowerer, + expr: ts.PropertyAccessExpression, +): IrExpr | null { + if (expr.questionDotToken || expr.name.text !== "PRIVATE_RANGES") return null; + if (netAddressClassOf(lowerer, expr.expression) !== "BlockList") return null; + return { + kind: "libCall", + fn: "net.blockListPrivateRanges", + args: [], + type: DYN, + loc: locOf(expr), + }; +} + +/** `v instanceof SocketAddress` / `v instanceof BlockList`. */ +export function lowerNetAddressInstanceof( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const cls = netAddressClassOf(lowerer, expr.right); + if (cls === null) return null; + const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); + return { + kind: "libCall", + fn: cls === "SocketAddress" ? "net.isSocketAddress" : "net.isBlockList", + args: [value], + type: BOOL, + loc: locOf(expr), + }; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts index 051981f09..b88b97eb6 100644 --- a/packages/compiler/src/frontend/lowering/builtins/operating-system.ts +++ b/packages/compiler/src/frontend/lowering/builtins/operating-system.ts @@ -10,6 +10,7 @@ import { STRING, type SrcLoc, UNDEFINED_T, + VOID, arrayOf, typeKey, } from "../../../ir/ir.js"; @@ -511,6 +512,29 @@ export function lowerPathFormatCall( return { kind: "call", callee: helper, args: [value], type: STRING, loc }; } +/** `os.setPriority([pid, ]priority)` — the one-argument form is pid 0 + * (Node's `if (priority === undefined) { priority = pid; pid = 0; }`); + * the runtime validates both. */ +export function lowerOsSetPriorityCall( + lowerer: Lowerer, + call: ts.CallExpression, + loc: SrcLoc, +): IrExpr { + const args = call.arguments; + if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + "setPriority with these arguments", + call, + "setPriority(priority) or setPriority(pid, priority) takes numbers", + ); + } + const [pid, priority] = + args.length === 1 + ? [numLit(0, loc), lowerer.lowerExprExpecting(args[0]!, F64)] + : [lowerer.lowerExprExpecting(args[0]!, F64), lowerer.lowerExprExpecting(args[1]!, F64)]; + return { kind: "libCall", fn: "os.setPriority", args: [pid, priority], type: VOID, loc }; +} + /** `os.getPriority(pid?)` — an omitted or undefined pid is 0 (Node's * `if (pid === undefined) pid = 0`); the runtime validates the rest. */ export function lowerOsGetPriorityCall( 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/builtins/promises.ts b/packages/compiler/src/frontend/lowering/builtins/promises.ts index 5fd189242..2dc212f81 100644 --- a/packages/compiler/src/frontend/lowering/builtins/promises.ts +++ b/packages/compiler/src/frontend/lowering/builtins/promises.ts @@ -279,25 +279,39 @@ export function lowerPromiseStaticCall( // tick-for-tick in Node for plain results (probed), with the // promise-returning form riding the async return-adoption machinery // (its one-tick residue is SEMANTICS.md 358) — so the lowering IS - // that wrapper: one interned async helper per callback type. The - // ...args form fences (close over the values instead). + // that wrapper: one interned async helper per callback and argument + // types. `Promise.try(f, ...args)` evaluates the arguments at the call + // and passes them to f, which still runs synchronously. if (member === "try") { const fNode = call.arguments[0]; - if (call.arguments.length !== 1 || fNode === undefined || ts.isSpreadElement(fNode)) { + const argNodes = call.arguments.slice(1); + if (fNode === undefined || call.arguments.some(ts.isSpreadElement)) { lowerer.noLowering( - `Promise.try with ${call.arguments.length} arguments`, + `Promise.try with ${call.arguments.length === 0 ? "no" : "spread"} arguments`, call, - "the ...args form has no lowering — close over the values: Promise.try(() => f(a, b))", + "pass the callback and its arguments directly: Promise.try(f, a, b)", ); } const f = lowerer.lowerExpr(fNode); - if (f.type.kind !== "func" || f.type.params.length !== 0) { + // Arguments past the callback's parameters are evaluated and dropped; + // only an arrow function, which has no `arguments`, can ignore them. + if ( + f.type.kind !== "func" || + f.type.params.length > argNodes.length || + (f.type.params.length < argNodes.length && !ts.isArrowFunction(fNode)) + ) { lowerer.noLowering( - `Promise.try over a callback of type '${lowerer.checker.typeToString(lowerer.typeOf(fNode))}'`, + `Promise.try over a callback of type '${lowerer.checker.typeToString(lowerer.typeOf(fNode))}' with ${argNodes.length} argument${argNodes.length === 1 ? "" : "s"}`, fNode, - "a zero-parameter function is the supported form", + "pass one argument per callback parameter: Promise.try((a, b) => a + b, 1, 2)", ); } + const fnT = f.type; + const args = argNodes.map((node, i) => + i < fnT.params.length + ? lowerer.lowerExprExpecting(node, fnT.params[i]!) + : lowerer.lowerExpr(node), + ); const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); if (resultT?.kind !== "promise") { lowerer.noLowering( @@ -323,13 +337,22 @@ export function lowerPromiseStaticCall( "annotate both to one T: Promise.try(f) with f returning T or Promise", ); } - const key = `promise.try:${typeKey(f.type)}`; + const key = `promise.try:${typeKey(f.type)}:${args.map((a) => typeKey(a.type)).join(",")}`; let helper = lowerer.arrHofHelpers.get(key); if (!helper) { helper = `%promise.try.${lowerer.arrHofHelpers.size}`; lowerer.arrHofHelpers.set(key, helper); const fRef: IrExpr = { kind: "varRef", localId: "f.0", type: f.type, loc }; - const callF: IrExpr = { kind: "callValue", callee: fRef, args: [], type: ret, loc }; + const argLocals = args.map((a, i) => ({ id: `a${i}.0`, name: `a${i}`, type: a.type })); + const callF: IrExpr = { + kind: "callValue", + callee: fRef, + args: argLocals + .slice(0, fnT.params.length) + .map((a) => ({ kind: "varRef" as const, localId: a.id, type: a.type, loc })), + type: ret, + loc, + }; // Adopt a promise result before either returning its value or completing void. const value: IrExpr = ret.kind === "promise" ? { kind: "awaitExpr", value: callF, type: ret.inner, loc } : callF; @@ -342,16 +365,22 @@ export function lowerPromiseStaticCall( : [{ kind: "return", value, loc }]; const lifted: IrFunction = { name: helper, - params: [{ localId: "f.0", name: "f", type: f.type }], + params: [ + { localId: "f.0", name: "f", type: f.type }, + ...argLocals.map((a) => ({ localId: a.id, name: a.name, type: a.type })), + ], returnType: inner, - locals: [{ id: "f.0", name: "f", type: f.type, mutable: true }], + locals: [ + { id: "f.0", name: "f", type: f.type, mutable: true }, + ...argLocals.map((a) => ({ ...a, mutable: true })), + ], body, loc, async: true, }; lowerer.liftedFns.push(lifted); } - return { kind: "call", callee: helper, args: [f], type: resultT, loc }; + return { kind: "call", callee: helper, args: [f, ...args], type: resultT, loc }; } // Promise.resolve — the already-settled promise. Zero arguments is // Promise; a PROMISE argument is returned as-is (the spec's diff --git a/packages/compiler/src/frontend/lowering/builtins/timers.ts b/packages/compiler/src/frontend/lowering/builtins/timers.ts index ab310fe52..98624dc4a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/timers.ts +++ b/packages/compiler/src/frontend/lowering/builtins/timers.ts @@ -4,6 +4,7 @@ import { locOf } from "../../program.js"; import { generatorMeta } from "../call-signatures.js"; import { BOOL, + DYN, F64, type IrExpr, type IrFunction, @@ -223,8 +224,8 @@ export function lowerTimeoutMethodCall( // (scr_timer_coerce_ms clamps anyway; the literal keeps the emitted call // self-describing). A resolve value travels as an already-resolved promise // of its own type, whose settlement the result copies when it fires. The -// options object takes `ref`; `signal` fences — a promise that ignored its -// cancellation signal would hold the loop open where Node exits. +// options object takes `ref` and `signal`; with a signal the runtime +// clears the timer and rejects with an AbortError when it aborts. export function lowerTimersPromisesCall( lowerer: Lowerer, expr: ts.CallExpression, @@ -232,6 +233,8 @@ export function lowerTimersPromisesCall( loc: SrcLoc, ): IrExpr | null { const promiseVoid: IrType = { kind: "promise", inner: VOID }; + if (bi.member === "scheduler.wait" || bi.member === "scheduler.yield") + return lowerSchedulerCall(lowerer, expr, bi.member, loc); if (bi.member !== "setTimeout" && bi.member !== "setImmediate") return null; const timeout = bi.member === "setTimeout"; const [valueNode, optionsNode] = timeout ? expr.arguments.slice(1) : expr.arguments; @@ -273,7 +276,16 @@ export function lowerTimersPromisesCall( type: resultT!, loc, }; - const ref = timersPromisesRef(lowerer, bi.member, optionsNode, loc); + const { ref, signal } = timersPromisesOptions(lowerer, bi.member, optionsNode, loc); + if (signal !== null) { + return { + kind: "libCall", + fn: timeout ? "tp.setTimeoutSignal" : "tp.setImmediateSignal", + args: timeout ? [ms!, value, ref, signal] : [value, ref, signal], + type: voidValue ? promiseVoid : resultT!, + loc, + }; + } return { kind: "libCall", fn: timeout ? "tp.setTimeoutValue" : "tp.setImmediateValue", @@ -283,42 +295,85 @@ export function lowerTimersPromisesCall( }; } -/** The `ref` option of a timers/promises options object literal (true when - * absent). `signal` and options that are not an object literal fence. */ -function timersPromisesRef( +/** scheduler.wait(delay[, options]) and scheduler.yield(): Node's Scheduler + * methods return setTimeout(delay, undefined, options) and setImmediate(), + * so both settle a void promise from the same timer heap and immediate + * queue. The options object takes `ref` and `signal` as setTimeout's does. */ +function lowerSchedulerCall( + lowerer: Lowerer, + expr: ts.CallExpression, + member: "scheduler.wait" | "scheduler.yield", + loc: SrcLoc, +): IrExpr { + 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: NaN, type: F64, loc }; + if (optionsNode === undefined) + return { kind: "libCall", fn: "tp.schedulerWait", args: [ms], type: promiseVoid, loc }; + const { ref, signal } = timersPromisesOptions(lowerer, member, optionsNode, loc); + const value: IrExpr = { + kind: "intrinsic", + name: "promise.resolve", + args: [], + type: promiseVoid, + loc, + }; + return { + kind: "libCall", + fn: signal === null ? "tp.schedulerWaitValue" : "tp.schedulerWaitSignal", + args: signal === null ? [ms, value, ref] : [ms, value, ref, signal], + type: promiseVoid, + loc, + }; +} + +/** The `ref` (true when absent) and `signal` (null when absent) options of + * a timers/promises options object literal, in source order. Options that + * are not an object literal fence. */ +function timersPromisesOptions( lowerer: Lowerer, member: string, optionsNode: ts.Expression | undefined, loc: SrcLoc, -): IrExpr { +): { ref: IrExpr; signal: IrExpr | null } { const absent: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; - if (optionsNode === undefined) return absent; + if (optionsNode === undefined) return { ref: absent, signal: null }; if (!ts.isObjectLiteralExpression(optionsNode)) { lowerer.noLowering( `timers/promises ${member} options that are not an object literal`, optionsNode, - "pass the options as an object literal: { ref }", + "pass the options as an object literal: { ref, signal }", ); - return absent; + return { ref: absent, signal: null }; } let ref: IrExpr = absent; + let signal: IrExpr | null = null; for (const prop of optionsNode.properties) { const name = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : null; - if (name === "signal") { - lowerer.noLowering( - `timers/promises ${member} with an AbortSignal`, - prop, - "a promise that ignored its cancellation signal would keep the process alive where Node exits", + if ( + name === "signal" && + (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) + ) { + signal = lowerer.lowerExprExpecting( + ts.isPropertyAssignment(prop) ? prop.initializer : (prop.name as ts.Identifier), + DYN, ); } else if (name === "ref" && ts.isPropertyAssignment(prop)) { ref = lowerer.lowerExprExpecting(prop.initializer, BOOL); } else { lowerer.noLowering( - `timers/promises ${member} options other than ref`, + `timers/promises ${member} options other than ref and signal`, prop, - "the lowered options object takes ref: boolean", + "the lowered options object takes ref: boolean and signal: AbortSignal", ); } } - return ref; + return { ref, signal }; } diff --git a/packages/compiler/src/frontend/lowering/builtins/url.ts b/packages/compiler/src/frontend/lowering/builtins/url.ts index 6e3beb9c2..daf857650 100644 --- a/packages/compiler/src/frontend/lowering/builtins/url.ts +++ b/packages/compiler/src/frontend/lowering/builtins/url.ts @@ -26,6 +26,7 @@ import { } from "../../../ir/ir.js"; import { optionalStringTags } from "./arguments.js"; import { type BuiltinModuleFn } from "../surfaces.js"; +import { lowerDynDispatchMethodCall } from "../lower-calls.js"; /** URL component assignment captures its receiver and RHS once, and yields * the original RHS after the setter performs string conversion. */ @@ -255,10 +256,12 @@ export function lowerSearchParamsNew( return { kind: "libCall", fn: "sp.fromPairs", args: [init], type: SEARCH_PARAMS_T, loc }; } if (init.type.kind === "array" && init.type.elem.kind === "record") { + const pairs = searchParamsFromTuples(lowerer, init, init.type.elem, loc); + if (pairs) return pairs; lowerer.unsupported( "SC1090", arg, - "URLSearchParams from tuple-typed pairs (type the pairs as string[][] — the tuple rows have a different layout)", + "URLSearchParams from tuple rows other than [string, string] (type the pairs as [string, string][] or string[][])", ); } lowerer.unsupported( @@ -268,6 +271,97 @@ export function lowerSearchParamsNew( ); } +/** `new URLSearchParams(pairs)` over a `[string, string][]` (an + * Object.entries result, for example): a helper per tuple shape appends + * each pair's name and value in array order. Null for other row types. */ +function searchParamsFromTuples( + lowerer: Lowerer, + init: IrExpr, + row: IrType & { kind: "record" }, + loc: SrcLoc, +): IrExpr | null { + const shape = lowerer.shapes.get(row.shapeId); + if ( + !shape?.tuple || + shape.fields.length !== 2 || + !shape.fields.every( + (field) => (field.name === "0" || field.name === "1") && field.type.kind === "string", + ) + ) + return null; + const key = `sp.fromTuples:${row.shapeId}`; + let helper = lowerer.valueHelpers.get(key); + if (!helper) { + helper = `%sp.fromTuples.${lowerer.valueHelpers.size}`; + lowerer.valueHelpers.set(key, helper); + const pairsT = init.type; + const sp = (): IrExpr => varRef("sp.0", SEARCH_PARAMS_T, loc); + const pair = (): IrExpr => varRef("pair.0", row, loc); + const field = (name: "0" | "1"): IrExpr => ({ + kind: "recordGet", + obj: pair(), + shapeId: row.shapeId, + field: name, + type: STRING, + loc, + }); + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: "sp.0", + init: { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc }, + loc, + }, + countedFor( + loc, + { + kind: "arrIntrinsic", + method: "length", + receiver: varRef("pairs.0", pairsT, loc), + args: [], + type: F64, + loc, + }, + (index) => [ + { + kind: "varDecl", + localId: "pair.0", + init: { kind: "arrayGet", arr: varRef("pairs.0", pairsT, loc), index, type: row, loc }, + loc, + }, + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "sp.append", + args: [sp(), field("0"), field("1")], + type: VOID, + loc, + }, + loc, + }, + ], + ), + { kind: "return", value: sp(), loc }, + ]; + const fn: IrFunction = { + name: helper, + params: [{ localId: "pairs.0", name: "pairs", type: pairsT }], + returnType: SEARCH_PARAMS_T, + locals: [ + { id: "pairs.0", name: "pairs", type: pairsT, mutable: false }, + { id: "sp.0", name: "sp", type: SEARCH_PARAMS_T, mutable: false }, + { id: "i.0", name: "i", type: F64, mutable: true }, + { id: "pair.0", name: "pair", type: row, mutable: false }, + ], + body, + loc, + }; + lowerer.liftedFns.push(fn); + } + return { kind: "call", callee: helper, args: [init], type: SEARCH_PARAMS_T, loc }; +} + function lowerOptionalStringSearchParams( lowerer: Lowerer, init: IrExpr, @@ -534,12 +628,14 @@ export function lowerSearchParamsMethodCall( if (name === "forEach" && args.length === 1) { return lowerSpForEachCall(lowerer, call, access); } - if (name === "keys" || name === "values" || name === "entries") { - lowerer.unsupported( - "SC1090", - call, - `URLSearchParams iterator objects outside a for-of head (write \`for (const x of sp.${name}())\` directly)`, - ); + if ((name === "keys" || name === "values" || name === "entries") && args.length === 0) { + // Outside a for-of head the iterator is a live object: the checked- + // dynamic view's iterator walks the pair list by index + // (scr_native_sp_step), so entries appended after it was created are + // visited, as in Node. + const boxed = lowerer.coerceInto(access.expression, lowerer.lowerExpr(access.expression), DYN); + const iterator = lowerDynDispatchMethodCall(lowerer, call, access, boxed, false); + if (iterator) return iterator; } lowerer.noLowering( `URLSearchParams.${name}`, @@ -751,3 +847,21 @@ export function lowerLegacyUrlCall( } return { kind: "libCall", fn: fn.fn, args, type: fn.result, loc }; } + +/** A URLSearchParams value, or a keys()/values()/entries() call on one: an + * iterable whose checked-dynamic view walks the live pair list. */ +export function isSearchParamsIterable(lowerer: Lowerer, node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if (lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "searchParams") return true; + if ( + !ts.isCallExpression(node) || + node.arguments.length !== 0 || + !ts.isPropertyAccessExpression(node.expression) + ) + return false; + const name = node.expression.name.text; + return ( + (name === "keys" || name === "values" || name === "entries") && + lowerer.mapTypeOf(lowerer.typeOf(node.expression.expression))?.kind === "searchParams" + ); +} diff --git a/packages/compiler/src/frontend/lowering/builtins/util-types.ts b/packages/compiler/src/frontend/lowering/builtins/util-types.ts index 979a996d8..4399c730e 100644 --- a/packages/compiler/src/frontend/lowering/builtins/util-types.ts +++ b/packages/compiler/src/frontend/lowering/builtins/util-types.ts @@ -12,6 +12,22 @@ export function lowerUtilTypeCall( ): IrExpr { if (expr.arguments.some(ts.isSpreadElement)) lowerer.noLowering(`${name} with spread arguments`, expr); + const first = expr.arguments[0]; + if (first !== undefined && lowerer.mapTypeOf(lowerer.typeOf(first))?.kind === "date") { + // A Date value has no checked-dynamic form; its static type decides + // every brand. The arguments still evaluate in order. + return { + kind: "seqExpr", + stmts: expr.arguments.map((arg): IrStmt => ({ + kind: "exprStmt", + expr: lowerer.lowerExpr(arg), + loc, + })), + result: { kind: "boolLit", value: bi.member === "isDate", type: BOOL, loc }, + type: BOOL, + loc, + }; + } const input = expr.arguments[0] ? lowerer.lowerExprExpecting(expr.arguments[0], DYN) : dynUndefinedExpr(loc); diff --git a/packages/compiler/src/frontend/lowering/builtins/workers.ts b/packages/compiler/src/frontend/lowering/builtins/workers.ts index 3144f208f..2311e0f96 100644 --- a/packages/compiler/src/frontend/lowering/builtins/workers.ts +++ b/packages/compiler/src/frontend/lowering/builtins/workers.ts @@ -9,6 +9,7 @@ import { F64, STRING, UNDEFINED_T, + VOID, isFfiCallbackParam, type IrExpr, type SrcLoc, @@ -27,6 +28,10 @@ export function lowerWorkerMetadata(member: string, loc: SrcLoc): IrExpr | null return { kind: "libCall", fn: "worker.parentPort", args: [], type: DYN, loc }; case "isInternalThread": return { kind: "boolLit", value: false, type: BOOL, loc }; + case "resourceLimits": + return { kind: "libCall", fn: "worker.resourceLimits", args: [], type: DYN, loc }; + case "threadName": + return { kind: "libCall", fn: "worker.threadName", args: [], type: DYN, loc }; default: return null; } @@ -76,6 +81,7 @@ export function lowerWorkerNew(lowerer: Lowerer, expression: ts.NewExpression): "native callbacks in worker executables must run on their owning script thread", ); } + refuseHeapLimits(lowerer, args[1]); lowerer.usesWorkers = true; const loc = locOf(expression); const options: IrExpr = args[1] @@ -86,7 +92,7 @@ export function lowerWorkerNew(lowerer: Lowerer, expression: ts.NewExpression): type: DYN, loc, }; - return { + const create: IrExpr = { kind: "libCall", fn: "worker.new", args: [ @@ -97,6 +103,70 @@ export function lowerWorkerNew(lowerer: Lowerer, expression: ts.NewExpression): type: DYN, loc, }; + if (!capturesStdio(args[1])) return create; + return { + kind: "seqExpr", + stmts: [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "stream.workerStdio", args: [], type: VOID, loc }, + loc, + }, + ], + result: create, + type: DYN, + loc, + }; +} + +/** True when the options are not a literal, or a literal whose stdout, + * stderr or stdin member is not the literal false: worker.stdout, + * worker.stderr and worker.stdin then need the stream unit. */ +function capturesStdio(options: ts.Expression | undefined): boolean { + if (!options) return false; + if (!ts.isObjectLiteralExpression(options)) return true; + return options.properties.some((option) => { + if (ts.isSpreadAssignment(option)) return true; + if (!option.name) return false; + const name = propertyNameText(option.name); + if (name !== "stdout" && name !== "stderr" && name !== "stdin") return name === undefined; + return !( + ts.isPropertyAssignment(option) && option.initializer.kind === ts.SyntaxKind.FalseKeyword + ); + }); +} + +const HEAP_LIMITS = new Set([ + "maxOldGenerationSizeMb", + "maxYoungGenerationSizeMb", + "codeRangeSizeMb", +]); + +/** resourceLimits' V8 heap fields, when an options literal spells them + * with numbers (Node ignores other values; the runtime refuses the rest). */ +function refuseHeapLimits(lowerer: Lowerer, options: ts.Expression | undefined): void { + if (!options || !ts.isObjectLiteralExpression(options)) return; + for (const option of options.properties) { + if (!ts.isPropertyAssignment(option) || propertyNameText(option.name) !== "resourceLimits") + continue; + if (!ts.isObjectLiteralExpression(option.initializer)) continue; + for (const limit of option.initializer.properties) { + if (!ts.isPropertyAssignment(limit)) continue; + const name = propertyNameText(limit.name); + if (name === undefined || !HEAP_LIMITS.has(name)) continue; + const type = lowerer.checker.getTypeAtLocation(limit.initializer); + if (!(type.flags & ts.TypeFlags.NumberLike)) continue; + lowerer.noLowering( + `Worker resourceLimits.${name}`, + limit, + "worker threads have no separate V8 heap; resourceLimits supports stackSizeMb", + ); + } + } +} + +function propertyNameText(name: ts.PropertyName): string | undefined { + return ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; } /** True when `node`'s type is worker_threads' `name` class (also reached @@ -166,3 +236,142 @@ export function lowerReceiveMessageOnPort(lowerer: Lowerer, call: ts.CallExpress loc, }; } + +/** new BroadcastChannel(name): the EventTarget unit's half installs + * first, then the channel opens (the name converts at runtime). */ +export function lowerBroadcastChannelNew( + lowerer: Lowerer, + expression: ts.NewExpression, +): IrExpr | null { + if (!isWorkerThreadsClass(lowerer, expression, "BroadcastChannel")) return null; + const args = expression.arguments ?? []; + if (args.length > 1 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + `BroadcastChannel constructor with ${args.length} arguments`, + expression, + "use new BroadcastChannel(name)", + ); + } + if (lowerer.targetPlatform === "wasi") { + lowerer.noLowering( + "BroadcastChannel in this target", + expression, + "use a native static executable", + ); + } + lowerer.usesWorkers = true; + const loc = locOf(expression); + return { + kind: "seqExpr", + stmts: [ + { + kind: "exprStmt", + expr: { kind: "libCall", fn: "event.broadcastInstall", args: [], type: VOID, loc }, + loc, + }, + ], + result: { + kind: "libCall", + fn: "worker.broadcastChannel", + args: [ + { + kind: "dynArrLit", + elems: args.map((a) => lowerer.lowerExprExpecting(a, DYN)), + type: DYN, + loc, + }, + ], + type: DYN, + loc, + }, + type: DYN, + loc, + }; +} + +/** setEnvironmentData(key, value?) and getEnvironmentData(key): this + * thread's map, cloned into each Worker it constructs. */ +export function lowerEnvironmentData( + lowerer: Lowerer, + call: ts.CallExpression, + member: "setEnvironmentData" | "getEnvironmentData", +): IrExpr { + const args = call.arguments; + const arity = member === "setEnvironmentData" ? 2 : 1; + if (args.length > arity || args.some(ts.isSpreadElement)) { + lowerer.noLowering(`${member} with ${args.length} arguments`, call); + } + if (lowerer.targetPlatform === "wasi") { + lowerer.noLowering(`${member} in this target`, call, "use a native static executable"); + } + lowerer.usesWorkers = true; + const loc = locOf(call); + const lowered: IrExpr[] = []; + for (let i = 0; i < arity; i++) { + lowered.push( + args[i] + ? lowerer.lowerExprExpecting(args[i]!, DYN) + : { + kind: "dynFrom", + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: DYN, + loc, + }, + ); + } + return { + kind: "libCall", + fn: member === "setEnvironmentData" ? "worker.setEnvironmentData" : "worker.getEnvironmentData", + args: lowered, + type: member === "setEnvironmentData" ? VOID : DYN, + loc, + }; +} + +/** 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( + lowerer: Lowerer, + call: ts.CallExpression, + member: "markAsUntransferable" | "isMarkedAsUntransferable", +): IrExpr { + const args = call.arguments; + if (args.length > 1 || args.some(ts.isSpreadElement)) { + lowerer.noLowering(`${member} with ${args.length} arguments`, call); + } + if (lowerer.targetPlatform === "wasi") { + lowerer.noLowering(`${member} in this target`, call, "use a native static executable"); + } + lowerer.usesWorkers = true; + 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 member === "markAsUntransferable" + ? { kind: "libCall", fn: "worker.markUntransferable", args: [value], type: VOID, loc } + : { kind: "libCall", fn: "worker.isMarkedUntransferable", args: [value], type: BOOL, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 0499223f1..8e30e8b82 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -123,6 +123,24 @@ export function generatorMeta( return { yieldT: type.yieldT, nextT: type.nextT, resultType }; } +/** A parameter with a type annotation or a JSDoc `@param`/`@type` tag. */ +export function documentedParameter(param: ts.ParameterDeclaration): boolean { + return ( + param.type !== undefined || + /@(?:param|type)\b/.test( + param.getSourceFile().text.slice(param.parent?.pos ?? param.pos, param.getStart()), + ) + ); +} + +/** A documented parameter in a JavaScript file TypeScript checks. */ +export function checkedJsDocumentedParameter( + lowerer: Lowerer, + param: ts.ParameterDeclaration, +): boolean { + return lowerer.checkedJsFile(param.getSourceFile()) && documentedParameter(param); +} + /** One parameter's ParamShape — the shared signature-shaped collection * point for function declarations, methods, constructors, and lambdas * (generic declarations defer to their call sites, where the resolved @@ -147,11 +165,14 @@ export function paramShape(lowerer: Lowerer, param: ts.ParameterDeclaration): Pa if ( isJsSourceFile(param.getSourceFile()) && ts.isConstructorDeclaration(param.parent) && - !param.dotDotDotToken + !param.dotDotDotToken && + !checkedJsDocumentedParameter(lowerer, param) ) { // Reflective JavaScript construction can supply values outside the // constructor's documented usual types. Retain the actual arguments // and apply checked conversions only at operations that need them. + // In a file TypeScript checks, documented types are enforced like + // TypeScript annotations. return param.initializer ? { type: DYN, mode: "omittable", bodyType: DYN } : { type: DYN, mode: param.questionToken ? "omittable" : "required" }; @@ -722,7 +743,7 @@ export function lambdaSignature( return { shapes, funcType: funcTypeFromParamShapes(shapes, DYN) }; } } - if (node.typeParameters) { + if (node.typeParameters && !lowerer.erasingLambdas.has(node)) { // Generic function-like forms monomorphize only where a static home // exists: top-level generic function declarations, generic methods // (class and object-literal), and module-scope never-reassigned diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts index da40f361b..859938845 100644 --- a/packages/compiler/src/frontend/lowering/class-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -14,12 +14,14 @@ import { isJsSourceFile, locOf } from "../program.js"; import type { Lowerer } from "./lowerer.js"; import { findMethodOn, type ClassInfo } from "./lower-classes.js"; import { classPropertiesHelper } from "./class-dynamic-dispatch.js"; -import { classPrototypeData } from "./class-prototypes.js"; +import { observedPrototypeMethod } from "./class-prototypes.js"; import { checkedIterableSpread } from "./checked-iterable-spread.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; /** Unmodified typed methods retain direct or virtual native dispatch. - * JavaScript notification methods keep their checked property fallback. */ + * JavaScript notification methods keep their checked property fallback, + * except in files TypeScript checks, whose methods join the method census + * like TypeScript methods. */ export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string): boolean { if (!isClassOwnEnumerableFieldName(name) || name.startsWith("get:") || name.startsWith("set:")) return false; @@ -38,11 +40,22 @@ export function isClassCallback(lowerer: Lowerer, info: ClassInfo, name: string) (lowerer.prototypeMethodAccesses.has(name) || (info.decl !== null && isJsSourceFile(info.decl.getSourceFile()) && + !lowerer.checkedJsFile(info.decl.getSourceFile()) && found.sig.params.length === 0 && found.sig.ret.kind === "void")) ); } +/** The census marked `name` and the receiver's class keeps the method in + * its prototype data, so the property bag answers every read. */ +function observedSlot(lowerer: Lowerer, receiver: IrExpr, name: string, loc: SrcLoc): boolean { + const info = + receiver.type.kind === "object" ? lowerer.classes.get(receiver.type.className) : undefined; + return info + ? observedPrototypeMethod(lowerer, info, name, loc, receiver) + : lowerer.prototypeMethodAccesses.has(name); +} + function callbackBag( lowerer: Lowerer, receiver: IrExpr, @@ -50,9 +63,8 @@ function callbackBag( name: string, readOnly = false, ): IrExpr { - const info = - receiver.type.kind === "object" ? lowerer.classes.get(receiver.type.className) : null; - if (info && lowerer.prototypeMethodAccesses.has(name)) classPrototypeData(lowerer, info, loc); + // Builds the prototype data that an observed read falls back to. + observedSlot(lowerer, receiver, name, loc); return { kind: "call", callee: classPropertiesHelper(lowerer, loc, readOnly).name, @@ -116,7 +128,7 @@ export function classCallbackValue( const bag = lowerer.declareHiddenLocal("%callbackBag", DYN); const value = varRef(bag.id, DYN, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; - const result: IrExpr = lowerer.prototypeMethodAccesses.has(name) + const result: IrExpr = observedSlot(lowerer, receiver, name, loc) ? { kind: "dynKeyGet", value, key, type: DYN, loc } : { kind: "ternary", @@ -173,7 +185,8 @@ export function classCallbackCall( type: DYN, loc, }; - const result: IrExpr = lowerer.prototypeMethodAccesses.has(name) + const observed = observedSlot(lowerer, receiver, name, loc); + const result: IrExpr = observed ? call : { kind: "ternary", @@ -206,7 +219,7 @@ export function classCallbackCall( { kind: "varDecl", localId: present.id, - init: lowerer.prototypeMethodAccesses.has(name) + init: observed ? { kind: "boolLit", value: true, type: BOOL, loc } : callbackPresent(value, key, loc), loc, diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 102ab79d1..444a9cf23 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1,4 +1,4 @@ -import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, helperLoc, nodeThrowExpr, varRef } from "../../ir/build.js"; import { eventTargetApiMembers, eventTargetInheritedMember } from "./lower-event-target.js"; import { BOOL, @@ -24,7 +24,7 @@ import { import { streamTypedRefEligible } from "../../ir/analysis.js"; import { everyStmtList, transformExpr, transformStmtList } from "../../ir/traverse.js"; import { PoisonError, type Lowerer } from "./lowerer.js"; -import { implicitDefaultInstance } from "./generic-functions.js"; +import { erasedGenericInstance, implicitDefaultInstance } from "./generic-functions.js"; import { type ParamShape } from "./call-signatures.js"; import { accessorCall, @@ -40,6 +40,7 @@ import { import { classPrototypeData, hasClassPrototypeData, + observedPrototypeMethod, reflectClassPrototype, } from "./class-prototypes.js"; import { @@ -67,6 +68,7 @@ import { ClassConstructionDispatch } from "./class-construction.js"; import { emitterRooted } from "./lower-event-emitter.js"; import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js"; import { STREAM_OPERATORS } from "./lower-stream.js"; +import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js"; const STREAM_METHODS = new Set([ "on", @@ -157,6 +159,81 @@ interface Dispatch { classes: Set; } +type ToPrimitiveHint = "default" | "number" | "string"; +type ConversionMethod = "valueOf" | "toString" | "sym:toPrimitive"; +const CONVERSION_CALL_PREFIX = "%conversionCall."; +type Hoisted = { stmts: IrStmt[]; value: IrExpr }; +const TO_PRIMITIVE_PREFIX = "%dyn.class.toPrimitive."; + +/** Operations that convert their dynamic operands with ToPrimitive: `+` + * and `==` take the default hint, the arithmetic, bitwise, relational and + * numeric conversions the number hint, and the string conversions the + * string hint. */ +const DEFAULT_HINT_CALLS = new Set(["dyn.abstractEq", "dyn.add"]); +const NUMBER_HINT_CALLS = new Set([ + "dyn.arithmetic", + "dyn.bigintConstructor", + "dyn.bitwise", + "dyn.compare", + "dyn.numberConstructor", + "dyn.toNumberCoerce", + "dyn.toNumeric", +]); +const STRING_HINT_CALLS = new Set(["dyn.stringConstructor", "dyn.toStringCoerce"]); +/** Operations whose second operand is a property key, converted with the + * string hint when the receiver admits a key: `in` needs an object, + * propertyIsEnumerable converts before checking its receiver, and the + * others need a receiver other than null and undefined. */ +const KEY_CALLS = new Map([ + ["dyn.hasKeyComputed", "object"], + ["dyn.propertyIsEnumerableComputed", "any"], + ["dyn.hasOwnComputed", "value"], + ["dyn.keyDeleteComputed", "value"], + ["dyn.keySetComputed", "value"], +]); + +function toPrimitiveHint(expr: IrExpr): ToPrimitiveHint | null { + if (expr.kind === "toString") return expr.operand.type.kind === "dyn" ? "string" : null; + if (expr.kind === "dynKeyGet") return expr.key.type.kind === "dyn" ? "string" : null; + if (expr.kind !== "libCall") return null; + if (DEFAULT_HINT_CALLS.has(expr.fn)) return "default"; + if (NUMBER_HINT_CALLS.has(expr.fn)) return "number"; + if (STRING_HINT_CALLS.has(expr.fn) || KEY_CALLS.has(expr.fn)) return "string"; + return null; +} + +/** An operand that needs no conversion: already passed through a + * conversion helper (loose equality converts conditionally), or boxed from + * a statically primitive value. */ +function isConversion(expr: IrExpr): boolean { + if (expr.kind === "ternary") return isConversion(expr.then); + if (expr.kind === "dynFrom") return PRIMITIVE_KINDS.has(expr.value.type.kind); + return expr.kind === "call" && expr.callee.startsWith(TO_PRIMITIVE_PREFIX); +} + +const PRIMITIVE_KINDS = new Set([ + "f64", + "string", + "bool", + "bigint", + "symbol", + "undefinedT", + "nullT", +]); + +/** A declared conversion method, own or inherited. */ +function conversionMethod(lowerer: Lowerer, info: ClassInfo, method: ConversionMethod): boolean { + if (info.fields.has(method)) return false; + const found = findMethodOn(lowerer, info, method); + return !!found && !found.declarer.builtinError; +} + +function isPrimitive(value: IrExpr, loc: SrcLoc): IrExpr { + return (["number", "string", "boolean", "undefined", "null", "bigint", "symbol"] as const) + .map((test): IrExpr => ({ kind: "dynTest", test, value, type: BOOL, loc })) + .reduce((a, b) => ({ kind: "logical", op: "||", left: a, right: b, type: BOOL, loc })); +} + interface PropertyReceiver { info: ClassInfo; capsule: ClassInfo; @@ -594,6 +671,9 @@ export class ClassDynamicDispatch { private readonly generatorDispatch = new GeneratorDynamicDispatch(); private readonly caughtDispatch = new CaughtValueDispatch(); private readonly constructionDispatch = new ClassConstructionDispatch(); + /** Property names a delete can remove (null: any name), for the field + * creation tracking decision. */ + private deletedNames: Set | null | undefined; private readonly boxed = new Set(); private readonly boxedConstructors = new Set(); private readonly dispatches = new Map(); @@ -618,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; @@ -641,6 +723,19 @@ export class ClassDynamicDispatch { /** The for-in key helpers, by libCall (dyn and native string[] keys). */ private readonly enumerations = new Map<"dyn.forInKeys" | "dyn.forInKeyStrings", IrFunction>(); private readonly enumerationClasses = new Set(); + /** OrdinaryToPrimitive over boxed class instances, by hint, and the + * per-class method calls its arms make. */ + private readonly toPrimitives = new Map< + ToPrimitiveHint, + { fn: IrFunction; classes: Set } + >(); + private readonly toPrimitiveCalls = new Map(); + /** `value.valueOf()` and `value.toString()` on a dynamic receiver: a test + * for boxed instances of classes declaring the method, and the call. */ + private readonly conversionCalls = new Map< + "valueOf" | "toString", + { test: IrFunction; call: IrFunction; classes: Set } + >(); process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { // Dispatch tables grow between worklist waves. Keep their normalization @@ -662,6 +757,8 @@ export class ClassDynamicDispatch { changed = this.constructionDispatch.process(lowerer, functions) || changed; const seenTypes = new Set(); const rewrite = new Set(); + const toPrimitiveSites = new Set(); + const hasKeySites = new Set(); const discover = (type: IrType): void => { const key = typeKey(type); if (seenTypes.has(key)) return; @@ -699,6 +796,11 @@ export class ClassDynamicDispatch { ) { this.serializesClasses = true; } + if ( + toPrimitiveHint(expr) !== null || + (expr.kind === "libCall" && (expr.fn === "dyn.valueOf" || expr.fn === "dyn.toString")) + ) + toPrimitiveSites.add(fn); switch (expr.kind) { case "dynFrom": discover(expr.value.type); @@ -719,6 +821,9 @@ export class ClassDynamicDispatch { ) rewrite.add(fn); break; + case "dynHasKey": + hasKeySites.add(fn); + break; case "dynKeyGet": case "dynInvoke": rewrite.add(fn); @@ -752,6 +857,11 @@ export class ClassDynamicDispatch { }, }); if (this.boxed.size === 0 && rewrite.size === 0) return changed; + const converting = this.convertingHints(lowerer); + const calling = this.calledConversions(lowerer); + if (converting.size || calling.size) for (const fn of toPrimitiveSites) rewrite.add(fn); + const prototypeMembers = this.prototypeMemberNames(lowerer); + if (prototypeMembers.size) for (const fn of hasKeySites) rewrite.add(fn); for (const fn of functions) { if ( !fn.name.startsWith("%dyn.class.instanceof:") && @@ -1019,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); @@ -1076,8 +1156,11 @@ export class ClassDynamicDispatch { current.def.tracksOwnFields = true; current.subclasses.forEach(track); }; - // Runtime-owned roots keep their dedicated property contracts. - if (!root.base?.def.runtime) track(root); + // Runtime-owned roots keep their dedicated property contracts. A + // family whose fields all exist from construction and are never + // deleted keeps a fixed layout, as TypeScript classes do. + this.deletedNames ??= deletedPropertyNames(lowerer.moduleOrder); + if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root); } const loc = this.propertyBag.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); @@ -1189,6 +1272,53 @@ export class ClassDynamicDispatch { fn.body = transformStmtList(fn.body, { stmt: (stmt) => stmt, expr: (expr) => { + if (expr.kind === "dynHasKey" && prototypeMembers.has(expr.key)) { + // A class instance's snapshot can carry its class's prototype + // data, which holds only the methods the program reads through + // the prototype. The computed-key form answers methods and + // accessors from the class itself. + const loc = expr.loc; + const test: IrExpr = { + kind: "libCall", + fn: "dyn.hasKeyComputed", + args: [ + expr.value, + { + kind: "dynFrom", + value: { kind: "strLit", value: expr.key, type: STRING, loc }, + type: DYN, + loc, + }, + ], + type: BOOL, + loc, + }; + changed = true; + return { + kind: "seqExpr", + stmts: [], + result: expr.negated + ? { kind: "unary", op: "!", operand: test, type: BOOL, loc } + : test, + type: BOOL, + loc, + }; + } + const called = calling.size ? this.convertMethodCall(lowerer, fn, expr, calling) : null; + if (called) { + changed = true; + return called; + } + const converted = converting.size + ? this.convertOperands(lowerer, fn, expr, converting) + : null; + if (converted) { + changed = true; + // A hoisting sequence revisits the operation as its child; a + // wrapped operation continues through the rewrites below. + if (converted.kind === "seqExpr") return converted; + expr = converted; + } if (expr.kind === "dynCheck" && isDynTypedRefType(expr.type)) { const type = expr.type; let cast = this.checkedCasts.get(type.className); @@ -2319,8 +2449,13 @@ export class ClassDynamicDispatch { const value = varRef("p.0", DYN, loc); const { receiver, condition } = this.propertyReceiver(plan, loc); if (isClassCallback(lowerer, info, dispatch.source.method)) { - const prototypeMethod = lowerer.prototypeMethodAccesses.has(dispatch.source.method); - if (prototypeMethod) classPrototypeData(lowerer, info, loc); + const prototypeMethod = observedPrototypeMethod( + lowerer, + info, + dispatch.source.method, + loc, + receiver, + ); const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); dispatch.callback.body.unshift({ kind: "if", @@ -2390,6 +2525,57 @@ export class ClassDynamicDispatch { changed = true; } } + for (const [method, conversion] of this.conversionCalls) { + for (const plan of this.propertyReceivers(lowerer)) { + if (conversion.classes.has(plan.key)) continue; + conversion.classes.add(plan.key); + if (!conversionMethod(lowerer, plan.info, method)) continue; + const loc = conversion.call.loc; + const { condition } = this.propertyReceiver(plan, loc); + const value = varRef("p.0", DYN, loc); + const result: IrExpr = { + kind: "call", + callee: this.toPrimitiveCall(lowerer, plan, method, method === "toString" ? 1 : 0, loc), + args: method === "toString" ? [value, varRef("p.1", DYN, loc)] : [value], + type: DYN, + loc, + }; + conversion.test.body.unshift({ + kind: "if", + cond: condition, + then: [{ kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }], + else_: null, + loc, + }); + conversion.call.body.unshift({ + kind: "if", + cond: condition, + then: [ + { + kind: "return", + value: + method === "toString" + ? { kind: "dynCheck", value: result, type: STRING, loc } + : result, + loc, + }, + ], + else_: null, + loc, + }); + changed = true; + } + } + for (const [hint, conversion] of this.toPrimitives) { + for (const plan of this.propertyReceivers(lowerer)) { + if (conversion.classes.has(plan.key)) continue; + conversion.classes.add(plan.key); + const arm = this.toPrimitiveArm(lowerer, plan, hint, conversion.fn); + if (!arm) continue; + conversion.fn.body.unshift(arm); + changed = true; + } + } for (const dispatch of lowerer.protocolDispatches.values()) { if (!this.generated.has(dispatch.fn)) { this.generated.add(dispatch.fn); @@ -2616,7 +2802,8 @@ export class ClassDynamicDispatch { return dispatch.reflect ? this.reflectPropertyBody(lowerer, { ...dispatch, name }, info, receiver) : this.propertyBody(lowerer, { ...dispatch, name }, info, receiver); - const prototypeMethod = lowerer.prototypeMethodAccesses.has(name); + const prototypeMethod = + !info.fields.has(name) && observedPrototypeMethod(lowerer, info, name, loc, receiver); if (info.fields.has(name) || (dispatch.probe === "in" && !prototypeMethod)) { if (info.fields.has(name) && info.def.tracksOwnFields) { const bag = this.readPropertyBagOrEmpty( @@ -2649,7 +2836,6 @@ export class ClassDynamicDispatch { { kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }, ]; } - if (prototypeMethod) classPrototypeData(lowerer, info, loc, receiver); const bag = this.readPropertyBagOrEmpty( lowerer, lowerer.coerceToExpected(receiver, DYN), @@ -3105,6 +3291,555 @@ export class ClassDynamicDispatch { return { kind: "call", callee: name, args: [bag, key], type: BOOL, loc }; } + /** The hints for which some boxed class declares a conversion method. A + * string conversion calls toString first, so without a declared toString + * or Symbol.toPrimitive it answers Object.prototype.toString's + * "[object Object]" as before. */ + private convertingHints(lowerer: Lowerer): Set { + const hints = new Set(); + for (const { info } of this.propertyReceivers(lowerer)) { + if ( + conversionMethod(lowerer, info, "sym:toPrimitive") || + conversionMethod(lowerer, info, "toString") + ) { + hints.add("string"); + hints.add("number"); + hints.add("default"); + } else if (conversionMethod(lowerer, info, "valueOf")) { + hints.add("number"); + hints.add("default"); + } + } + return hints; + } + + /** An operation that converts dynamic operands to primitives, with each + * operand passed through the hint's conversion helper first. Operations + * with two dynamic operands evaluate both before converting the left one, + * as JavaScript does. Returns null when `expr` needs no conversion. */ + private convertOperands( + lowerer: Lowerer, + fn: IrFunction, + expr: IrExpr, + converting: Set, + ): IrExpr | null { + const hint = toPrimitiveHint(expr); + if (hint === null || !converting.has(hint)) return null; + const loc = expr.loc; + const convert = (value: IrExpr): IrExpr => ({ + kind: "call", + callee: this.toPrimitiveHelper(lowerer, hint, loc), + args: [value], + type: DYN, + loc, + }); + if (expr.kind === "toString") { + if (isConversion(expr.operand)) return null; + return { ...expr, operand: convert(expr.operand) }; + } + if (expr.kind === "dynKeyGet") { + // A computed read converts its key after evaluating the receiver, + // unless the receiver is null or undefined. + if (expr.key.type.kind !== "dyn" || isConversion(expr.key)) return null; + const [receiver, key] = this.hoistOperands(fn, [expr.value, expr.key], loc) as [ + Hoisted, + Hoisted, + ]; + return { + kind: "seqExpr", + stmts: receiver.stmts.concat(key.stmts), + result: { + ...expr, + value: receiver.value, + key: { + kind: "ternary", + cond: { + kind: "dynTest", + test: "nullish", + negated: true, + value: receiver.value, + type: BOOL, + loc, + }, + then: convert(key.value), + else_: key.value, + type: DYN, + loc, + }, + }, + type: expr.type, + loc, + }; + } + if (expr.kind !== "libCall") return null; + const receiverNeeds = KEY_CALLS.get(expr.fn); + if (receiverNeeds) { + if (expr.args[1]?.type.kind !== "dyn" || isConversion(expr.args[1])) return null; + // Every operand is evaluated before the key converts, as in + // `obj[key] = value`, where the value is evaluated first. + const hoisted = this.hoistOperands(fn, expr.args, loc); + const args = hoisted.map((operand) => operand.value); + const receiver = args[0]!; + const admits: IrExpr | null = + receiverNeeds === "any" + ? null + : receiverNeeds === "object" + ? { kind: "unary", op: "!", operand: isPrimitive(receiver, loc), type: BOOL, loc } + : { kind: "dynTest", test: "nullish", negated: true, value: receiver, type: BOOL, loc }; + args[1] = admits + ? { + kind: "ternary", + cond: admits, + then: convert(args[1]!), + else_: args[1]!, + type: DYN, + loc, + } + : convert(args[1]!); + return { + kind: "seqExpr", + stmts: hoisted.flatMap((operand) => operand.stmts), + result: { ...expr, args }, + type: expr.type, + loc, + }; + } + const dynamic = expr.args.flatMap((arg, i) => (arg.type.kind === "dyn" ? [i] : [])); + if (dynamic.some((i) => isConversion(expr.args[i]!) && expr.args[i]!.kind !== "dynFrom")) + return null; + const operands = dynamic.filter((i) => !isConversion(expr.args[i]!)); + if (operands.length === 0) return null; + if (expr.args.length === 1) return { ...expr, args: [convert(expr.args[0]!)] }; + // Every operand is evaluated before the first conversion. + const hoisted = expr.args.map((arg): Hoisted => + arg.kind === "strLit" || arg.kind === "numLit" || arg.kind === "boolLit" + ? { stmts: [], value: arg } + : this.hoistOperands(fn, [arg], loc)[0]!, + ); + const args = hoisted.map((operand) => operand.value); + if (expr.fn === "dyn.abstractEq") { + // Loose equality converts an object only when the other operand is a + // primitive other than null and undefined. + if (dynamic.length !== 2) return null; + const primitive = (value: IrExpr): IrExpr => + (["number", "string", "boolean", "bigint", "symbol"] as const) + .map((test): IrExpr => ({ kind: "dynTest", test, value, type: BOOL, loc })) + .reduce((a, b) => ({ kind: "logical", op: "||", left: a, right: b, type: BOOL, loc })); + const [left, right] = [args[0]!, args[1]!]; + for (const i of operands) + args[i] = { + kind: "ternary", + cond: primitive(i === 0 ? right : left), + then: convert(args[i]!), + else_: args[i]!, + type: DYN, + loc, + }; + } else for (const i of operands) args[i] = convert(args[i]!); + return { + kind: "seqExpr", + stmts: hoisted.flatMap((operand) => operand.stmts), + result: { ...expr, args }, + type: expr.type, + loc, + }; + } + + /** The method and accessor names of boxed classes whose instances can + * carry their class's prototype data. */ + private prototypeMemberNames(lowerer: Lowerer): Set { + const names = new Set(); + for (const { info } of this.propertyReceivers(lowerer)) { + if (!hasClassPrototypeData(info)) continue; + for (let c: ClassInfo | null = info; c; c = c.base) + for (const name of c.methods.keys()) + names.add(name.startsWith("get:") || name.startsWith("set:") ? name.slice(4) : name); + } + return names; + } + + /** The methods some boxed class declares among valueOf and toString. */ + private calledConversions(lowerer: Lowerer): Set<"valueOf" | "toString"> { + const methods = new Set<"valueOf" | "toString">(); + for (const { info } of this.propertyReceivers(lowerer)) { + if (conversionMethod(lowerer, info, "valueOf")) methods.add("valueOf"); + if (conversionMethod(lowerer, info, "toString")) methods.add("toString"); + } + return methods; + } + + /** `value.valueOf()` or `value.toString(argument)` on a dynamic receiver: + * a boxed instance of a class declaring the method calls it, any other + * value keeps the runtime method call. The toString result must be a + * string, as for the runtime's own-member call. */ + private convertMethodCall( + lowerer: Lowerer, + fn: IrFunction, + expr: IrExpr, + calling: Set<"valueOf" | "toString">, + ): IrExpr | null { + if (expr.kind !== "libCall" || (expr.fn !== "dyn.valueOf" && expr.fn !== "dyn.toString")) + return null; + const method = expr.fn === "dyn.valueOf" ? "valueOf" : "toString"; + const receiver = expr.args[0]!; + if (!calling.has(method)) return null; + if (receiver.kind === "varRef" && receiver.localId.startsWith(CONVERSION_CALL_PREFIX)) + return null; + const loc = expr.loc; + const operands = this.hoistOperands( + fn, + method === "toString" ? [receiver, expr.args[1]!] : [receiver], + loc, + CONVERSION_CALL_PREFIX, + ); + const values = operands.map((operand) => operand.value); + const helpers = this.conversionCallHelpers(lowerer, method, loc); + return { + kind: "seqExpr", + stmts: operands.flatMap((operand) => operand.stmts), + result: { + kind: "ternary", + cond: { kind: "call", callee: helpers.test.name, args: [values[0]!], type: BOOL, loc }, + then: { kind: "call", callee: helpers.call.name, args: values, type: expr.type, loc }, + else_: { ...expr, args: [...values, ...expr.args.slice(values.length)] }, + type: expr.type, + loc, + }, + type: expr.type, + loc, + }; + } + + private conversionCallHelpers( + lowerer: Lowerer, + method: "valueOf" | "toString", + loc: SrcLoc, + ): { test: IrFunction; call: IrFunction } { + let helpers = this.conversionCalls.get(method); + if (!helpers) { + const at = helperLoc(loc); + const params: IrParam[] = [ + { localId: "p.0", name: "value", type: DYN }, + ...(method === "toString" ? [{ localId: "p.1", name: "argument", type: DYN }] : []), + ]; + const locals = params.map((param) => ({ + id: param.localId, + name: param.name, + type: param.type, + mutable: false, + })); + const name = { kind: "strLit" as const, value: method, type: STRING, loc: at }; + const test: IrFunction = { + name: `${TO_PRIMITIVE_PREFIX}${method}.test`, + params: params.slice(0, 1), + locals: locals.slice(0, 1), + returnType: BOOL, + body: [ + { + kind: "return", + value: { kind: "boolLit", value: false, type: BOOL, loc: at }, + loc: at, + }, + ], + loc: at, + }; + const call: IrFunction = { + name: `${TO_PRIMITIVE_PREFIX}${method}.call`, + params, + locals, + returnType: method === "toString" ? STRING : DYN, + body: [ + { + kind: "return", + value: + method === "toString" + ? { + kind: "libCall", + fn: "dyn.toString", + args: [varRef("p.0", DYN, at), varRef("p.1", DYN, at), name], + type: STRING, + loc: at, + } + : { + kind: "libCall", + fn: "dyn.valueOf", + args: [varRef("p.0", DYN, at), name], + type: DYN, + loc: at, + }, + loc: at, + }, + ], + loc: at, + }; + helpers = { test, call, classes: new Set() }; + this.conversionCalls.set(method, helpers); + this.generated.add(test); + this.generated.add(call); + lowerer.liftedFns.push(test, call); + } + return helpers; + } + + /** Evaluates each operand into a fresh local, in order. */ + private hoistOperands( + fn: IrFunction, + operands: readonly IrExpr[], + loc: SrcLoc, + prefix = "%toPrimitive.operand.", + ): Hoisted[] { + return operands.map((operand) => { + const id = `${prefix}${fn.locals.length}`; + fn.locals.push({ id, name: "operand", type: operand.type, mutable: false }); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: id, init: operand, loc }]; + return { stmts, value: varRef(id, operand.type, loc) }; + }); + } + + /** The conversion helper for `hint`: a boxed class instance answers its + * OrdinaryToPrimitive result, any other value is returned unchanged for + * the runtime operation to convert. Arms are added per boxed class. */ + private toPrimitiveHelper(lowerer: Lowerer, hint: ToPrimitiveHint, loc: SrcLoc): string { + let conversion = this.toPrimitives.get(hint); + if (!conversion) { + const at = helperLoc(loc); + const fn: IrFunction = { + name: `${TO_PRIMITIVE_PREFIX}${hint}`, + params: [{ localId: "p.0", name: "value", type: DYN }], + locals: [{ id: "p.0", name: "value", type: DYN, mutable: false }], + returnType: DYN, + body: [{ kind: "return", value: varRef("p.0", DYN, at), loc: at }], + loc: at, + }; + conversion = { fn, classes: new Set() }; + this.toPrimitives.set(hint, conversion); + this.generated.add(fn); + lowerer.liftedFns.push(fn); + } + return conversion.fn.name; + } + + /** OrdinaryToPrimitive for one boxed class: the declared conversion + * methods in hint order, each result returned when it is a primitive. + * Object.prototype.valueOf answers the object itself and its toString + * answers "[object Object]". */ + private toPrimitiveArm( + lowerer: Lowerer, + plan: PropertyReceiver, + hint: ToPrimitiveHint, + helper: IrFunction, + ): IrStmt | null { + const { info } = plan; + const loc = helper.loc; + const { condition } = this.propertyReceiver(plan, loc); + if (conversionMethod(lowerer, info, "sym:toPrimitive")) { + // A Symbol.toPrimitive method decides alone: its result must be a + // primitive. + const result = `%toPrimitive.exotic.${helper.locals.length}`; + helper.locals.push({ id: result, name: "exotic", type: DYN, mutable: false }); + const hintValue: IrExpr = { + kind: "dynFrom", + value: { kind: "strLit", value: hint, type: STRING, loc }, + type: DYN, + loc, + }; + const body: IrStmt[] = [ + { + kind: "varDecl", + localId: result, + init: { + kind: "call", + callee: this.toPrimitiveCall(lowerer, plan, "sym:toPrimitive", 1, loc), + args: [varRef("p.0", DYN, loc), hintValue], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: isPrimitive(varRef(result, DYN, loc), loc), + then: [{ kind: "return", value: varRef(result, DYN, loc), loc }], + else_: null, + loc, + }, + { + kind: "return", + value: nodeThrowExpr(1, "", "Cannot convert object to primitive value", DYN, loc), + loc, + }, + ]; + return { kind: "if", cond: condition, then: body, else_: null, loc }; + } + const order: ("valueOf" | "toString")[] = + hint === "string" ? ["toString", "valueOf"] : ["valueOf", "toString"]; + if (!order.some((method) => conversionMethod(lowerer, info, method))) return null; + if (hint === "string" && !conversionMethod(lowerer, info, "toString")) return null; + const body: IrStmt[] = []; + let settled = false; + for (const method of order) { + if (!conversionMethod(lowerer, info, method)) { + if (method === "toString") { + body.push({ + kind: "return", + value: { + kind: "dynFrom", + value: { kind: "strLit", value: "[object Object]", type: STRING, loc }, + type: DYN, + loc, + }, + loc, + }); + settled = true; + break; + } + continue; + } + const result = `%toPrimitive.${method}.${helper.locals.length}`; + helper.locals.push({ id: result, name: method, type: DYN, mutable: false }); + body.push( + { + kind: "varDecl", + localId: result, + init: { + kind: "call", + callee: this.toPrimitiveCall(lowerer, plan, method, 0, loc), + args: [varRef("p.0", DYN, loc)], + type: DYN, + loc, + }, + loc, + }, + { + kind: "if", + cond: isPrimitive(varRef(result, DYN, loc), loc), + then: [{ kind: "return", value: varRef(result, DYN, loc), loc }], + else_: null, + loc, + }, + ); + } + if (!settled) + body.push({ + kind: "return", + value: nodeThrowExpr(1, "", "Cannot convert object to primitive value", DYN, loc), + loc, + }); + return { kind: "if", cond: condition, then: body, else_: null, loc }; + } + + /** A function calling `method` with `arity` arguments on a boxed instance + * of `plan`'s class, as a dynamic method call would: an own replacement on + * the instance answers first for methods the program can replace. */ + private toPrimitiveCall( + lowerer: Lowerer, + plan: PropertyReceiver, + method: ConversionMethod, + arity: number, + loc: SrcLoc, + ): string { + const key = JSON.stringify([plan.key, method, arity]); + const existing = this.toPrimitiveCalls.get(key); + if (existing) return existing.name; + const { info } = plan; + const { receiver } = this.propertyReceiver(plan, loc); + const args = Array.from({ length: arity }, (_, i) => varRef(`p.${i + 1}`, DYN, loc)); + const source: Invoke = { + kind: "dynInvoke", + recv: varRef("p.0", DYN, loc), + method, + calleeName: method === "sym:toPrimitive" ? "value[Symbol.toPrimitive]" : `value.${method}`, + args, + type: DYN, + loc, + }; + const value = varRef("p.0", DYN, loc); + let callback: IrExpr = dynUndefinedExpr(loc); + if (isClassCallback(lowerer, info, method)) { + const prototypeMethod = lowerer.prototypeMethodAccesses.has(method); + if (prototypeMethod) classPrototypeData(lowerer, info, loc); + const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); + callback = { + kind: "ternary", + cond: prototypeMethod + ? { kind: "boolLit", value: true, type: BOOL, loc } + : { + kind: "libCall", + fn: "dyn.hasKey", + args: [bag, { kind: "strLit", value: method, type: STRING, loc }], + type: BOOL, + loc, + }, + then: { + kind: "dynObjLit", + fields: [ + { + key: { kind: "strLit", value: "value", type: STRING, loc }, + value: { + kind: "dynKeyGet", + value: bag, + key: { kind: "strLit", value: method, type: STRING, loc }, + type: DYN, + loc, + }, + }, + ], + type: DYN, + loc, + }, + else_: dynUndefinedExpr(loc), + type: DYN, + loc, + }; + } + const params: IrParam[] = [ + { localId: "p.0", name: "value", type: DYN }, + ...args.map((_, i) => ({ localId: `p.${i + 1}`, name: `p${i + 1}`, type: DYN })), + ]; + const fn: IrFunction = { + name: `${TO_PRIMITIVE_PREFIX}call.${this.toPrimitiveCalls.size}`, + params, + locals: [ + ...params.map((param) => ({ + id: param.localId, + name: param.name, + type: param.type, + mutable: false, + })), + { id: "p.callback", name: "callback", type: DYN, mutable: false }, + { id: "p.calleeName", name: "calleeName", type: STRING, mutable: false }, + ], + returnType: DYN, + body: [], + loc, + }; + const dispatch: Dispatch = { source, fn, callback: fn, classes: new Set() }; + const before = lowerer.diags.length; + let body: IrStmt[]; + try { + body = this.methodBody(lowerer, dispatch, info, receiver); + } catch (error) { + if (!(error instanceof PoisonError) || !info.decl) throw error; + const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); + if (!fence) throw error; + body = [fence]; + } + fn.body = [ + { kind: "varDecl", localId: "p.callback", init: callback, loc }, + { + kind: "varDecl", + localId: "p.calleeName", + init: { kind: "strLit", value: source.calleeName, type: STRING, loc }, + loc, + }, + ...body, + ]; + this.toPrimitiveCalls.set(key, fn); + this.generated.add(fn); + lowerer.liftedFns.push(fn); + return fn.name; + } + private readPropertyBagOrEmpty(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr { // One call per read site: large JS programs have a branch per class and // property, so the absent-bag handling lives in the shared helper. @@ -3159,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, }; @@ -3191,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 @@ -3790,8 +4608,7 @@ export class ClassDynamicDispatch { const body: IrStmt[] = []; const symbol = symbolMemberKey(lowerer, info, name, loc); if (symbol || (isClassOwnEnumerableFieldName(name) && !name.startsWith("sym:"))) { - const prototypeMethod = lowerer.prototypeMethodAccesses.has(name); - if (prototypeMethod) classPrototypeData(lowerer, info, loc, receiver); + const prototypeMethod = observedPrototypeMethod(lowerer, info, name, loc, receiver); // Selecting the method must not give the object own storage. const bag = this.readPropertyBag(lowerer, receiver, loc); const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc }; @@ -4210,6 +5027,8 @@ export class ClassDynamicDispatch { let callee: string; let virtual = false; let owner: ClassInfo; + /** Erased instances of subclasses that redeclare a generic method. */ + const overrides: { cls: ClassInfo; callee: string }[] = []; if (methodInfo) { owner = methodInfo.declarer; params = methodInfo.sig.params; @@ -4231,6 +5050,44 @@ export class ClassDynamicDispatch { params = instance.params; result = instance.returnType; callee = instance.name; + } else if ( + generic && + !generic.info.implicitParams && + generic.info.typeParams.length > 0 && + generic.declarer.decl && + !lowerer.overrideBelow(info, method) + ) { + // A generic method called by name runs its erased instance; a + // subclass that redeclares it runs its own, selected by the + // receiver's runtime class (most derived first). + owner = generic.declarer; + const instance = erasedGenericInstance(lowerer, generic.declarer.decl, generic.info); + params = instance.params; + result = instance.returnType; + callee = instance.name; + if (genericOverrideBelow(info, method)) { + const subclasses: ClassInfo[] = []; + const collect = (c: ClassInfo): void => { + for (const sub of c.subclasses) { + collect(sub); + if (sub.genericMethods?.has(method) && lowerer.classCanBeConstructed(sub)) + subclasses.push(sub); + } + }; + collect(info); + for (const sub of subclasses) { + const own = sub.genericMethods!.get(method)!; + if (!sub.decl || own.implicitParams || own.typeParams.length === 0) return fence(); + const subInstance = erasedGenericInstance(lowerer, sub.decl, own); + if ( + subInstance.params.length !== params.length || + subInstance.params.some((p, i) => !typeEquals(p.type, params[i]!.type)) || + !typeEquals(subInstance.returnType, result) + ) + return fence(); + overrides.push({ cls: sub, callee: subInstance.name }); + } + } } else return fence(); const incoming = dispatch.source.args.map((_, i) => varRef(`p.${i + 1}`, DYN, loc)); const args: IrExpr[] = []; @@ -4262,7 +5119,7 @@ export class ClassDynamicDispatch { if (!typeEquals(converted.type, param.type)) return fence(); args.push(converted); } - const call: IrExpr = virtual + let call: IrExpr = virtual ? { kind: "virtualCall", className: owner.def.name, @@ -4278,6 +5135,29 @@ export class ClassDynamicDispatch { type: result, loc, }; + for (const override of overrides.toReversed()) { + const subType: IrType = { kind: "object", className: override.cls.def.name }; + call = { + kind: "ternary", + cond: { + kind: "instanceOf", + value: receiver, + className: override.cls.def.name, + type: BOOL, + loc, + }, + then: { + kind: "call", + callee: override.callee, + args: [{ kind: "downcast", value: receiver, type: subType, loc }, ...args], + type: result, + loc, + }, + else_: call, + type: result, + loc, + }; + } let body: IrStmt[]; if (result.kind === "void") body = [ diff --git a/packages/compiler/src/frontend/lowering/class-method-mutations.ts b/packages/compiler/src/frontend/lowering/class-method-mutations.ts index b56d27262..8e7b1f152 100644 --- a/packages/compiler/src/frontend/lowering/class-method-mutations.ts +++ b/packages/compiler/src/frontend/lowering/class-method-mutations.ts @@ -48,9 +48,10 @@ export function collectClassMethodMutations( let unknownOwnField = false; // JavaScript method inference and dynamic class factories keep their // existing dispatch contract. The native-call proof applies to typed - // declarations, while writes in either source language can invalidate it. + // declarations (including JavaScript files TypeScript checks), while + // writes in either source language can invalidate it. for (const file of files) { - if (isJsSourceFile(file)) continue; + if (isJsSourceFile(file) && !lowerer.checkedJsFile(file)) continue; ts.walkPreorder(file, (node) => { if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) typedClasses.push(node); if ( @@ -212,7 +213,7 @@ export function collectClassMethodMutations( receiver: ts.Expression, site: ts.Node, ): boolean => { - if (!ts.isDeleteExpression(site) || !ts.isIdentifier(key)) return false; + if (!ts.isDeleteExpression(site) || !ts.isIdentifier(unwrap(key))) return false; const statement = site.parent; if (!ts.isExpressionStatement(statement)) return false; const loop = statement.parent; @@ -230,9 +231,11 @@ export function collectClassMethodMutations( ); }; const markKey = (key: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { - key = unwrap(key); - if (ts.isStringLiteralLike(key)) markName(key.text, site); - else if (ts.isNumericLiteral(key)) markName(String(Number(key.text)), site); + const value = unwrap(key); + if (ts.isStringLiteralLike(value)) markName(value.text, site); + else if (ts.isNumericLiteral(value)) markName(String(Number(value.text)), site); + // The key keeps its assertions: `slots[at as number]` and `slots[at!]` + // write a number key whatever the type of `at`. else computedWrites.push({ key, receiver, site }); }; const markFields = (value: ts.Expression, site: ts.Node, receiver?: ts.Expression): void => { @@ -547,7 +550,7 @@ export function collectClassMethodMutations( } for (const { key, receiver, site } of computedWrites) { const type = key ? lowerer.typeOf(key) : undefined; - const names = type && (literalValues(type) ?? (key ? literalLoopKeys(key) : null)); + const names = type && (literalValues(type) ?? (key ? literalLoopKeys(unwrap(key)) : null)); if (names) { for (const name of names) markName(String(name), site); continue; diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 4267a186a..7c8770021 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -24,7 +24,7 @@ import { type ClassInfo, } from "./lower-classes.js"; import { funcTypeFromParamShapes, type ParamShape } from "./call-signatures.js"; -import { implicitDefaultInstance } from "./generic-functions.js"; +import { erasedGenericInstance, implicitDefaultInstance } from "./generic-functions.js"; import { errorToStringMethod } from "./error-methods.js"; import { classCallbackValue, isClassCallback } from "./class-callbacks.js"; @@ -225,6 +225,20 @@ export function classMethodValue( params = instance.params; ret = instance.returnType; callee = instance.name; + } else if ( + generic && + !generic.info.implicitParams && + generic.info.typeParams.length > 0 && + generic.declarer.decl && + !lowerer.overrideBelow(info, method) && + !genericOverrideBelow(info, method) + ) { + // A generic method as a value: its erased instance. + owner = generic.declarer; + const instance = erasedGenericInstance(lowerer, blame, generic.info); + params = instance.params; + ret = instance.returnType; + callee = instance.name; } else { lowerer.unsupported("SC1090", blame, "values of builtin or unspecialized generic methods"); } diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index a1486a617..3c610c51a 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -1,5 +1,4 @@ import * as ts from "../ts7/adapter.js"; -import { isGenericCallableMemberType } from "../type-mapper.js"; /** Index only program classes. Ambient declarations describe APIs, not * compiled implementations, and must not change a protocol's storage. */ @@ -708,15 +707,6 @@ export class ClassProtocols { const protocols = this.index(); const symbol = type.getSymbol(); const properties = this.checker.getPropertiesOfType(type); - // Generic-only members have no single runtime callable signature. Keep - // their existing declaration-based specialization and refusal boundary. - if ( - properties.length > 0 && - properties.every((property) => - isGenericCallableMemberType(this.checker.getTypeOfSymbol(property), this.checker), - ) - ) - return false; if (symbol && protocols.has(symbol)) return true; if ( !symbol || @@ -731,7 +721,6 @@ export class ClassProtocols { return false; for (const property of properties) { const memberType = this.checker.getTypeOfSymbol(property); - if (isGenericCallableMemberType(memberType, this.checker)) continue; if (this.checker.getCallSignatures(memberType).length === 0) continue; for (const declaration of this.methods.get(property.name) ?? []) { const classSymbol = declaration.name diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 77f7cb541..1db709e91 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -229,6 +229,27 @@ export function classPrototypeData( return { kind: "call", callee: info.def.prototypeDataHelper, args: [], type: DYN, loc }; } +/** Whether reads of method `name` on instances of `info` use the observed + * prototype slot: the census marked the name and the class has prototype + * data that holds the method. Classes without prototype data (generic + * instantiations, mixins and their subclasses) and function-local classes + * check for the property and otherwise use the declared method. */ +export function observedPrototypeMethod( + lowerer: Lowerer, + info: ClassInfo, + name: string, + loc: SrcLoc, + receiver?: IrExpr, +): boolean { + if (!lowerer.prototypeMethodAccesses.has(name)) return false; + // Building the data also marks a function-local class whose instances + // need it. That class value's prototype holds only methods the program + // replaced there, so its reads check for the key (own or inherited) and + // otherwise use the declared method. + const data = classPrototypeData(lowerer, info, loc, receiver); + return data !== null && info.localClass === undefined; +} + export function hasClassPrototypeData(info: ClassInfo): boolean { return ( info.callableBase !== undefined || diff --git a/packages/compiler/src/frontend/lowering/coercions/unions.ts b/packages/compiler/src/frontend/lowering/coercions/unions.ts index ca7495dd7..0356b714e 100644 --- a/packages/compiler/src/frontend/lowering/coercions/unions.ts +++ b/packages/compiler/src/frontend/lowering/coercions/unions.ts @@ -77,6 +77,48 @@ export function narrowedRetagHelper( return lowerer.unionRetagHelper(fromId, toId, loc, trappable); } +/** A union conversion whose source arms include an object type with a + * required property of type `never` (the error arm of `Result`). + * No value has that type, but its arm still has a storage layout (never + * maps to the f64 placeholder) that no destination arm matches. When the + * node's checker type proves a source arm uninhabited, the re-tag traps on + * it instead of needing a destination. Optional `never` properties, + * `never[]` and functions returning `never` have values and keep their + * arms. Null when no source arm is proven uninhabited. */ +export function uninhabitedArmRetagHelper( + lowerer: Lowerer, + node: ts.Node, + fromId: string, + toId: string, + loc: SrcLoc, +): string | null { + const type = lowerer.typeOf(node); + if (!type.isUnionType()) return null; + const uninhabited = new Set(); + const inhabited = new Set(); + for (const part of ts.constituentTypes(type)) { + const arm = lowerer.mapTypeOf(part); + const tag = arm ? lowerer.armTag(fromId, arm) : -1; + if (tag < 0) continue; + (hasRequiredNeverProperty(lowerer, part) ? uninhabited : inhabited).add(tag); + } + // An arm shared with an inhabited constituent holds real values. + for (const tag of inhabited) uninhabited.delete(tag); + if (uninhabited.size === 0) return null; + return lowerer.unionRetagHelper(fromId, toId, loc, uninhabited); +} + +function hasRequiredNeverProperty(lowerer: Lowerer, type: ts.Type): boolean { + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + return lowerer.checker + .getPropertiesOfType(type) + .some( + (property) => + (property.flags & ts.SymbolFlags.Optional) === 0 && + (lowerer.checker.getTypeOfSymbol(property).flags & ts.TypeFlags.Never) !== 0, + ); +} + /** The stranded-UNIT trap for PLAIN (non-union) slots: a null/undefined * value flowing into a non-nullable typed slot the checker approved — * `null!` and `null as any as T` casts, and the non-strict world's diff --git a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts index b1e6aa98f..a79864983 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-callbacks.ts @@ -709,6 +709,30 @@ function arrayUnionHofHelper( return name; } +/** A kept filter element as the result's element type: the value itself, + * or its narrowed arm or class view when the predicate narrowed it. */ +function filterRetainedValue( + lowerer: Lowerer, + v: IrExpr, + elem: IrType, + outElem: IrType, + loc: SrcLoc, +): IrExpr { + if (typeEquals(outElem, elem)) return v; + const classView = + outElem.kind === "object" ? checkedClassAssertion(lowerer, v, outElem, loc) : null; + if (classView !== null) return classView; + if (v.type.kind !== "union") return v; + return { + kind: "unionNarrow", + unionId: v.type.unionId, + tag: lowerer.armTag(v.type.unionId, outElem), + value: v, + type: outElem, + loc, + }; +} + /** The loop body of one synthetic array HOF, from existing IR nodes: * * map: out = []; n = a.length; for (i = 0; i < n; i++) out.push(f(a[i])); return out; @@ -914,24 +938,7 @@ function buildArrayHofFn( { id: "v.0", name: "v", type: valueT, mutable: false }, ); returnType = outT; - const classView = - !typeEquals(outElem, elem) && outElem.kind === "object" - ? checkedClassAssertion(lowerer, varRef("v.0", valueT, loc), outElem, loc) - : null; - const retained = typeEquals(outElem, elem) - ? varRef("v.0", valueT, loc) - : classView !== null - ? classView - : valueT.kind === "union" - ? { - kind: "unionNarrow" as const, - unionId: valueT.unionId, - tag: lowerer.armTag(valueT.unionId, outElem), - value: varRef("v.0", valueT, loc), - type: outElem, - loc, - } - : varRef("v.0", valueT, loc); + const retained = filterRetainedValue(lowerer, varRef("v.0", valueT, loc), elem, outElem, loc); body = [ { kind: "varDecl", @@ -1039,16 +1046,7 @@ export function lowerArrayFindLikeCall( // `packages.some((p) => p.scripts[name])` idiom, whose callback // returns the index read's `string | undefined`). const fnRet = fnArg.type.ret; - const truthyRet = - fnRet.kind === "bool" || - fnRet.kind === "f64" || - fnRet.kind === "string" || - fnRet.kind === "dyn" || - (fnRet.kind === "union" && - (lowerer.unions.get(fnRet.unionId)?.arms ?? []).every( - (a) => a.kind !== "dyn" && a.kind !== "caught", - )); - if (!truthyRet) lowerer.badType(argNode, lowerer.typeOf(argNode)); + if (!findPredicateOk(lowerer, fnRet)) lowerer.badType(argNode, lowerer.typeOf(argNode)); const last = method === "findLast" || method === "findLastIndex"; if ( method === "some" || @@ -1116,42 +1114,7 @@ export function lowerArrayFindLikeCall( if (resultT.kind !== "union") lowerer.badType(call, lowerer.typeOf(call)); // defensive: T | undefined always maps to a union const undefTag = lowerer.armTag(resultT.unionId, UNDEFINED_T); if (undefTag < 0) lowerer.badType(call, lowerer.typeOf(call)); - let retag: string | null = null; - if (!lowerer.coercibleValue(valueT, resultT)) { - const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); - if (!unchangedElement && (!inline || argNode.type !== undefined)) { - lowerer.unsupported( - "SC1090", - argNode, - `narrowing '.${method}' requires an inline callback with an inferred predicate (annotate the return ': boolean' to keep the receiver's element type)`, - ); - } - const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; - const predicate = signature - ? lowerer.checker.getTypePredicateOfSignature(signature) - : undefined; - if ( - (!unchangedElement && (!predicate || predicate.parameterIndex !== 0)) || - valueT.kind !== "union" - ) { - lowerer.unsupported( - "SC1090", - call, - `'.${method}' result narrowing without a predicate over its element`, - ); - } - retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); - const present = lowerer.stripUndefinedArm(resultT); - if ( - retag === null && - !(present.kind === "object" && canAssertClassValue(lowerer, valueT, present)) - ) - lowerer.unsupported( - "SC1090", - call, - `'.${method}' narrowing to an incompatible result layout`, - ); - } + const retag = findResultRetag(lowerer, call, argNode, method, valueT, resultT, unchangedElement); const helper = findHelper( lowerer, elem, @@ -1168,6 +1131,67 @@ export function lowerArrayFindLikeCall( return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc }; } +/** The retag helper that converts a found element into find's result union, + * or null when the element converts directly. Only an inline callback with + * an inferred predicate over its element can narrow the result; a written + * predicate is an unchecked assertion and keeps the refusal. */ +function findResultRetag( + lowerer: Lowerer, + call: ts.CallExpression, + argNode: ts.Expression, + method: string, + valueT: IrType, + resultT: IrType & { kind: "union" }, + unchangedElement: boolean, +): string | null { + if (lowerer.coercibleValue(valueT, resultT)) return null; + const loc = locOf(call); + const inline = ts.isArrowFunction(argNode) || ts.isFunctionExpression(argNode); + if (!unchangedElement && (!inline || argNode.type !== undefined)) { + lowerer.unsupported( + "SC1090", + argNode, + `narrowing '.${method}' requires an inline callback with an inferred predicate (annotate the return ': boolean' to keep the receiver's element type)`, + ); + } + const signature = inline ? lowerer.checker.getSignatureFromDeclaration(argNode) : undefined; + const predicate = signature ? lowerer.checker.getTypePredicateOfSignature(signature) : undefined; + if ( + (!unchangedElement && (!predicate || predicate.parameterIndex !== 0)) || + valueT.kind !== "union" + ) { + lowerer.unsupported( + "SC1090", + call, + `'.${method}' result narrowing without a predicate over its element`, + ); + } + const retag = lowerer.narrowedRetagHelper(call, valueT.unionId, resultT.unionId, loc); + const present = lowerer.stripUndefinedArm(resultT); + if ( + retag === null && + !(present.kind === "object" && canAssertClassValue(lowerer, valueT, present)) + ) + lowerer.unsupported("SC1090", call, `'.${method}' narrowing to an incompatible result layout`); + return retag; +} + +/** The find-like predicate results whose ToBoolean has a static answer: + * bool passes through; f64/string by value; checked values and + * truthy-answerable unions by their runtime kind or arm. */ +function findPredicateOk(lowerer: Lowerer, fnRet: IrType): boolean { + return ( + fnRet.kind === "bool" || + fnRet.kind === "f64" || + fnRet.kind === "string" || + fnRet.kind === "dyn" || + (fnRet.kind === "union" && + (lowerer.unions.get(fnRet.unionId)?.arms ?? []).every( + (a) => a.kind !== "dyn" && a.kind !== "caught", + )) + ); +} + /** ToBoolean of a predicate result inside a synthesized HOF helper — the * ensureBool subset the find-like path admits (bool through; checked * values via dynTest; f64/string/truthy-answerable unions via toBool). */ @@ -1208,17 +1232,7 @@ function findHelper( const fnT = funcOf([valueT, F64, arrT].slice(0, arity), fnRet); const v = varRef("v.0", valueT, loc); - const presentArm = lowerer.stripUndefinedArm(resultT); - // A successful inferred class predicate may select a descendant of a - // stored base arm. Keep the value read before the callback mutated the array. - const classView = - retag === null && !lowerer.coercibleValue(valueT, resultT) && presentArm.kind === "object" - ? checkedClassAssertion(lowerer, v, presentArm, loc) - : null; - const found: IrExpr = - retag === null - ? lowerer.coerceToExpected(classView ?? v, resultT) - : { kind: "call", callee: retag, args: [v], type: resultT, loc }; + const found = findFoundValue(lowerer, v, resultT, retag, loc); const miss: IrExpr = { kind: "unionWrap", unionId: resultT.unionId, @@ -1272,6 +1286,25 @@ function findHelper( return name; } +/** A found element as find's result union. A successful inferred class + * predicate may select a descendant of a stored base arm. The value is the + * one read before the callback ran, even if the callback mutated its slot. */ +function findFoundValue( + lowerer: Lowerer, + v: IrExpr, + resultT: IrType & { kind: "union" }, + retag: string | null, + loc: SrcLoc, +): IrExpr { + if (retag !== null) return { kind: "call", callee: retag, args: [v], type: resultT, loc }; + const presentArm = lowerer.stripUndefinedArm(resultT); + const classView = + !lowerer.coercibleValue(v.type, resultT) && presentArm.kind === "object" + ? checkedClassAssertion(lowerer, v, presentArm, loc) + : null; + return lowerer.coerceToExpected(classView ?? v, resultT); +} + /** The findIndex/findLastIndex loop — find's index-returning sibling, no * union; findLastIndex is the identical loop walked backwards: * @@ -2580,3 +2613,200 @@ function buildDynFilterFn(name: string, elemT: IrType, arity: number, loc: SrcLo loc, }; } + +/** Tuple `filter` / `find` / `findIndex` / `some` / `every`. `reads` holds + * one element read per position, in index order, each over the helper's + * `a.0` receiver parameter. The helper visits positions in order and reads + * each one only when it is visited, so a callback's write to a later + * position is visible; filter and find keep the value read before the + * callback ran. find, findIndex, some and every return at the first + * deciding position. Predicate results and narrowing follow the array + * methods' rules. */ +export function lowerTuplePredicateCall( + lowerer: Lowerer, + call: ts.CallExpression, + method: "filter" | "find" | "findIndex" | "some" | "every", + receiver: IrExpr, + elem: IrType, + reads: IrExpr[], +): IrExpr { + const loc = locOf(call); + const argNode = call.arguments[0]!; + const booleanFilter = + method === "filter" && + ts.isIdentifier(argNode) && + argNode.text === "Boolean" && + lowerer.isStdlibSymbol(lowerer.resolveValueSymbol(argNode) ?? undefined); + if (booleanFilter) { + if (elem.kind === "union") lowerer.requireTruthyUnion(elem.unionId, argNode); + if (elem.kind === "dyn" || elem.kind === "void" || isUnitType(elem)) { + lowerer.badType(argNode, lowerer.typeOf(argNode)); + } + } + const { fnArg, arity } = booleanFilter + ? { fnArg: truthyFilterCallback(lowerer, elem, loc), arity: 1 } + : lowerArrayCallback(lowerer, argNode, [elem], receiver.type); + const fnRet = fnArg.type.ret; + if (method === "filter" ? !filterPredicateOk(lowerer, fnRet) : !findPredicateOk(lowerer, fnRet)) { + if (fnRet.kind === "void") { + lowerer.unsupported( + "SC1090", + argNode, + `'.${method}()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)`, + ); + } + lowerer.badType(argNode, lowerer.typeOf(argNode)); + } + let resultT: IrType; + let outElem: IrType = elem; + // A narrowing find retags from the element union widened with undefined, + // the value type an array find narrows from. + let foundT: IrType = elem; + let retag: string | null = null; + if (method === "filter") { + // The element union is built in position order, so the checker's + // result element can list the same arms in another order. + const checkerResult = lowerer.mapTypeOf(lowerer.typeOf(call)); + const arms = (type: IrType): IrType[] => + type.kind === "union" ? lowerer.unions.get(type.unionId)!.arms : [type]; + const unchanged = + checkerResult?.kind === "array" && + arms(checkerResult.elem).length === arms(elem).length && + arms(checkerResult.elem).every((arm) => arms(elem).some((other) => typeEquals(arm, other))); + outElem = unchanged + ? elem + : filterResultElem(lowerer, call, argNode, elem, elem, booleanFilter); + resultT = arrayOf(outElem); + } else if (method === "find") { + resultT = lowerer.irTypeOf(call); + if (resultT.kind !== "union" || lowerer.armTag(resultT.unionId, UNDEFINED_T) < 0) { + lowerer.badType(call, lowerer.typeOf(call)); + } + if (!lowerer.coercibleValue(elem, resultT)) foundT = arrayValueType(lowerer, elem); + retag = findResultRetag(lowerer, call, argNode, method, foundT, resultT, false); + } else { + resultT = method === "findIndex" ? F64 : BOOL; + } + const key = `tuple.${method}:${typeKey(receiver.type)}:${typeKey(fnArg.type)}:${typeKey(resultT)}:${arity}${callbackSite(fnArg)}`; + let helper = lowerer.arrHofHelpers.get(key); + if (!helper) { + helper = `%tuple.${method}.${lowerer.arrHofHelpers.size}`; + lowerer.arrHofHelpers.set(key, helper); + const v = varRef("v.0", elem, loc); + const out = varRef("out.0", resultT, loc); + const test = (index: number): IrExpr => { + const invocation: IrExpr = { + kind: "callValue", + callee: varRef("f.0", fnArg.type, loc), + args: [v, numLit(index, loc)].slice(0, arity), + type: fnRet, + loc, + }; + return method === "filter" + ? filterCond(invocation, fnRet, loc) + : predicateToBoolean(invocation); + }; + const decided = (index: number): IrStmt => { + switch (method) { + case "filter": + return arrayValueStore( + lowerer, + out, + { kind: "arrIntrinsic", method: "length", receiver: out, args: [], type: F64, loc }, + filterRetainedValue(lowerer, v, elem, outElem, loc), + outElem, + loc, + ); + case "find": + return { + kind: "return", + value: findFoundValue( + lowerer, + lowerer.coerceToExpected(v, foundT), + resultT as IrType & { kind: "union" }, + retag, + loc, + ), + loc, + }; + case "findIndex": + return { kind: "return", value: numLit(index, loc), loc }; + case "some": + return { kind: "return", value: boolLit(true, loc), loc }; + case "every": + return { kind: "return", value: boolLit(false, loc), loc }; + } + }; + const body: IrStmt[] = + method === "filter" + ? [ + { + kind: "varDecl", + localId: "out.0", + init: { kind: "arrayLit", elems: [], type: resultT, loc }, + loc, + }, + ] + : []; + reads.forEach((read, index) => { + body.push( + index === 0 + ? { kind: "varDecl", localId: "v.0", init: read, loc } + : { kind: "assign", localId: "v.0", value: read, loc }, + { + kind: "if", + cond: + method === "every" + ? { kind: "unary", op: "!", operand: test(index), type: BOOL, loc } + : test(index), + then: [decided(index)], + else_: null, + loc, + }, + ); + }); + let done: IrExpr; + switch (method) { + case "filter": + done = out; + break; + case "find": { + const unionId = (resultT as IrType & { kind: "union" }).unionId; + done = { + kind: "unionWrap", + unionId, + tag: lowerer.armTag(unionId, UNDEFINED_T), + value: { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }, + type: resultT, + loc, + }; + break; + } + case "findIndex": + done = numLit(-1, loc); + break; + default: + done = boolLit(method === "every", loc); + } + body.push({ kind: "return", value: done, loc }); + lowerer.liftedFns.push({ + name: helper, + params: [ + { localId: "a.0", name: "a", type: receiver.type }, + { localId: "f.0", name: "f", type: fnArg.type }, + ], + returnType: resultT, + locals: [ + { id: "a.0", name: "a", type: receiver.type, mutable: false }, + { id: "f.0", name: "f", type: fnArg.type, mutable: false }, + ...(reads.length > 0 ? [{ id: "v.0", name: "v", type: elem, mutable: true }] : []), + ...(method === "filter" + ? [{ id: "out.0", name: "out", type: resultT, mutable: false }] + : []), + ], + body, + loc, + }); + } + return { kind: "call", callee: helper, args: [receiver, fnArg], type: resultT, loc }; +} diff --git a/packages/compiler/src/frontend/lowering/containers/array-construction.ts b/packages/compiler/src/frontend/lowering/containers/array-construction.ts index 88c542ec0..015cb2123 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-construction.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-construction.ts @@ -1004,6 +1004,10 @@ export function lowerArrayFromCall( if (src.type.kind === "dyn" && args.length === 1) { return lowerCheckedArrayFrom(lowerer, src, loc); } + // Array.from(sp): the checked-dynamic view's live pair iteration. + if (src.type.kind === "searchParams" && args.length === 1) { + return lowerCheckedArrayFrom(lowerer, lowerer.coerceInto(args[0]!, src, DYN), loc); + } // A generator (or an iterator adapted into one) drains in order. if (src.type.kind === "generator" && args.length === 1) { const arrT = lowerer.mapTypeOf(lowerer.typeOf(call)); diff --git a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts index 43e8b4959..db60cdf1b 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-from-async.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-from-async.ts @@ -17,9 +17,10 @@ import { locOf } from "../../program.js"; import { arrayValueRead, arrayValueStore, arrayValueType } from "../array-values.js"; import { newFnCtx } from "../function-context.js"; import { lowerArrayCallback } from "./callback-arguments.js"; +import { extractIteratorValue, resultRecordOf, type GenType } from "../lower-generators.js"; -/** `Array.fromAsync(source, mapFn?)` over an array, as an async helper - * with the spec's loop: +/** `Array.fromAsync(source, mapFn?)` over an array or an async generator, + * as an async helper with the spec's loop: * * const out = []; let k = 0; * for await (const v of source) out.push(mapFn ? await mapFn(v, k++) : v); @@ -29,9 +30,10 @@ import { lowerArrayCallback } from "./callback-arguments.js"; * awaited (a promise element through its promise, any other value as one * resolved promise), and the iterator result is awaited again, which the * helper spells as the same microtask hops the `for await` array loop - * takes. A throwing or rejecting mapFn, and a rejected element, close the - * iterator before the returned promise rejects. Null when this is not an - * `Array.fromAsync` call. */ + * takes. An async generator is driven directly: each next() result is + * awaited once and its value is used as is. A throwing or rejecting mapFn, + * and a rejected array element, close the iterator before the returned + * promise rejects. Null when this is not an `Array.fromAsync` call. */ export function lowerArrayFromAsyncCall( lowerer: Lowerer, call: ts.CallExpression, @@ -54,23 +56,18 @@ export function lowerArrayFromAsyncCall( lowerer.badType(call, lowerer.typeOf(call)); const outT = resultT.inner; const source = lowerer.lowerExpr(args[0]!); - if (source.type.kind !== "array") { - if (source.type.kind === "generator" && source.type.async) { - // An async generator's completion settles one microtask later here - // than in Node, which would reorder the returned promise. - lowerer.noLowering( - "Array.fromAsync over an async generator", - args[0]!, - "collect its values with for await", - ); - } + let step: SourceStep; + if (source.type.kind === "array") { + step = arraySourceStep(lowerer, args[0]!, source.type.elem); + } else if (source.type.kind === "generator" && source.type.async) { + step = generatorSourceStep(lowerer, args[0]!, source.type); + } else { lowerer.noLowering( `Array.fromAsync over a value of type '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}'`, args[0]!, - "an array is the supported source", + "an array or an async generator is the supported source", ); } - const step = arraySourceStep(lowerer, args[0]!, source.type.elem); let mapper: (IrExpr & { type: IrType & { kind: "func" } }) | null = null; if (args[1]) { const callback = lowerArrayCallback(lowerer, args[1], [step.valueT], outT); @@ -207,19 +204,173 @@ function nonThenable(lowerer: Lowerer, type: IrType): boolean { ); } +interface SourceStep { + /** The value each step hands to the mapping function. */ + valueT: IrType; + loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; +} + +/** The async generator loop: each next() result is awaited, `done` ends + * the loop, and the value goes to the mapping function as is (no further + * await, unlike the async-from-sync array form). A mapping function that + * throws or rejects closes the generator by awaiting return(); a failure + * of that close is dropped, so the mapping error rejects the result. A + * rejected next() does not close the generator and rejects the result on + * the turn Node rejects it. */ +function generatorSourceStep(lowerer: Lowerer, node: ts.Expression, genT: GenType): SourceStep { + if (genT.yieldT.kind === "void") { + lowerer.noLowering("Array.fromAsync over an async generator that never yields", node); + } + if (genT.nextT.kind !== "undefinedT" && genT.nextT.kind !== "dyn") { + lowerer.noLowering( + `Array.fromAsync over an async generator whose yields expect .next(value) ('${lowerer.fmt(genT.nextT)}')`, + node, + ); + } + const recT = resultRecordOf(lowerer, genT); + const promiseT: IrType = { kind: "promise", inner: recT }; + const fieldT = lowerer.shapes.get(recT.shapeId)!.fields.find((f) => f.name === "value")!.type; + return { + valueT: genT.yieldT, + loop(source, append, loc) { + const g = lowerer.declareHiddenLocal("%fromAsyncGenerator", genT); + const needsClose = lowerer.declareHiddenLocal("%fromAsyncNeedsClose", BOOL); + needsClose.mutable = true; + const p = lowerer.declareHiddenLocal("%fromAsyncNext", promiseT); + const r = lowerer.declareHiddenLocal("%fromAsyncResult", recT); + r.mutable = true; + const nextError = lowerer.declareHiddenLocal("%fromAsyncNextError", CAUGHT); + const closeError = lowerer.declareHiddenLocal("%fromAsyncCloseError", CAUGHT); + const gRef = varRef(g.id, genT, loc); + const rRef = varRef(r.id, recT, loc); + const read: IrExpr = { + kind: "recordGet", + obj: rRef, + shapeId: recT.shapeId, + field: "value", + type: fieldT, + loc, + }; + const value = extractIteratorValue(lowerer, genT.yieldT, fieldT, read, loc); + if (!value) { + lowerer.noLowering( + `Array.fromAsync over an async generator yielding '${lowerer.fmt(genT.yieldT)}'`, + node, + ); + } + const setClose = (on: boolean): IrStmt => ({ + kind: "assign", + localId: needsClose.id, + value: boolLit(on, loc), + loc, + }); + const loop: IrStmt = { + kind: "while", + cond: boolLit(true, loc), + body: [ + { + kind: "varDecl", + localId: p.id, + init: { kind: "genResume", mode: "next", gen: gRef, arg: null, type: promiseT, loc }, + loc, + }, + { kind: "varDecl", localId: r.id, init: null, loc }, + // A rejected next() rejects the result two reactions later, as + // the rejected array element does. + { + kind: "tryCatch", + tryBody: [ + { + kind: "assign", + localId: r.id, + value: { kind: "awaitExpr", value: varRef(p.id, promiseT, loc), type: recT, loc }, + loc, + }, + ], + catchBody: [hop(loc), hop(loc), { kind: "rethrow", localId: nextError.id, loc }], + catchLocalId: nextError.id, + finallyBody: null, + loc, + }, + { + kind: "if", + cond: { + kind: "recordGet", + obj: rRef, + shapeId: recT.shapeId, + field: "done", + type: BOOL, + loc, + }, + then: [{ kind: "break", loc }], + else_: null, + loc, + }, + setClose(true), + ...append(value!), + setClose(false), + ], + loc, + }; + const stmts: IrStmt[] = [ + { kind: "varDecl", localId: g.id, init: source, loc }, + { kind: "varDecl", localId: needsClose.id, init: boolLit(false, loc), loc }, + { + kind: "tryCatch", + tryBody: [loop], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef(needsClose.id, BOOL, loc), + then: [closeGenerator(gRef, promiseT, recT, closeError.id, loc)], + else_: null, + loc, + }, + ], + loc, + }, + ]; + return stmts; + }, + }; +} + +/** Await `gen.return()`, dropping a failure of the close. */ +function closeGenerator( + gen: IrExpr, + promiseT: IrType, + recT: IrType, + errorLocalId: string, + loc: SrcLoc, +): IrStmt { + return { + kind: "tryCatch", + tryBody: [ + { + kind: "exprStmt", + expr: { + kind: "awaitExpr", + value: { kind: "genResume", mode: "return", gen, arg: null, type: promiseT, loc }, + type: recT, + loc, + }, + loc, + }, + ], + catchBody: [], + catchLocalId: errorLocalId, + finallyBody: null, + loc, + }; +} + /** The `for await` array loop (lowerForOf's async-from-sync form): two * hops per element that is not a promise, the element's own await plus * one hop for a promise element, two hops before the final done result, * and one hop to close the iterator when the body completes abruptly. */ -function arraySourceStep( - lowerer: Lowerer, - node: ts.Expression, - elem: IrType, -): { - /** The awaited value each element hands to the mapping function. */ - valueT: IrType; - loop(source: IrExpr, append: (value: IrExpr) => IrStmt[], loc: SrcLoc): IrStmt[]; -} { +function arraySourceStep(lowerer: Lowerer, node: ts.Expression, elem: IrType): SourceStep { const yieldedT = arrayValueType(lowerer, elem); let promiseTag: number | null = null; let valueT = yieldedT; diff --git a/packages/compiler/src/frontend/lowering/containers/array-methods.ts b/packages/compiler/src/frontend/lowering/containers/array-methods.ts index ea821f006..40c2e730d 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-methods.ts @@ -46,6 +46,7 @@ import { lowerArrayFindLikeCall, lowerArrayFlatMapCall, lowerArrayReduceCall, + lowerTuplePredicateCall, } from "./array-callbacks.js"; import { lowerArraySpreadItems, @@ -1847,12 +1848,19 @@ export function lowerTupleReadMethodCall( if (lowerer.chainBlocked(access, call)) return null; const method = access.name.text; const search = method === "includes" || method === "indexOf" || method === "lastIndexOf"; + const predicate = + method === "filter" || + method === "find" || + method === "findIndex" || + method === "some" || + method === "every"; if ( method !== "slice" && method !== "map" && method !== "flatMap" && method !== "join" && - !search + !search && + !predicate ) return null; if (!lowerer.isStdlibMember(access)) return null; @@ -2048,6 +2056,9 @@ export function lowerTupleReadMethodCall( "capture the original tuple explicitly to preserve its identity", ); } + if (predicate) { + return lowerTuplePredicateCall(lowerer, call, method, receiver, elem, fields.map(read)); + } const { fnArg, arity } = lowerArrayCallback(lowerer, callback, [elem], receiver.type); const ret = fnArg.type.ret; if (ret.kind === "void" || ret.kind === "func") lowerer.badType(call, lowerer.typeOf(call)); diff --git a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts index 0ceb4d677..cf45a7575 100644 --- a/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts +++ b/packages/compiler/src/frontend/lowering/containers/array-pipelines.ts @@ -108,6 +108,7 @@ const PURE_MATH = new Set([ "math.exp", "math.expm1", "math.floor", + "math.f16round", "math.fround", "math.imul", "math.log", diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index b9400cbbd..7a19927de 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -151,7 +151,7 @@ function lowerBytesSortCall( /* ── typed arrays / Buffer ─────────────────────────────────────────────── */ /** The typed-array constructors with a runtime representation, by lib - * interface name. Other flavors (Float16Array, BigInt64Array, ...) fall through + * interface name. Other flavors (BigInt64Array, BigUint64Array) fall through * to the generic stdlib-constructor fence. DataView is handled separately. */ const BYTES_CTORS: Record = { Uint8Array: "u8", @@ -161,6 +161,7 @@ const BYTES_CTORS: Record = { Int16Array: "i16", Uint32Array: "u32", Int32Array: "i32", + Float16Array: "f16", Float32Array: "f32", Float64Array: "f64", }; @@ -397,6 +398,7 @@ function lowerArrayBufferView( u8: "arrayBuffer.viewU8", u32: "arrayBuffer.viewU32", i32: "arrayBuffer.viewI32", + f16: "arrayBuffer.viewF16", f32: "arrayBuffer.viewF32", f64: "arrayBuffer.viewF64", dv: "arrayBuffer.viewDV", @@ -1220,6 +1222,7 @@ const DV_GETTERS: Record> — a * literal-union key type makes a fixed-field record, which has no * grouping representation) and stringifies number keys like JS property - * keys. Node's result carries a null prototype — records here have no - * prototype at all, so the difference is unobservable except through - * util.inspect's "[Object: null prototype]" prefix (ledgered). Other - * iterables fence (spread into an array first); Null when the callee - * isn't the stdlib Object/Map groupBy. */ + * keys; its result has a null prototype. Other iterables fence (spread + * into an array first). Null when the callee isn't the stdlib Object/Map + * groupBy. */ export function lowerGroupByStaticCall( lowerer: Lowerer, call: ts.CallExpression, @@ -50,15 +52,22 @@ export function lowerGroupByStaticCall( lowerer.noLowering(`${what} with ${call.arguments.length} arguments`, call); } const items = lowerer.lowerExpr(call.arguments[0]!); - if (items.type.kind !== "array") { + const elem = groupedElement(lowerer, items.type); + if (elem === null) { lowerer.noLowering( `${what} over an iterable of type '${lowerer.checker.typeToString(lowerer.typeOf(call.arguments[0]!))}'`, call.arguments[0]!, - "arrays are the supported items form — spread other iterables first: [...items]", + "arrays, Sets, Maps and generators are the supported items forms — spread other iterables first: [...items]", ); } - const elem = items.type.elem; - const f = lowerer.lowerExpr(call.arguments[1]!); + const groupT = arrayOf(elem); + const fNode = call.arguments[1]!; + // An unannotated JavaScript callback parameter takes the element. + const f = + isJsSourceFile(fNode.getSourceFile()) && + (ts.isArrowFunction(fNode) || ts.isFunctionExpression(fNode)) + ? lowerArrayCallback(lowerer, fNode, [elem], groupT, true).fnArg + : lowerer.lowerExpr(fNode); if ( f.type.kind !== "func" || f.type.params.length > 2 || @@ -81,7 +90,7 @@ export function lowerGroupByStaticCall( if ( resultT?.kind !== "map" || !typeEquals(resultT.key, keyT) || - !typeEquals(resultT.value, items.type) + !typeEquals(resultT.value, groupT) ) { lowerer.noLowering( `${what} at this result type`, @@ -89,7 +98,7 @@ export function lowerGroupByStaticCall( "the result must be Map over the callback's K and the items' T — annotate: Map.groupBy(items, f)", ); } - const key = `map.groupBy:${typeKey(f.type)}`; + const key = `map.groupBy:${typeKey(items.type)}:${typeKey(f.type)}`; let helper = lowerer.mapHofHelpers.get(key); if (!helper) { helper = `%map.groupBy.${lowerer.mapHofHelpers.size}`; @@ -124,9 +133,9 @@ export function lowerGroupByStaticCall( } const resultT: IrType & { kind: "record" } = { kind: "record", - shapeId: lowerer.shapes.intern([], false, lowerer.withUndefinedArm(items.type), []), + shapeId: lowerer.shapes.intern([], false, lowerer.withUndefinedArm(groupT), []), }; - const key = `object.groupBy:${typeKey(f.type)}`; + const key = `object.groupBy:${typeKey(items.type)}:${typeKey(f.type)}`; let helper = lowerer.mapHofHelpers.get(key); if (!helper) { helper = `%object.groupBy.${lowerer.mapHofHelpers.size}`; @@ -153,12 +162,13 @@ export function lowerGroupByStaticCall( function buildGroupByFn( lowerer: Lowerer, name: string, - itemsT: IrType & { kind: "array" }, + sourceT: IrType, fnT: IrType & { kind: "func" }, resultT: (IrType & { kind: "map" }) | (IrType & { kind: "record" }), loc: SrcLoc, ): IrFunction { - const elem = itemsT.elem; + const elem = groupedElement(lowerer, sourceT)!; + const itemsT = arrayOf(elem) as IrType & { kind: "array" }; const keyT = fnT.ret; const arity = fnT.params.length; const isMap = resultT.kind === "map"; @@ -325,10 +335,21 @@ function buildGroupByFn( ]), { kind: "return", value: gRef(), loc }, ]; + if (sourceT.kind !== "array") { + // Another iterable: the same per-element statements over its own + // walk, the callback's index counted by hand. + const loop = body[2] as IrStmt & { kind: "for" }; + const perElement = loop.body.slice(1); + locals[0] = { id: "items.0", name: "items", type: sourceT, mutable: true }; + const walk = groupedWalk(lowerer, sourceT, elem, perElement, loc, (local) => + locals.push(local), + ); + body.splice(1, 2, { kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc }, ...walk); + } return { name, params: [ - { localId: "items.0", name: "items", type: itemsT }, + { localId: "items.0", name: "items", type: sourceT }, { localId: "f.0", name: "f", type: fnT }, ], returnType: resultT, @@ -337,3 +358,194 @@ function buildGroupByFn( loc, }; } + +/** The element a grouped source delivers: an array's or Set's element, a + * Map's [key, value] pair (the checker's own tuple shape), or a + * generator's yield. Null for other sources. */ +function groupedElement(lowerer: Lowerer, sourceT: IrType): IrType | null { + switch (sourceT.kind) { + case "array": + case "set": + return sourceT.elem; + case "map": + return { + kind: "record", + shapeId: lowerer.shapes.intern( + [ + { name: "0", type: sourceT.key }, + { name: "1", type: sourceT.value }, + ], + true, + ), + }; + case "generator": + return stepDrivable(sourceT) ? sourceT.yieldT : null; + default: + return null; + } +} + +/** The walk of a Set, Map or generator source: binds `v.0` to each element, + * runs `perElement`, then advances the `i.0` counter. A Set or Map is + * walked live, as forEach walks it (appended entries are visited, deleted + * ones skipped), between iterEnter and iterExit. A generator is stepped, + * and closed when a callback throws, as Node closes the iterator. */ +function groupedWalk( + lowerer: Lowerer, + sourceT: IrType, + elem: IrType, + perElement: IrStmt[], + loc: SrcLoc, + addLocal: (local: IrLocal) => void, +): IrStmt[] { + const items = (): IrExpr => varRef("items.0", sourceT, loc); + const bump: IrStmt = { + kind: "assign", + localId: "i.0", + value: { + kind: "bin", + op: "+", + left: varRef("i.0", F64, loc), + right: numLit(1, loc), + type: F64, + loc, + }, + loc, + }; + const visit = (value: IrExpr): IrStmt[] => [ + { kind: "varDecl", localId: "v.0", init: value, loc }, + ...perElement, + bump, + ]; + if (sourceT.kind === "set" || sourceT.kind === "map") { + const collT = sourceT; + const iter = ( + method: "iterCount" | "iterLive" | "iterKey" | "iterValue" | "iterEnter" | "iterExit", + args: IrExpr[], + type: IrType, + ): IrExpr => + collT.kind === "map" + ? { kind: "mapIntrinsic", method, receiver: items(), args, type, loc } + : { + kind: "setIntrinsic", + method: method as "iterCount" | "iterLive" | "iterKey" | "iterEnter" | "iterExit", + receiver: items(), + args, + type, + loc, + }; + addLocal({ id: "j.0", name: "j", type: F64, mutable: true }); + const j = (): IrExpr => varRef("j.0", F64, loc); + const element: IrExpr = + collT.kind === "map" + ? { + kind: "recordLit", + fields: [ + { name: "0", value: iter("iterKey", [j()], collT.key) }, + { name: "1", value: iter("iterValue", [j()], collT.value) }, + ], + type: elem, + loc, + } + : iter("iterKey", [j()], collT.elem); + return [ + { kind: "exprStmt", expr: iter("iterEnter", [], VOID), loc }, + { + kind: "tryCatch", + tryBody: [ + { + kind: "for", + init: { kind: "varDecl", localId: "j.0", init: numLit(0, loc), loc }, + cond: { + kind: "bin", + op: "<", + left: j(), + right: iter("iterCount", [], F64), + type: BOOL, + loc, + }, + update: { + kind: "assign", + localId: "j.0", + value: { kind: "bin", op: "+", left: j(), right: numLit(1, loc), type: F64, loc }, + loc, + }, + body: [ + { + kind: "if", + cond: iter("iterLive", [j()], BOOL), + then: visit(element), + else_: null, + loc, + }, + ], + loc, + }, + ], + catchBody: null, + catchLocalId: null, + finallyBody: [{ kind: "exprStmt", expr: iter("iterExit", [], VOID), loc }], + loc, + }, + ]; + } + // A generator: `open` holds while an element is being grouped, so a + // throwing callback closes the generator; an exhausted or throwing + // generator is not closed. + const genT = sourceT as GenType; + addLocal({ id: "open.0", name: "open", type: BOOL, mutable: true }); + const setOpen = (value: boolean): IrStmt => ({ + kind: "assign", + localId: "open.0", + value: { kind: "boolLit", value, type: BOOL, loc }, + loc, + }); + return [ + { + kind: "varDecl", + localId: "open.0", + init: { kind: "boolLit", value: false, type: BOOL, loc }, + loc, + }, + { + kind: "tryCatch", + tryBody: [ + { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [ + setOpen(false), + { + kind: "if", + cond: genStep(items(), loc), + then: [{ kind: "break", loc }], + else_: null, + loc, + }, + setOpen(true), + ...visit(genValue(varRef("items.0", genT, loc) as IrExpr & { type: GenType }, loc)), + ], + loc, + }, + ], + catchBody: null, + catchLocalId: null, + finallyBody: [ + { + kind: "if", + cond: varRef("open.0", BOOL, loc), + then: [ + { + kind: "exprStmt", + expr: { kind: "genResume", mode: "close", gen: items(), arg: null, type: VOID, loc }, + loc, + }, + ], + else_: null, + loc, + }, + ], + loc, + }, + ]; +} diff --git a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts index d9bf8bcd4..8c8349c0b 100644 --- a/packages/compiler/src/frontend/lowering/containers/collection-methods.ts +++ b/packages/compiler/src/frontend/lowering/containers/collection-methods.ts @@ -41,6 +41,7 @@ import { } from "../collection-ingestion.js"; import { strCharsCall } from "./array-construction.js"; import { arrayElementRead } from "./array-iteration.js"; +import { isSearchParamsIterable } from "../builtins/url.js"; /** Ambient Map method calls. `get`/`set`/`has`/`delete`/`clear` lower to * mapIntrinsic; `forEach` desugars to a direct call of a synthetic loop @@ -1173,6 +1174,11 @@ export function lowerMapSeedNew( mapT, ); } + // new Map(sp) / new Map(sp.entries()): the checked-dynamic view's live + // pairs seed the map in order. + if (isSearchParamsIterable(lowerer, argNode)) { + return mapFromSeedValue(lowerer, lowerer.lowerExprExpecting(argNode, DYN), mapT); + } const input = collectionInput(lowerer, argNode); if (input) { if (!collectionDestinationMatches(lowerer, input, mapT)) return null; diff --git a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts index bd89f6aea..c95731fc7 100644 --- a/packages/compiler/src/frontend/lowering/expressions/object-literals.ts +++ b/packages/compiler/src/frontend/lowering/expressions/object-literals.ts @@ -1,3 +1,4 @@ +import { dateJsonType, isDateJsonSource } from "../builtins/dates.js"; import { dynUndefinedExpr, numLit, strLit, varRef } from "../../../ir/build.js"; import { pathModuleValue } from "../lower-builtin-values.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -32,7 +33,7 @@ import { isSafeToRepeat } from "./evaluation-safety.js"; import { tryLowerExpression } from "./try-lower-expression.js"; import { fenceSymbolFieldCopy } from "../symbol-fields.js"; import { planUnionRetag } from "../union-retag.js"; -import { runtimeOptionalStorageOperand } from "../lower-exprs.js"; +import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js"; /** `{ a: 1, b: "x" }` → recordLit. The record type comes from the * contextual type when tsc has one (annotated declarations, arguments, @@ -732,6 +733,10 @@ export function lowerObjectLiteral( ? prop.name : null; while (value && ts.isParenthesizedExpression(value)) value = value.expression; + // A nested literal holding such a binding: building it as a static + // record would copy the binding's object. + if (value && (ts.isArrayLiteralExpression(value) || ts.isObjectLiteralExpression(value))) + return jsHoldsDynObject(lowerer, value); if (!value || !ts.isIdentifier(value)) return false; const stored = lowerer.peekLocal(value)?.type ?? lowerer.globalOf(value)?.type; const inferred = lowerer.mapTypeOf(lowerer.typeOf(value)); @@ -1151,6 +1156,30 @@ export function lowerObjectLiteral( type = { kind: "record", shapeId: expected.shapeId }; } } + // A Date's toJSON() result keeps its null in a field with no declared + // destination: the lib types say string. + if (!expected) { + for (const property of expr.properties) { + const value = ts.isPropertyAssignment(property) + ? property.initializer + : ts.isShorthandPropertyAssignment(property) + ? property.name + : null; + if ( + value && + property.name && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && + isDateJsonSource(lowerer, value) + ) { + const widened = lowerer.runtimeOptionalRecordField( + type, + property.name.text, + dateJsonType(lowerer), + ); + if (widened.kind === "record") type = { kind: "record", shapeId: widened.shapeId }; + } + } + } let shape = lowerer.shapes.get(type.shapeId)!; // JavaScript Dates carry native object identity in checked storage. An // inferred scalar Date field would erase that identity before boxing. diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 38c728027..d3c5eb1ba 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -59,6 +59,11 @@ export function appendImplicitUndefinedReturn( ): void { if (bodyReturn.kind === "void") return; if (!bodyMayFallThrough(body)) return; + // An async generator that falls off its end completes without awaiting + // anything: its request settles as the body returns, where `return x` + // takes a turn to await x. + let settled = false; + if (lowerer.ctx.isAsync === true && lowerer.ctx.generator) settled = true; // A DYN body that can complete without returning (a JS function whose // guarded return may not run — mustSucceed's `if (typeof fn === // 'function') return fn.apply(...)`): JS completes with undefined — @@ -67,6 +72,7 @@ export function appendImplicitUndefinedReturn( body.push({ kind: "return", value: dynUndefinedExpr(loc), + ...(settled ? { awaited: true as const } : {}), loc, }); return; @@ -74,7 +80,7 @@ export function appendImplicitUndefinedReturn( if (bodyReturn.kind === "union") { const value = lowerer.wrappedUndefined(bodyReturn, loc); if (value) { - body.push({ kind: "return", value, loc }); + body.push({ kind: "return", value, ...(settled ? { awaited: true as const } : {}), loc }); return; } // no undefined arm: the trap below stands in, exactly like non-unions diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts index 712863072..bd785a256 100644 --- a/packages/compiler/src/frontend/lowering/generic-functions.ts +++ b/packages/compiler/src/frontend/lowering/generic-functions.ts @@ -94,6 +94,11 @@ export interface GenericFnInfo { * checker types key an instantiation, exactly the generic machinery — * the untyped params ARE the type parameters (see implicitCallInstance). */ implicitParams?: (ts.Symbol | null)[]; + /** Implicit-any monomorphization of destructured object parameters + * (implicitPatternsOf): parallel to decl.parameters, the destructured + * names where the slot binds the call site's object type. Computed on + * first use. */ + implicitPatterns?: (ImplicitPattern | null)[]; } export interface GenericInstance { @@ -132,6 +137,8 @@ export interface GenericInstance { * any-params poisoned it) — the body lowers in return-INFERENCE mode * (returnType holds the DYN recursion pin until the post-pass settles). */ implicitInferReturn?: true; + /** An erased instance (every type parameter bound to `unknown`). */ + erased?: true; } /** Defaults bind during inference; collection only resolves symbols in @@ -716,6 +723,7 @@ export function lowerGenericInstance( lowerer.fnStack.push(fnCtx); lowerer.localClassInstantiations.push({ owner: decl, name: inst.name }); lowerer.genericInstantiationPath = inst.path; + if (inst.erased) lowerer.erasingDepth++; try { // Static specializations bind lexical this only when the caller // proved its exact receiver. Other instances retain the fence; @@ -818,6 +826,7 @@ export function lowerGenericInstance( lowerer.instantiationContext = prevContext; lowerer.localClassInstantiations.pop(); lowerer.suppressStats = prevSuppress; + if (inst.erased) lowerer.erasingDepth--; } } @@ -927,6 +936,29 @@ function runtimeOptionalHofGenericBinding( return { typeParam, type: lowerer.runtimeOptionalType(receiver.elem) }; } +/** The erased instance of a generic declaration: every type parameter + * bound to `unknown`, the function JavaScript runs for any type arguments. + * Values of generic methods (dynamic dispatch, interface views) call it. */ +export function erasedGenericInstance( + lowerer: Lowerer, + blame: ts.Node, + info: GenericFnInfo, +): GenericInstance { + lowerer.erasingDepth++; + try { + const instance = genericValueInstance( + lowerer, + blame, + info, + new Map(info.typeParams.map((tp) => [tp, DYN])), + ); + instance.erased = true; + return instance; + } finally { + lowerer.erasingDepth--; + } +} + /** The instance a pinned VALUE reference names: the declaration's modes * over the DECLARED types mapped under the bindings (mapType's resolver * substitutes — the instance-body trick), which is the same result the @@ -934,7 +966,7 @@ function runtimeOptionalHofGenericBinding( * one instance per key. */ function genericValueInstance( lowerer: Lowerer, - ref: ts.Expression, + ref: ts.Node, info: GenericFnInfo, bindings: Map, ): GenericInstance { @@ -1098,6 +1130,114 @@ function broadPromiseParam(lowerer: Lowerer, type: ts.Type): boolean { ); } +/** A destructured object parameter (`function f({ Base, name })`) whose + * names bind the call site argument's property types. */ +export interface ImplicitPattern { + elements: { property: string; symbol: ts.Symbol }[]; +} + +/** The destructured object parameters of a JS function-like that bind like + * implicit type parameters: every element a plain name (optionally renamed + * from an identifier or string key) with no default, no rest element, and + * no write in the body, under a broad inferred type. Parallel to + * decl.parameters. */ +export function implicitPatternsOf( + lowerer: Lowerer, + decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction, +): (ImplicitPattern | null)[] { + return decl.parameters.map((param): ImplicitPattern | null => { + if (!ts.isObjectBindingPattern(param.name) || !decl.body) return null; + if (param.dotDotDotToken || param.questionToken || param.initializer) return null; + const elements: ImplicitPattern["elements"] = []; + for (const element of param.name.elements) { + if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) + return null; + const key = element.propertyName ?? element.name; + if (!ts.isIdentifier(key) && !ts.isStringLiteral(key)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(element.name); + if (!symbol || paramWrittenInBody(lowerer, decl.body, symbol, element.name.text)) return null; + // A JSDoc or contextual type names the properties: keep its ABI. + if ((lowerer.typeOf(element.name).flags & ts.TypeFlags.Any) === 0) return null; + elements.push({ property: key.text, symbol }); + } + return elements.length > 0 ? { elements } : null; + }); +} + +/** The type a destructured parameter takes at one call site (the object's + * own representation, a record or a checked value) and the checker type + * each destructured name binds. An object literal argument types the names + * from its property expressions, which see the enclosing instance's + * bindings (`makeClass({ Base, name })` inside a specialized + * `extend(Base, name)`); any other argument uses its checker type. Null + * when the argument lacks a destructured property, or passes a class the + * callee itself defines: each such class would instantiate the callee + * again (a static `extend` calling `makeClass({ Base: this })`). */ +function implicitPatternBinding( + lowerer: Lowerer, + callee: ts.Node, + pattern: ImplicitPattern, + arg: ts.Expression, +): { record: IrType; types: Map } | null { + let literal: ts.Expression = arg; + while (ts.isParenthesizedExpression(literal)) literal = literal.expression; + const types = new Map(); + if (ts.isObjectLiteralExpression(literal)) { + const values = new Map(); + /** Shorthand properties name the binding they read. */ + const shorthand = new Map(); + for (const property of literal.properties) { + if (ts.isShorthandPropertyAssignment(property) && ts.isIdentifier(property.name)) { + values.set(property.name.text, property.name); + const read = lowerer.checker.getShorthandAssignmentValueSymbol(property); + if (read) shorthand.set(property.name.text, read); + } else if ( + ts.isPropertyAssignment(property) && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) + ) + values.set(property.name.text, property.initializer); + else return null; + } + for (const element of pattern.elements) { + const value = values.get(element.property); + if (!value) return null; + const read = shorthand.get(element.property); + const type = (read && lowerer.implicitParamTypes?.get(read)) ?? lowerer.typeOf(value); + types.set(element.symbol, lowerer.checker.getBaseTypeOfLiteralType(type)); + } + // The object keeps the representation the call site lowers it to; the + // destructured names read their static types back out of it. + const lowered = tryLowerExpression(lowerer, literal); + if (lowered?.type.kind !== "record" && lowered?.type.kind !== "dyn") return null; + if (lowered.type.kind === "record") { + for (const field of lowerer.shapes.get(lowered.type.shapeId)?.fields ?? []) + if (field.type.kind === "classval" && classDefinedIn(lowerer, field.type.className, callee)) + return null; + } + return { record: lowered.type, types }; + } + const argType = lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(arg)); + const record = lowerer.mapTypeOf(argType); + if (record?.kind !== "record") return null; + for (const element of pattern.elements) { + const property = lowerer.checker.getPropertyOfType(argType, element.property); + if (!property) return null; + types.set(element.symbol, lowerer.checker.getTypeOfSymbol(property)); + } + return { record, types }; +} + +/** The class is declared inside `scope`. */ +function classDefinedIn(lowerer: Lowerer, className: string, scope: ts.Node): boolean { + for ( + let n: ts.Node | undefined = lowerer.classes.get(className)?.decl ?? undefined; + n; + n = n.parent + ) + if (n === scope) return true; + return false; +} + /** The implicit-type-parameter slots of a JS function-like: parallel to * decl.parameters, the param SYMBOL where the slot is a bindable * implicit param (identifier-named, broad inferred/JSDoc type, not @@ -1142,6 +1282,10 @@ export function implicitAnyParamSymbolsOf( hasImplicitParams = true; return sym; }); + // Destructured object parameters monomorphize too (implicitPatternsOf); + // their slots stay null here. + if (!hasImplicitParams && isJsSourceFile(decl.getSourceFile())) + hasImplicitParams = implicitPatternsOf(lowerer, decl).some((pattern) => pattern !== null); return hasImplicitParams ? out : null; } @@ -1413,7 +1557,27 @@ export function implicitCallInstance( ): GenericInstance { const shapes: ParamShape[] = []; const argTypes = new Map(); + const patterns = (info.implicitPatterns ??= implicitPatternsOf(lowerer, info.decl)); + /** The destructured names' bound types, which the object's own + * representation does not always carry. */ + const patternKey: string[] = []; info.decl.parameters.forEach((param, i) => { + const pattern = patterns[i]; + const patternArg = call.arguments[i]; + if (pattern && patternArg && !ts.isSpreadElement(patternArg)) { + // The argument's object type binds each destructured name, so a + // class passed as `{ Base }` keeps its static class type. + const bound = implicitPatternBinding(lowerer, info.decl, pattern, patternArg); + if (bound) { + for (const [symbol, type] of bound.types) { + argTypes.set(symbol, type); + const mapped = lowerer.mapTypeOf(type); + patternKey.push(mapped ? typeKey(mapped) : "?"); + } + shapes.push({ type: bound.record, mode: "required" }); + return; + } + } const sym = info.implicitParams![i]; if (!sym) { shapes.push(lowerer.paramShape(param)); @@ -1431,6 +1595,17 @@ export function implicitCallInstance( shapes.push({ type: bound, mode: "required" }); return; } + // `this` in a static method specialized for its receiver class is + // that class: bind it as a call naming the class would. + if (stored?.kind === "classval" && arg.kind === ts.SyntaxKind.ThisKeyword) { + const classDecl = lowerer.classes.get(stored.className)?.decl; + const classType = classDecl?.name ? lowerer.typeOf(classDecl.name) : null; + if (classType) { + argTypes.set(sym, classType); + shapes.push({ type: stored, mode: "required" }); + return; + } + } // A represented native array must keep its storage when a direct // inferred helper receives it. Re-boxing a checker-any array would // copy it, losing writes (and attempting to read unfilled slots). @@ -1540,7 +1715,7 @@ export function implicitCallInstance( } shapes.push({ type: bound, mode: "required" }); }); - return internImplicitInstance(lowerer, call, info, shapes, argTypes); + return internImplicitInstance(lowerer, call, info, shapes, argTypes, patternKey.join(",")); } /** The all-dyn DEFAULT instance — today's compiled body exactly: what a @@ -1573,8 +1748,9 @@ function internImplicitInstance( info: GenericFnInfo, shapes: ParamShape[], argTypes: Map, + patternKey = "", ): GenericInstance { - const key = shapes.map((s) => typeKey(s.type)).join(","); + const key = shapes.map((s) => typeKey(s.type)).join(",") + (patternKey ? `|${patternKey}` : ""); let inst = info.instances.get(key); if (inst?.implicitState === "failed") lowerer.unsupported("SC1090", blame, `the failed specialization of '${info.baseName}'`); diff --git a/packages/compiler/src/frontend/lowering/js-class-fields.ts b/packages/compiler/src/frontend/lowering/js-class-fields.ts new file mode 100644 index 000000000..4df513c91 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/js-class-fields.ts @@ -0,0 +1,182 @@ +import * as ts from "../ts7/adapter.js"; +import { isClassOwnEnumerableFieldName } from "../../ir/ir.js"; +import type { ClassInfo } from "./lower-classes.js"; + +/** Reflection that can remove a property or change its attributes. */ +const PROPERTY_REFLECTION = new Set(["deleteProperty", "freeze", "seal", "preventExtensions"]); + +/** `Object.freeze({ ... })` and the like apply to a new literal, which is + * not a class instance. */ +function freshLiteralArgument(callee: ts.PropertyAccessExpression): boolean { + const call = callee.parent; + if (!ts.isCallExpression(call) || call.expression !== callee) return false; + let argument = call.arguments[0]; + while (argument && ts.isParenthesizedExpression(argument)) argument = argument.expression; + return ( + argument !== undefined && + (ts.isObjectLiteralExpression(argument) || ts.isArrayLiteralExpression(argument)) + ); +} + +/** Property names that a `delete` in `files` can remove, or null when a + * delete with a computed key or a reflective removal can remove any name. */ +export function deletedPropertyNames(files: readonly ts.SourceFile[]): Set | null { + const names = new Set(); + let any = false; + for (const file of files) + ts.walkPreorder(file, (node) => { + if (any) return "stop"; + if ( + ts.isPropertyAccessExpression(node) && + PROPERTY_REFLECTION.has(node.name.text) && + !freshLiteralArgument(node) + ) { + any = true; + return "stop"; + } + if (!ts.isDeleteExpression(node)) return undefined; + let target = node.expression; + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (ts.isPropertyAccessExpression(target)) names.add(target.name.text); + else if ( + ts.isElementAccessExpression(target) && + (ts.isStringLiteralLike(target.argumentExpression) || + ts.isNumericLiteral(target.argumentExpression)) + ) + names.add(target.argumentExpression.text); + else any = true; + return undefined; + }); + return any ? null : names; +} + +/** Whether `node` reads or passes `this` or `super`. */ +function usesReceiver(node: ts.Node): boolean { + let found = false; + ts.walkPreorder(node, (part) => { + if (part.kind === ts.SyntaxKind.ThisKeyword || part.kind === ts.SyntaxKind.SuperKeyword) { + found = true; + return "stop"; + } + return undefined; + }); + return found; +} + +/** The field a statement `this.name = value` creates when `value` does not + * use the receiver, or null for any other statement. */ +function receiverFreeFieldAssignment(statement: ts.Statement): string | null { + if (!ts.isExpressionStatement(statement)) return null; + const assignment = statement.expression; + if ( + !ts.isBinaryExpression(assignment) || + assignment.operatorToken.kind !== ts.SyntaxKind.EqualsToken || + !ts.isPropertyAccessExpression(assignment.left) || + assignment.left.expression.kind !== ts.SyntaxKind.ThisKeyword || + usesReceiver(assignment.right) + ) + return null; + return assignment.left.name.text; +} + +export interface Construction { + /** Fields created before any code could observe the instance. */ + created: Set; + /** No code could observe the instance before construction finished. */ + sealed: boolean; +} + +/** The fields a class's construction creates before the instance can be + * observed: field declarations and top-level `this.name = value` + * statements whose values do not use the receiver, up to the first + * statement that uses `this` or `super` in any other way. A subclass + * continues its base's sequence (`base`) only when the whole base + * construction kept the instance unobserved. `isField` tells fields from + * names whose assignment runs a setter or adds a property. Null when the + * constructor's shape is not analyzed. */ +export function constructionPrefix( + decl: ts.ClassDeclaration | ts.ClassExpression, + base: Construction | null, + isField: (name: string) => boolean, +): Construction | null { + const created = new Set(base?.created ?? []); + let sealed = base?.sealed ?? true; + const ctor = decl.members.find( + (member): member is ts.ConstructorDeclaration => + ts.isConstructorDeclaration(member) && member.body !== undefined, + ); + if (ctor?.parameters.some((parameter) => usesReceiver(parameter))) sealed = false; + const statements = ctor?.body?.statements ?? []; + // A derived constructor's statements before super() cannot reach the + // instance; the field declarations run when super() returns. + let at = 0; + if (base && ctor) { + const superAt = statements.findIndex( + (statement) => + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + statement.expression.expression.kind === ts.SyntaxKind.SuperKeyword, + ); + if (superAt < 0) return null; + const call = (statements[superAt] as ts.ExpressionStatement).expression as ts.CallExpression; + if (call.arguments.some(usesReceiver)) sealed = false; + at = superAt + 1; + } + for (const member of decl.members) { + if (!ts.isPropertyDeclaration(member) || !ts.isIdentifier(member.name)) continue; + if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)) + continue; + if (member.initializer && usesReceiver(member.initializer)) sealed = false; + if (sealed) created.add(member.name.text); + } + for (const statement of statements.slice(at)) { + if (!sealed) break; + // A name that is not a field runs a setter or adds a property bag + // entry, either of which can observe the instance. + const name = receiverFreeFieldAssignment(statement); + if (name !== null && isField(name)) created.add(name); + else if (name !== null || usesReceiver(statement) || ts.isReturnStatement(statement)) + sealed = false; + } + return { created, sealed }; +} + +/** Whether a class declares a getter or setter, which can intercept a + * base constructor's field assignment. */ +function declaresAccessor(info: ClassInfo): boolean { + return [...info.methods.keys()].some( + (name) => name.startsWith("get:") || name.startsWith("set:"), + ); +} + +/** `constructionPrefix` for a collected class and its bases. */ +export function construction(info: ClassInfo): Construction | null { + if (!info.decl) return null; + let base: Construction | null = null; + if (info.base) { + base = construction(info.base); + if (!base) return null; + if (declaresAccessor(info)) base = { created: base.created, sealed: false }; + } + return constructionPrefix(info.decl, base, (name) => info.fields.has(name)); +} + +/** Whether every class in the family under `root` creates all of its + * enumerable fields before its instances can be observed, and no field + * name can be deleted. Such fields exist for the instance's whole life in + * layout order, as TypeScript fields do, so their writes need no creation + * tracking. */ +export function fixedFieldFamily(root: ClassInfo, deleted: Set | null): boolean { + if (deleted === null) return false; + const fixed = (info: ClassInfo): boolean => { + if (declaresAccessor(info)) return false; + const built = construction(info); + if (!built) return false; + for (const field of info.fieldOrder) { + if (!isClassOwnEnumerableFieldName(field.name)) continue; + if (!built.created.has(field.name) || deleted.has(field.name)) return false; + } + return info.subclasses.every(fixed); + }; + return fixed(root); +} diff --git a/packages/compiler/src/frontend/lowering/lower-assert.ts b/packages/compiler/src/frontend/lowering/lower-assert.ts index 0bccde3fd..9d434eae1 100644 --- a/packages/compiler/src/frontend/lowering/lower-assert.ts +++ b/packages/compiler/src/frontend/lowering/lower-assert.ts @@ -2364,6 +2364,7 @@ const BYTES_BRANDS: ReadonlySet = new Set([ "Int8Array", "Int16Array", "Int32Array", + "Float16Array", "Float32Array", "Float64Array", "BigInt64Array", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 84f634ad9..56c639397 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"; @@ -105,6 +106,8 @@ import { lowerRequireResolveCall, } from "./builtins/module-resolution.js"; import { lowerCipherMethodCall, lowerCryptoHashMethodCall } from "./builtins/crypto.js"; +import { lowerErrorIsErrorCall } from "./builtins/errors.js"; +import { lowerDisposableStackUseCall } from "./builtins/disposable-stack.js"; import { lowerDirentMethodCall, lowerFileHandleMethodCall, @@ -1378,6 +1381,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { lowerer.fenceStaticResponseMember(expr.expression, "call") ?? lowerer.fenceStaticHeadersMember(expr.expression, "call") ?? lowerer.fenceStaticReadableStreamMember(expr.expression, "call") ?? + lowerDisposableStackUseCall(lowerer, expr, expr.expression) ?? lowerer.lowerStdlibMethodCall(expr, expr.expression) ?? // Dyn receivers (JSON.parse-derived `unknown`/`any` values) — @@ -1387,6 +1391,7 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // by the receiver's own element. lowerer.lowerFilterNarrowCall(expr, expr.expression) ?? lowerArrayIsArrayCall(lowerer, expr, expr.expression) ?? + lowerErrorIsErrorCall(lowerer, expr, expr.expression) ?? lowerBigIntMethodCall(lowerer, expr, expr.expression) ?? lowerSymbolStaticCall(lowerer, expr, expr.expression) ?? lowerSymbolMethodCall(lowerer, expr, expr.expression) ?? @@ -1517,6 +1522,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); @@ -3199,6 +3206,13 @@ function lowerDynReceiverMethodCall( const info = lowerer.classes.get(probed.type.className); if (info) return lowerStaticMethodCall(lowerer, call, access); } + // A possibly missing element of a class-typed array (`items[i]`) is + // the receiver's class or undefined. The class method call checks for + // undefined itself, as it does in TypeScript. + const presentArm = + probed?.type.kind === "union" ? lowerer.stripUndefinedArm(probed.type) : null; + if (presentArm?.kind === "object" && typeEquals(presentArm, lowerer.mapTypeOf(recvTs) ?? DYN)) + return null; if ( probed?.type.kind === "union" && isJsSourceFile(call.getSourceFile()) && @@ -3378,6 +3392,18 @@ function lowerDynReceiverMethodCall( loc: locOf(call), }; } + // Every dyn kind's prototype chain declares valueOf, and the runtime + // answers each kind's own: primitives and plain objects themselves, a + // Date its time value, an own or inherited valueOf member its result. + if (access.name.text === "valueOf" && call.arguments.length === 0) { + return { + kind: "libCall", + fn: "dyn.valueOf", + args: [recv, { kind: "strLit", value: access.getText(), type: STRING, loc: locOf(call) }], + type: DYN, + loc: locOf(call), + }; + } // SHARED prototype names with a runtime dispatch (scr_dyn_invoke): // push/slice/join/forEach/map/apply/... dispatch on the receiver's // RUNTIME kind — the honest answer for names more than one dyn- @@ -4559,6 +4585,24 @@ function reconcileOverloadReturn( ) { call = lowerer.maybeNarrow(call, expr); } + // An override narrowed to part of its base's union return shares the + // union ABI; a call resolved to that override narrows the result back. + if ( + call.type.kind === "union" && + rsig && + rdecl && + ts.isMethodDeclaration(rdecl) && + lowerer.unionReturnOverrides.has(rdecl) + ) { + const declared = lowerer.mapTypeOf(lowerer.checker.getReturnTypeOfSignature(rsig)); + if (declared && !typeEquals(declared, call.type)) { + if (declared.kind !== "union") { + const helper = lowerer.narrowedArmHelper(call.type.unionId, declared, call.loc); + if (helper) return lowerer.checkedNarrowCall(helper, call, declared, call.loc); + } + return lowerer.coerceInto(expr, call, declared); + } + } const resolvedOverload = rsig && rdecl && @@ -8169,7 +8213,7 @@ export function lowerRecordFieldCall( } } const args = completeFuncValueArgs(lowerer, call, callee.type, locOf(call)); - const result: IrExpr = { + let result: IrExpr = { kind: "callValue", callee, ...(receiver ? { receiver } : {}), @@ -8177,6 +8221,10 @@ export function lowerRecordFieldCall( type: callee.type.ret, loc, }; + // A generic member's field holds its erased instance: the result + // converts to this call's instantiated return type at the call. + if (isGenericCallableMemberType(lowerer.typeOf(access), lowerer.checker)) + result = lowerer.coerceInto(call, result, lowerer.irTypeOf(call)); return init ? { kind: "seqExpr", stmts: [init], result, type: result.type, loc } : result; } @@ -8590,6 +8638,26 @@ function lowerObjLitGenericMethodCall( const onInterface = lowerer.checker .declarationsOf(propSym) .some((d) => d.parent !== undefined && ts.isInterfaceDeclaration(d.parent)); + // A receiver held as a checked-dynamic value (a class-backed interface) + // dispatches by name to the runtime object's member, which a generic + // method answers with its erased instance; the result converts to this + // call's instantiated return type. + if (onInterface && !call.arguments.some(ts.isSpreadElement)) { + const receiver = lowerer.lowerExpr(access.expression); + if (receiver.type.kind === "dyn") { + const loc = locOf(call); + const invoked: IrExpr = { + kind: "dynInvoke", + recv: receiver, + method: name, + calleeName: access.getText(), + args: call.arguments.map((arg) => lowerer.lowerExprExpecting(arg, DYN)), + type: DYN, + loc, + }; + return lowerer.coerceInto(call, invoked, lowerer.irTypeOf(call)); + } + } if (onInterface) { lowerer.unsupported( "SC1090", @@ -8733,7 +8801,15 @@ export function lowerObjectMethodCall( // The EventEmitter surface: API-named calls on emitter-rooted // receivers lower through the emitter spoke (subclass members with // these names are fenced at collection, so `found` never shadows). - if (info && !found && EMITTER_API_MEMBERS.has(access.name.text) && emitterRooted(lowerer, info)) { + if ( + info && + !found && + (EMITTER_API_MEMBERS.has(access.name.text) || + // A static with no instance twin (events.getEventListeners). + (access.name.text === "getEventListeners" && + lowerer.checker.getConstructSignatures(lowerer.typeOf(access.expression)).length > 0)) && + emitterRooted(lowerer, info) + ) { return lowerEmitterMethodCall(lowerer, call, access, info); } // The inherited EventTarget/Event methods of a class rooted at those diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 4577ae452..06739166c 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -53,7 +53,12 @@ import { } from "./instantiation-path.js"; import { appendImplicitUndefinedReturn, hasExplicitJsDocReturn } from "./function-returns.js"; import { bodyReadsArguments } from "../arguments-usage.js"; -import { generatorMeta, type ParamShape } from "./call-signatures.js"; +import { + checkedJsDocumentedParameter, + documentedParameter, + generatorMeta, + type ParamShape, +} from "./call-signatures.js"; import { omittedArgFor } from "./call-arguments.js"; import { isGenericCallableMemberType, @@ -74,11 +79,15 @@ import { isAssertClassNew } from "./assert-instances.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; -import { bufEncoding } from "./containers/bytes.js"; +import { knownBufEncoding } from "./containers/bytes.js"; import { isSafeToRepeat } from "./expressions/evaluation-safety.js"; import { lowerSearchParamsNew } from "./builtins/url.js"; import { lowerTextCodecNew } from "./builtins/text-codecs.js"; -import { lowerMessageChannelNew, lowerWorkerNew } from "./builtins/workers.js"; +import { + lowerBroadcastChannelNew, + lowerMessageChannelNew, + lowerWorkerNew, +} from "./builtins/workers.js"; import { unsupportedPackageValueDiag, unsupportedDiag, @@ -129,6 +138,7 @@ import { hasLexicalPrivateReference, hasStaticThis, rejectStaticThis } from "./s import { lowerUrlNew } from "./lower-url.js"; import { isNativeProxyInitializer, lowerNativeProxy } from "./expressions/native-proxy.js"; import { classStaticDataFor } from "./class-static-data.js"; +import { construction, constructionPrefix } from "./js-class-fields.js"; import { lowerObjectFactoryNew } from "./object-factory-new.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerInstanceConstructorNew } from "./class-instance-constructor.js"; @@ -386,6 +396,9 @@ export interface ClassInfo { def: IrClassDef; expressionInitializationEmitted?: true; runtimeStatics?: readonly ts.ClassElement[]; + /** Direct calls of runtime static methods (directRuntimeStaticCall), by + * member name. */ + runtimeStaticCalls?: Map; runtimePrototypeMembers?: readonly ts.ClassElement[]; localPrototypeData?: true; prototypeMethodValues?: Map; @@ -972,7 +985,8 @@ function jsFieldInitializerType(lowerer: Lowerer, type: IrType, expression: ts.E .some( (decl) => ts.isParameter(decl) && - (ts.isConstructorDeclaration(decl.parent) || + ((ts.isConstructorDeclaration(decl.parent) && + !checkedJsDocumentedParameter(lowerer, decl)) || (lowerer.typeOf(expression).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0), ) ) @@ -2989,7 +3003,14 @@ export function collectClassShapeInner( "overriding a method with a different signature (parameter types must match the base declaration exactly; returns may narrow to a subclass)", ); } - methods.set(mName, { params: shapes, ret, abstract: true }); + const unionReturn = + overridden?.sig.ret.kind === "union" && !typeEquals(ret, overridden.sig.ret); + if (unionReturn) lowerer.unionReturnOverrides.add(member); + methods.set(mName, { + params: shapes, + ret: unionReturn ? overridden.sig.ret : ret, + abstract: true, + }); continue; } // A body-less method is an OVERLOAD SIGNATURE (abstract methods @@ -3256,6 +3277,12 @@ export function collectClassShapeInner( ); } } + // A return narrowed to part of an inherited union returns through + // the union ABI; call sites narrow it back to the declared type. + if (overridden?.sig.ret.kind === "union" && !typeEquals(ft.ret, overridden.sig.ret)) { + ft.ret = overridden.sig.ret; + lowerer.unionReturnOverrides.add(member); + } const asyncMember = member.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) === true; // Async methods dispatch STATICALLY (the body enters through its @@ -3653,6 +3680,19 @@ export function collectClassShapeInner( const keySym = lateBoundKeySymOf(lowerer, p); if (keySym) lateBoundByKey.set(keySym, p); } + // A subclass field created before anything can observe the instance + // keeps its type: super() cannot run code that reads it earlier. + const baseConstruction = + base && ![...methods.keys()].some((m) => m.startsWith("get:") || m.startsWith("set:")) + ? construction(base) + : null; + const sealedPrefix = baseConstruction + ? constructionPrefix( + decl, + baseConstruction, + (name) => !hasAccessor(name) && !methods.has(name) && !lowerer.findMethodOn(base, name), + ) + : null; if (ctor?.body) { for (const stmt of ctor.body.statements) { const lhs = @@ -3723,7 +3763,8 @@ export function collectClassShapeInner( type = inheritedJsFieldType(lowerer, decl, name, type); // super() can invoke a subclass setter or method before its // constructor assignments run. These slots start undefined. - if (base && lowerer.dynConvertible(type)) type = DYN; + if (base && lowerer.dynConvertible(type) && !sealedPrefix?.created.has(name)) + type = DYN; fields.set(name, type); fieldOrder.push({ name, type, initializer: undefined }); continue; @@ -5642,6 +5683,8 @@ function staticCallOn( throughValue: boolean, ): IrExpr | null { const loc = locOf(call); + const runtimeStatic = directRuntimeStaticCall(lowerer, call, access, info, throughValue); + if (runtimeStatic) return runtimeStatic; if ( (hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) || (info.callableBase && @@ -5780,6 +5823,179 @@ function staticCallOn( return { kind: "call", callee: fnName, args, type: found.method.ret, loc }; } +/** `C.m(...)` where `m` is a static method that a function-local + * JavaScript class defines at runtime (a factory's `class extends base`), + * inherited by `C` through its statically known base chain. When the body + * captures nothing from the factory's scope, every evaluation of the class + * defines the same method, so the call lowers directly with `this` bound + * to `C`, as a free function given `C` would. A receiver-dependent result + * (a class built from `this`) then keeps a static base. Null when another + * declaration of `m` answers first, the program assigns that member, or + * the method form has no direct lowering. */ +function directRuntimeStaticCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, + info: ClassInfo, + throughValue: boolean, +): IrExpr | null { + const name = access.name.text; + if (throughValue || name.startsWith("#")) return null; + let declarer: ClassInfo | null = null; + let member: ts.MethodDeclaration | null = null; + for (let c: ClassInfo | null = info; c; c = c.base) { + if ( + c.staticFields.some((field) => field.name === name) || + c.staticMethods?.has(name) === true || + c.genericStatics?.has(name) === true + ) + return null; + const runtime = c.runtimeStatics?.find( + (element) => + (element as { name?: ts.PropertyName }).name !== undefined && + ts.isIdentifier((element as { name: ts.PropertyName }).name) && + (element as { name: ts.Identifier }).name.text === name, + ); + if (runtime) { + if (!ts.isMethodDeclaration(runtime) || !runtime.body) return null; + declarer = c; + member = runtime; + break; + } + } + if (!declarer || !member || !declarer.decl) return null; + if (member.asteriskToken || member.typeParameters) return null; + if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) + return null; + if (hasLexicalSuper(member) || capturesEnclosingScope(lowerer, member, declarer.decl)) + return null; + if (memberAssigned(lowerer, member, name) || bodyReadsArguments(member)) return null; + const implicit = + member.parameters.length === 0 ? null : implicitAnyParamSymbolsOf(lowerer, member, true); + if (member.parameters.length > 0 && !implicit) return null; + let calls = declarer.runtimeStaticCalls; + if (!calls) { + calls = new Map(); + declarer.runtimeStaticCalls = calls; + } + const method: GenericFnInfo = calls.get(name) ?? { + decl: member, + baseName: name, + qualifiedName: `%${declarer.def.name}.runtimeStatic:${name}`, + typeParams: [], + instances: new Map(), + member: { cls: declarer, kind: "static" }, + ...(implicit ? { implicitParams: implicit } : {}), + }; + calls.set(name, method); + // A direct lowering the method form does not support keeps the dynamic + // call: its refusal is not this program's error. + const sink: ScrDiagnostic[] = []; + const previousSink = lowerer.diagSink; + lowerer.diagSink = sink; + let lowered: IrExpr; + try { + const receiver = staticMethodForReceiver(lowerer, access, method, info, false); + const instance = implicit + ? implicitCallInstance(lowerer, call, receiver) + : genericCallInstance(lowerer, call, receiver); + lowered = { + kind: "call", + callee: instance.name, + args: lowerer.completeArgs(call.arguments, instance.params, locOf(call), call), + type: instance.returnType, + loc: locOf(call), + }; + } catch (error) { + if (error instanceof PoisonError) return null; + throw error; + } finally { + lowerer.diagSink = previousSink; + } + return sink.length === 0 ? lowered : null; +} + +/** `super` inside the method (arrows included) names the defining + * class's base, which a receiver specialization does not track. */ +function hasLexicalSuper(root: ts.Node): boolean { + let found = false; + const visit = (node: ts.Node): void => { + if (node.kind === ts.SyntaxKind.SuperKeyword) found = true; + if ( + (ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || + ts.isClassDeclaration(node) || + ts.isClassExpression(node) + ) + return; + node.forEachChild(visit); + }; + root.forEachChild(visit); + return found; +} + +/** The method reads a binding declared in a function that encloses its + * class: each evaluation of the class can see a different value. */ +function capturesEnclosingScope( + lowerer: Lowerer, + member: ts.MethodDeclaration, + classDecl: ts.ClassLikeDeclaration, +): boolean { + let scope: ts.Node | undefined = classDecl.parent; + while (scope && !ts.isFunctionLike(scope) && !ts.isSourceFile(scope)) scope = scope.parent; + if (!scope || ts.isSourceFile(scope)) return false; + const inside = (node: ts.Node, container: ts.Node): boolean => { + for (let n: ts.Node | undefined = node; n; n = n.parent) if (n === container) return true; + return false; + }; + let captured = false; + const visit = (node: ts.Node): void => { + if (captured) return; + if (ts.isIdentifier(node)) { + const symbol = lowerer.checker.getSymbolAtLocation(node); + const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined; + // The enclosing function's own name is the same binding for every + // evaluation. + if (decl && decl !== scope && inside(decl, scope) && !inside(decl, member)) captured = true; + return; + } + node.forEachChild(visit); + }; + member.forEachChild(visit); + return captured; +} + +/** Some code assigns or deletes a member of this name, or defines it by + * reflection: the member a runtime lookup finds can then differ. */ +function memberAssigned(lowerer: Lowerer, member: ts.MethodDeclaration, name: string): boolean { + let found = false; + for (const sf of lowerer.program.getSourceFiles()) { + if (sf.isDeclarationFile || found) continue; + if (!sf.text.includes(name)) continue; + ts.walkPreorder(sf, (node) => { + if (found) return "skip"; + if (ts.isPropertyAccessExpression(node) && node.name.text === name) { + const parent = node.parent; + if ( + parent && + ((ts.isBinaryExpression(parent) && + parent.left === node && + parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) || + ts.isDeleteExpression(parent)) + ) + found = true; + } + if (ts.isStringLiteral(node) && node.text === name && node !== member.name) { + const parent = node.parent; + if (parent && (ts.isCallExpression(parent) || ts.isElementAccessExpression(parent))) + found = true; + } + return undefined; + }); + } + return found; +} + function staticMethodForReceiver( lowerer: Lowerer, access: ts.PropertyAccessExpression, @@ -5955,6 +6171,22 @@ function overrideReturnAbi( className: string, base: ClassInfo | null, ): IrType { + // A return narrowed to part of an inherited union (`Meta` for + // `Meta | undefined`) keeps the union ABI; returns convert into it. + if (inherited.kind === "union" && !typeEquals(declared, inherited)) { + const arms = lowerer.unions.get(inherited.unionId)?.arms ?? []; + const fits = (type: IrType): boolean => + arms.some( + (arm) => + typeEquals(arm, type) || + (arm.kind === "object" && + type.kind === "object" && + lowerer.isSubclassOf(type.className, arm.className)), + ); + const parts = + declared.kind === "union" ? (lowerer.unions.get(declared.unionId)?.arms ?? []) : [declared]; + return parts.length > 0 && parts.every(fits) ? inherited : declared; + } if (declared.kind !== "object" || inherited.kind !== "object") return declared; if ( lowerer.isSubclassOf(declared.className, inherited.className) || @@ -6222,7 +6454,8 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou for (const declaration of property ? lowerer.checker.declarationsOf(property) : []) { if ( ts.isMethodDeclaration(declaration) && - isJsSourceFile(declaration.getSourceFile()) + isJsSourceFile(declaration.getSourceFile()) && + !checkedJsOverride(lowerer, declaration, member) ) { lowerer.virtualJsMethods.add(declaration); if ( @@ -6292,6 +6525,21 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou } } +/** An override whose base and overriding methods are in files TypeScript + * checks and document every parameter: TypeScript checks the override + * against its base, so both keep their declared signatures, as in + * TypeScript classes. */ +function checkedJsOverride( + lowerer: Lowerer, + base: ts.MethodDeclaration, + member: ts.MethodDeclaration, +): boolean { + return [base, member].every( + (method) => + lowerer.checkedJsFile(method.getSourceFile()) && method.parameters.every(documentedParameter), + ); +} + /** The receiver's EXACT runtime class, when the expression proves it: a * `new C(...)` expression directly, or a const binding initialized with * one (the binding can never be reassigned to a subclass instance). @@ -6740,7 +6988,8 @@ export function classMemberNameOf(lowerer: Lowerer, name: ts.PropertyName): stri member === "iterator" || member === "asyncIterator" || member === "dispose" || - member === "asyncDispose" + member === "asyncDispose" || + member === "toPrimitive" ) { return `sym:${member}`; } @@ -7612,7 +7861,6 @@ export function superCallStmt( args: IrExpr[], loc: SrcLoc, ): IrStmt { - const base = superBaseOf(info)!; const thisRef: IrExpr = { kind: "varRef", localId: thisLocal.id, @@ -7657,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 @@ -8153,7 +8403,7 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { if (worker) return worker; const readlineUi = lowerReadlineUiNew(lowerer, expr); if (readlineUi) return readlineUi; - const channel = lowerMessageChannelNew(lowerer, expr); + const channel = lowerMessageChannelNew(lowerer, expr) ?? lowerBroadcastChannelNew(lowerer, expr); if (channel) return channel; let intlConstructor = expr.expression; if (ts.isIdentifier(intlConstructor)) { @@ -8645,20 +8895,10 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { }; const pattern = strArg(args[0], "pattern"); const flags = strArg(args[1], "flags argument"); - // Node accepts the 'd' and 'v' flags the engine does not have; a - // literal flags string refuses here like the literal regex form. - if (flags.kind === "strLit" && args[1] !== undefined) { - for (const f of ["d", "v"]) { - if (flags.value.includes(f)) { - lowerer.unsupported( - "SC1120", - args[1], - f === "d" - ? "the regex 'd' flag (match indices)" - : "the regex 'v' flag (unicode sets)", - ); - } - } + // Node accepts the 'd' flag the engine does not have; a literal + // flags string refuses here like the literal regex form. + if (flags.kind === "strLit" && args[1] !== undefined && flags.value.includes("d")) { + lowerer.unsupported("SC1120", args[1], "the regex 'd' flag (match indices)"); } return { kind: "libCall", @@ -8727,24 +8967,41 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr { ); } // `new StringDecoder(encoding?)` (node:string_decoder): the decoder - // is a two-field record — the CANONICAL encoding name (aliases fold - // at compile time, exactly what `.encoding` answers in Node) and - // the packed-f64 pending state starting at 0 (nothing buffered). - // The encoding must be a literal (Node's alias set); omitted means - // utf8, Node's default. + // is a two-field record — the CANONICAL encoding name (exactly what + // `.encoding` answers in Node) and the packed-f64 pending state + // starting at 0 (nothing buffered). A literal Buffer encoding folds + // its alias at compile time; any other encoding argument is + // normalized at run time (Node's normalizeEncoding, which throws + // ERR_UNKNOWN_ENCODING for an unknown name). Omitted means utf8, + // Node's default. if (symbol && symbol.name === "StringDecoder" && lowerer.isStdlibSymbol(symbol)) { const args = expr.arguments ?? []; if (args.length > 1) { lowerer.noLowering("new StringDecoder with 2 arguments", expr, undefined, symbol); } - const encName = - args.length === 1 ? bufEncoding(lowerer, "new StringDecoder", args[0]!) : "utf8"; + const encNode = args[0]; + const encT = encNode ? lowerer.typeOf(encNode) : undefined; + const encName = !encNode + ? "utf8" + : encT!.isStringLiteralType() + ? knownBufEncoding(encT!.value) + : undefined; + const encoding: IrExpr = + encName !== undefined + ? { kind: "strLit", value: encName, type: STRING, loc } + : { + kind: "libCall", + fn: "strdec.encoding", + args: [lowerer.lowerExprExpecting(encNode!, DYN)], + type: STRING, + loc, + }; const decT = lowerer.mapTypeOf(lowerer.typeOf(expr)); if (decT?.kind !== "record") lowerer.badType(expr, lowerer.typeOf(expr)); return { kind: "recordLit", fields: [ - { name: "%enc", value: { kind: "strLit", value: encName, type: STRING, loc } }, + { name: "%enc", value: encoding }, { name: "%pending", value: { kind: "numLit", value: 0, type: F64, loc } }, ], type: decT, diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index c7fae7607..e73bbd4ce 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -1072,32 +1072,19 @@ export function lowerEmitterMethodCall( lowerer.checker.getConstructSignatures(lowerer.typeOf(access.expression)).length > 0 ) { if (member === "addAbortListener") return lowerAddAbortListener(lowerer, call, loc); - if (member === "setMaxListeners" && args.length === 1) { - if (lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind !== "f64") { - // Node's runtime ladder over the dyn value ("setMaxListeners" is - // the message's slot for the static form). - const raw = lowerer.lowerExpr(args[0]!); - if (raw.type.kind === "dyn" || raw.kind === "unitLit" || lowerer.dynConvertible(raw.type)) { - const n: IrExpr = - raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; - return { - kind: "libCall", - fn: "emitter.setDefaultMaxChk", - args: [n, { kind: "strLit", value: "setMaxListeners", type: STRING, loc }], - type: VOID, - loc, - }; - } - } - const n = lowerer.lowerExprExpecting(args[0]!, F64); - if (n.type.kind !== "f64") { - lowerer.noLowering( - `EventEmitter.setMaxListeners with a '${lowerer.fmt(n.type)}' argument`, - args[0]!, - "the lowered form takes a number (Node throws ERR_INVALID_ARG_TYPE at runtime for other values)", - ); - } - return { kind: "libCall", fn: "emitter.setDefaultMax", args: [n], type: VOID, loc }; + // The events module's functions are EventEmitter's statics too. + if (member === "once") return lowerEventsOnce(lowerer, call, loc); + if (member === "setMaxListeners" && args.length === 1) + return lowerDefaultMaxListenersSet(lowerer, args[0]!, loc); + if ( + member === "listenerCount" || + member === "getMaxListeners" || + member === "getEventListeners" || + (member === "setMaxListeners" && + args.length > 1 && + args.slice(1).every((arg) => lowerer.mapTypeOf(lowerer.typeOf(arg))?.kind === "object")) + ) { + return lowerEventsEmitterHelper(lowerer, call, member, loc)!; } if (member === "setMaxListeners") { // The per-target form with a target that is provably NOT an @@ -2257,3 +2244,165 @@ export function lowerEventsOnce(lowerer: Lowerer, call: ts.CallExpression, loc: loc, }; } + +/** The EventEmitter argument of an events-module helper: an emitter + * instance (not a stream, whose listener tuples the runtime pins). */ +function helperEmitter(lowerer: Lowerer, member: string, node: ts.Expression): IrExpr { + const value = lowerer.lowerExpr(node); + const info = value.type.kind === "object" ? lowerer.classes.get(value.type.className) : undefined; + if (!emitterRooted(lowerer, info) || streamSidesOf(lowerer, info) !== null) { + lowerer.noLowering( + `events.${member} over this value`, + node, + "the argument must be an EventEmitter instance; EventTarget and stream arguments are not supported yet", + ); + } + return value; +} + +/** setMaxListeners(n) with no targets: the process-wide default, through + * Node's validateNumber(n, 'setMaxListeners', 0) ladder. */ +function lowerDefaultMaxListenersSet(lowerer: Lowerer, node: ts.Expression, loc: SrcLoc): IrExpr { + if (lowerer.mapTypeOf(lowerer.typeOf(node))?.kind !== "f64") { + // Node's runtime ladder over the dyn value ("setMaxListeners" is + // the message's slot for the static form). + const raw = lowerer.lowerExpr(node); + if (raw.type.kind === "dyn" || raw.kind === "unitLit" || lowerer.dynConvertible(raw.type)) { + const n: IrExpr = + raw.type.kind === "dyn" ? raw : { kind: "dynFrom", value: raw, type: DYN, loc }; + return { + kind: "libCall", + fn: "emitter.setDefaultMaxChk", + args: [n, { kind: "strLit", value: "setMaxListeners", type: STRING, loc }], + type: VOID, + loc, + }; + } + } + const n = lowerer.lowerExprExpecting(node, F64); + if (n.type.kind !== "f64") { + lowerer.noLowering( + `EventEmitter.setMaxListeners with a '${lowerer.fmt(n.type)}' argument`, + node, + "the lowered form takes a number (Node throws ERR_INVALID_ARG_TYPE at runtime for other values)", + ); + } + return { kind: "libCall", fn: "emitter.setDefaultMax", args: [n], type: VOID, loc }; +} + +/** The events module's helpers over EventEmitter instances, also reachable + * as EventEmitter statics: listenerCount(emitter, name), getMaxListeners + * (emitter), setMaxListeners(n[, ...emitters]) and getEventListeners + * (emitter, name). Node answers each through the emitter's own method, so + * they lower as those methods do. EventTarget and stream arguments are + * refused. Null for other members. */ +export function lowerEventsEmitterHelper( + lowerer: Lowerer, + call: ts.CallExpression, + member: string, + loc: SrcLoc, +): IrExpr | null { + const args = call.arguments; + if (args.some(ts.isSpreadElement)) { + lowerer.noLowering(`events.${member} with spread arguments`, call); + } + if (member === "listenerCount") { + if (args.length !== 2) + lowerer.noLowering(`events.listenerCount with ${args.length} arguments`, call); + const receiver = helperEmitter(lowerer, member, args[0]!); + const name = isErrorMonitorRef(lowerer, args[1]!) + ? eventNameExpr(ERROR_MONITOR_EVENT, loc) + : lowerer.lowerExprExpecting(args[1]!, STRING); + if (name.type.kind !== "string") { + lowerer.noLowering( + `events.listenerCount with a '${lowerer.fmt(name.type)}' event name`, + args[1]!, + ); + } + return { kind: "libCall", fn: "emitter.count", args: [receiver, name], type: F64, loc }; + } + if (member === "getMaxListeners") { + if (args.length !== 1) + lowerer.noLowering(`events.getMaxListeners with ${args.length} arguments`, call); + const receiver = helperEmitter(lowerer, member, args[0]!); + return { kind: "libCall", fn: "emitter.getMax", args: [receiver], type: F64, loc }; + } + if (member === "setMaxListeners") { + // An omitted n is the current default, assigned again. + if (args.length === 0) { + return { + kind: "libCall", + fn: "emitter.setDefaultMax", + args: [{ kind: "libCall", fn: "emitter.getDefaultMax", args: [], type: F64, loc }], + type: VOID, + loc, + }; + } + if (args.length === 1) return lowerDefaultMaxListenersSet(lowerer, args[0]!, loc); + // Node evaluates every argument, validates n, then sets each target. + const n = lowerer.lowerExprExpecting(args[0]!, F64); + if (n.type.kind !== "f64") { + lowerer.noLowering( + `events.setMaxListeners with a '${lowerer.fmt(n.type)}' limit and targets`, + args[0]!, + "pass a number", + ); + } + const limit = lowerer.declareHiddenLocal("%maxListeners", F64); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: limit.id, init: n, loc }]; + const targets = args.slice(1).map((node) => { + const value = helperEmitter(lowerer, member, node); + const slot = lowerer.declareHiddenLocal("%maxListenersTarget", value.type); + stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc }); + return varRef(slot.id, value.type, loc); + }); + for (const target of targets) { + stmts.push({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "emitter.setMax", + args: [target, varRef(limit.id, F64, loc)], + type: target.type, + loc, + }, + loc, + }); + } + // The effects sit in the statements; the call answers undefined. + const tail: IrExpr = { kind: "libCall", fn: "console.noop", args: [], type: VOID, loc }; + return { kind: "seqExpr", stmts, result: tail, type: VOID, loc }; + } + if (member === "getEventListeners") { + if (args.length !== 2) + lowerer.noLowering(`events.getEventListeners with ${args.length} arguments`, call); + // emitter.listeners(name): the event's listener array, typed by the + // event's unified tuple (the instance method's rules). + const receiver = helperEmitter(lowerer, member, args[0]!); + const name = eventNameOf(lowerer, "events.getEventListeners", args[1]!); + const table = emitterEvents(lowerer); + const sig = table.get(name); + if ( + sig?.dynListener || + flexibleJsEvent(name, sig) || + computedPatterns(lowerer).some( + (pattern) => pattern.listener && patternMatchesName(pattern, name), + ) + ) { + lowerer.noLowering( + `events.getEventListeners of the event '${name}'`, + call, + "a listener of this event uses checked-dynamic dispatch, so the listener array has no one static element type — listenerCount(emitter, name) counts", + ); + } + const tuple = tupleOf(lowerer, table, name, call); + return { + kind: "libCall", + fn: "emitter.listeners", + args: [receiver, eventNameExpr(name, loc)], + type: arrayOf({ kind: "func", params: tuple, ret: { kind: "void" } }), + loc, + }; + } + return null; +} diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index af9e64f30..f0538bb71 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1,7 +1,9 @@ +import { dateJsonType, isDateJsonSource } from "./builtins/dates.js"; import { guardNullProto, nullProtoRecordShape, recordNullProtoTest } from "./null-prototype.js"; import { lowerProtocolPropertyRead } from "./class-protocol-dispatch.js"; import { generatorDrain } from "./iterator-adapters.js"; import { lowerEventObjectProperty, lowerEventObjectPropertyWrite } from "./lower-event-target.js"; +import { lowerDisposableStackInstanceOf } from "./builtins/disposable-stack.js"; import { lowerEventStaticProperty } from "./builtins/numbers.js"; import { lowerUnionEquality, tagEqualityMayMissAlias } from "./strict-equality.js"; import { isOptionalProcessStreamProperty } from "./builtins/process.js"; @@ -9,6 +11,7 @@ import { lowerWorkerMetadata } from "./builtins/workers.js"; import { lowerReadlineProperty } from "./builtins/readline.js"; import { lowerBlobInstanceof } from "./builtins/blob.js"; import { lowerKeyObjectInstanceof } from "./builtins/key-object.js"; +import { lowerNetAddressInstanceof, lowerNetAddressStaticRead } from "./builtins/net-address.js"; import { STATS_TYPE_TESTS } from "./builtins/filesystem.js"; import { threadsImportOf } from "../threads-import.js"; import { experimentalImportOf, lifetimeRegionMemberOf } from "../experimental-import.js"; @@ -224,7 +227,7 @@ import { } from "./lower-builtin-values.js"; import { lowerArrayFromValue } from "./containers/array-construction.js"; import { lowerPerfHooksTypeof } from "./builtins/performance.js"; -import { lowerUrlAssignment } from "./builtins/url.js"; +import { isSearchParamsIterable, lowerUrlAssignment } from "./builtins/url.js"; import { jsBindingHasOpenWrites } from "./lower-stmts.js"; import { checkedClassInstanceOf } from "./class-construction.js"; import { lowerModuleNamespaceElement } from "./module-namespace-elements.js"; @@ -645,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) { @@ -3310,7 +3337,11 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx (expr.kind === "recordGet" || expr.kind === "recordKeyGet") && (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) ) { - const receiver = lowerer.mapTypeOf(lowerer.typeOf(node.expression)); + // The read itself rules out a null or undefined receiver, which a + // JavaScript receiver such as `m.groups` still declares. + const receiver = lowerer.mapTypeOf( + lowerer.checker.getNonNullableType(lowerer.typeOf(node.expression)), + ); const shape = receiver?.kind === "record" ? lowerer.shapes.get(receiver.shapeId) : undefined; const declared = expr.kind === "recordGet" @@ -3322,10 +3353,23 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx !(declared.kind === "union" && lowerer.armTag(declared.unionId, UNDEFINED_T) >= 0) ) return expr; + // The same for a field holding a Date's toJSON() result, whose null + // the declared string does not represent. + if ( + expr.kind === "recordGet" && + lowerer.isRuntimeOptionalField(expr.shapeId, expr.field) && + lowerer.armTag(expr.type.unionId, NULL_T) >= 0 && + declared && + !(declared.kind === "union" && lowerer.armTag(declared.unionId, NULL_T) >= 0) + ) + return expr; } if (ts.isIdentifier(node)) { const symbol = lowerer.resolveValueSymbol(node); if (symbol && lowerer.unstableSwitchBindings.has(symbol)) return expr; + // A binding initialized by a Date's toJSON() can hold null where the + // checker says string; consumers check it. + if (symbol && lowerer.dateJsonBindings.has(symbol)) return expr; } if ( ts.isPropertyAccessExpression(node) && @@ -4927,6 +4971,8 @@ export function lowerIntrinsicProperty( return lowerNumberParserValue(lowerer, numberMember, locOf(expr)); if (lowerer.stdlibGlobalMember(expr, "Buffer") === "poolSize") return { kind: "libCall", fn: "buffer.poolSize", args: [], type: F64, loc: locOf(expr) }; + const netAddressStatic = lowerNetAddressStaticRead(lowerer, expr); + if (netAddressStatic) return netAddressStatic; if (lowerer.stdlibGlobalMember(expr, "Buffer") === "isBuffer") return lowerCheckedPredicateValue(lowerer, "Buffer.isBuffer", "buffer", locOf(expr)); if (lowerer.stdlibGlobalMember(expr, "Date") === "now") @@ -5310,6 +5356,27 @@ export function lowerIntrinsicProperty( loc: locOf(expr), }; } + // The flag getters answer whether `flags` holds the flag's letter. + const flag = REGEX_FLAG_GETTERS.get(name); + if (flag !== undefined) { + const loc = locOf(expr); + const flags: IrExpr = { + kind: "regexIntrinsic", + method: "flags", + receiver: strictReceiver({ kind: "regex" }), + args: [], + type: STRING, + loc, + }; + return { + kind: "strIntrinsic", + method: "includes", + receiver: flags, + args: [{ kind: "strLit", value: flag, type: STRING, loc }], + type: BOOL, + loc, + }; + } if (name === "test") { lowerer.unsupported("SC1090", expr, "regex methods as values (call 'test' directly)"); } @@ -5494,6 +5561,31 @@ export function lowerIntrinsicProperty( return null; } +/** JavaScript: whether `expr` is or holds an existing checked-dynamic + * object — a binding stored as a dyn value whose checker type is an + * object, or an array or object literal with such an element. Building + * the enclosing literal with a static element type would copy it. */ +export function jsHoldsDynObject(lowerer: Lowerer, expr: ts.Expression): boolean { + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (ts.isIdentifier(expr)) { + const stored = lowerer.peekLocal(expr)?.type ?? lowerer.globalOf(expr)?.type; + return stored?.kind === "dyn" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Object) !== 0; + } + if (ts.isArrayLiteralExpression(expr)) + return expr.elements.some( + (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), + ); + if (ts.isObjectLiteralExpression(expr)) + return expr.properties.some( + (property) => + (ts.isPropertyAssignment(property) && jsHoldsDynObject(lowerer, property.initializer)) || + (ts.isShorthandPropertyAssignment(property) && + ts.isIdentifier(property.name) && + jsHoldsDynObject(lowerer, property.name)), + ); + return false; +} + /** `[a, b, c]`. The element type comes from the contextual type when tsc * has one (`const a: number[] = []`, arguments, nested literals) and from * the literal's own inferred type otherwise. A bare `[]` with no context @@ -5621,6 +5713,15 @@ export function lowerArrayLiteral( } } } + // A Date's toJSON() element keeps its null in a literal with no declared + // destination: the lib types say string. + if ( + !expected && + mapped?.kind === "array" && + mapped.elem.kind === "string" && + expr.elements.some((element) => isDateJsonSource(lowerer, element)) + ) + mapped = { kind: "array", elem: dateJsonType(lowerer) }; // An array literal inside an npm-static implicit-any instance can // inherit the checker's poisoned any[] context even after the // active binding gives every element one concrete lowering. Recover a @@ -5665,6 +5766,18 @@ export function lowerArrayLiteral( ) ) mapped = DYN; + // An element that is an existing checked-dynamic object (a file-scope + // object literal binding, or a literal holding one) keeps its identity + // only in a checked-dynamic array: a static element type would copy it. + if ( + !expected && + isJsSourceFile(expr.getSourceFile()) && + mapped?.kind !== "dyn" && + expr.elements.some( + (element) => !ts.isSpreadElement(element) && jsHoldsDynObject(lowerer, element), + ) + ) + mapped = DYN; // An EMPTY literal under a CONST ASSERTION whose type queries panicked // (tsgo's readonly-[] TupleType conversion) uses a unit-element array, // mapType's own `[] as const` rule. @@ -5849,6 +5962,11 @@ export function lowerArrayLiteral( const drained = generatorDrain(lowerer, src, type.elem, locOf(el)); if (drained) src = drained; } + // `[...sp]` and `[...sp.entries()]` over a URLSearchParams: its + // checked-dynamic view iterates the live pair list, and the drained + // [name, value] pairs convert to the literal's tuple elements. + const searchParamsSource = isSearchParamsIterable(lowerer, el.expression); + if (src.type.kind === "searchParams") src = lowerer.coerceInto(el.expression, src, DYN); // A native checked iterable can supply scalar elements even when // the checker inferred a typed array from its producer. Drain once // and validate the elements before the ordinary spread copy. @@ -5857,7 +5975,8 @@ export function lowerArrayLiteral( (type.elem.kind === "dyn" || type.elem.kind === "f64" || type.elem.kind === "string" || - type.elem.kind === "bool") + type.elem.kind === "bool" || + searchParamsSource) ) { src = lowerer.coerceInto(el.expression, checkedIteratorPack(lowerer, src, locOf(el)), type); } @@ -10227,7 +10346,6 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr lowerOptionalNumber(lowerer, operand, loc, node); const arithmeticLeft = strictEquality ? left : arithmeticNumber(left, expr.left); const arithmeticRight = strictEquality ? right : arithmeticNumber(right, expr.right); - const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; const bothNum = arithmeticLeft.type.kind === "f64" && arithmeticRight.type.kind === "f64"; const bothStr = left.type.kind === "string" && right.type.kind === "string"; @@ -10449,234 +10567,17 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr }; } case ts.SyntaxKind.EqualsEqualsEqualsToken: - case ts.SyntaxKind.ExclamationEqualsEqualsToken: { - const negated = op === ts.SyntaxKind.ExclamationEqualsEqualsToken; - const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); - if (indexed) return indexed; - if (plainBothNum) - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - if (bothStr) { - if (left.kind === "strLit" && right.kind === "strLit") - return { - kind: "boolLit", - value: (left.value === right.value) !== negated, - type: BOOL, - loc, - }; - return { kind: "strEq", negated, left, right, type: BOOL, loc }; - } - // bool === bool: a plain value compare (the config-drift checks' - // `desired.lanMode !== actual.lanMode` shape). - if (left.type.kind === "bool" && right.type.kind === "bool") { - return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; - } - const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); - if (unitTest) return unitTest; - if (left.type.kind === "union" || right.type.kind === "union") { - const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); - if (compared) return compared; - const aliasRefusal = (type: IrType): void => { - if (tagEqualityMayMissAlias(lowerer, type)) - lowerer.unsupported( - "SC1090", - expr, - `comparisons of related class values beside structural union members (${NARROW_FIRST})`, - ); - }; - - // JS strict equality of the ARM values, per union tag (a - // per-union helper): equal tags compare payloads (f64 ==, string - // bytes, bool ==, ref-arm POINTER identity), different tags are - // never equal. Two shapes lower: same union on both sides, and - // union vs plain arm value (`u === "text"` where the checker - // didn't narrow) — the plain side wraps into the union, which - // preserves payload identity. Different unions (tsc admits - // partially-overlapping ones) still need narrowing first. - const ut = - left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" }); - const bothUnion = left.type.kind === "union" && right.type.kind === "union"; - const sameUnion = bothUnion && typeEquals(left.type, right.type); - const plainFunc = - left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; - const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; - const funcArm = unionArms.find((arm) => arm.kind === "func"); - // `listeners()` reports original prefix callbacks, but the - // checker views an indexed result as function | undefined. For - // identity comparison, retain the original closure pointer rather - // than adapting it to the tuple signature. - if ( - !bothUnion && - plainFunc !== null && - funcArm?.kind === "func" && - unionArms.filter((arm) => arm.kind === "func").length === 1 && - unionArms.some(isUnitType) - ) { - return { - kind: "unionFuncEq", - unionId: ut.unionId, - tag: lowerer.armTag(ut.unionId, funcArm), - union: left.type.kind === "union" ? left : right, - func: plainFunc, - negated, - type: BOOL, - loc, - }; - } - if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { - aliasRefusal(ut); - return { - kind: "unionEq", - unionId: ut.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(expr.left, left, ut), - right: lowerer.coerceInto(expr.right, right, ut), - type: BOOL, - loc, - }; - } - if (left.type.kind === "union" && right.type.kind === "union") { - const leftDef = lowerer.unions.get(left.type.unionId); - const rightDef = lowerer.unions.get(right.type.unionId); - if (leftDef && rightDef) { - const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); - const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); - const sameValueArms = - leftValues.length === rightValues.length && - leftValues.every((arm) => - rightValues.some((candidate) => typeEquals(candidate, arm)), - ); - if (sameValueArms) { - const arms = [...leftDef.arms]; - for (const arm of rightDef.arms) { - if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); - } - arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); - const common: IrType & { kind: "union" } = { - kind: "union", - unionId: lowerer.unions.intern(arms), - }; - aliasRefusal(common); - return { - kind: "unionEq", - unionId: common.unionId, - negated, - sameValue: false, - left: lowerer.coerceInto(expr.left, left, common), - right: lowerer.coerceInto(expr.right, right, common), - type: BOOL, - loc, - }; - } - } - } - lowerer.unsupported("SC1090", expr, `comparisons of union-typed values (${NARROW_FIRST})`); - } - // Arrays, maps, functions, class instances and records compare by - // reference identity (pointer compare) — JS-exact object equality. - // Hierarchy-related classes compare after widening the derived side - // (same pointer either way — prefix layout). - let idLeft = left; - let idRight = right; - if (left.type.kind === "object" && right.type.kind === "object") { - if (lowerer.isSubclassOf(left.type.className, right.type.className)) { - idLeft = lowerer.upcastTo(left, right.type.className); - } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { - idRight = lowerer.upcastTo(right, left.type.className); - } - } - // Two function values compare by identity whatever their STATIC - // signatures (tsc admits the comparison when one side is - // assignable to the other — a tuple-typed listeners() element - // against a prefix-declared handler): the pointer answers it. - if (idLeft.type.kind === "func" && idRight.type.kind === "func") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Two CLASS values compare by identity whatever their static - // classes — one immortal object per class, one pointer compare - // (the function-identity rule verbatim; `X === D` through a - // base-typed slot answers exactly JS). - if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if ( - (idLeft.type.kind === "array" || - idLeft.type.kind === "map" || - idLeft.type.kind === "set" || - idLeft.type.kind === "regex" || - idLeft.type.kind === "url" || - idLeft.type.kind === "object" || - idLeft.type.kind === "record" || - // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, - // a symbol equals exactly itself, and Symbol.for's interned - // values compare equal across call sites — all one pointer - // compare, JS's spec without approximation. - idLeft.type.kind === "symbol" || - // Typed arrays / Buffers ARE objects to ===: pointer identity - // (buf === buf.swap16() — the in-place mutators return this). - idLeft.type.kind === "bytes" || - idLeft.type.kind === "promise") && - typeEquals(idLeft.type, idRight.type) - ) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - // Runtime HANDLES are objects to === too: one handle per socket/ - // request/response, so pointer identity IS JS's object equality - // (`c.pause() === c` — Node's chaining assertions). */ - if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { - return { - kind: "bin", - op: negated ? "!==" : "===", - left: idLeft, - right: idRight, - type: BOOL, - loc, - }; - } - if (lowerer.dynConvertible(idLeft.type) && lowerer.dynConvertible(idRight.type)) { - return { - kind: "dynScalarEq", - left: lowerer.coerceToExpected(idLeft, DYN), - right: lowerer.coerceToExpected(idRight, DYN), - ...(negated ? { negated: true as const } : {}), - type: BOOL, - loc, - }; - } - lowerer.unsupported("SC1043", expr); - break; - } + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return lowerStrictEquality( + lowerer, + expr, + expr.left, + expr.right, + left, + right, + op === ts.SyntaxKind.ExclamationEqualsEqualsToken, + loc, + ); case ts.SyntaxKind.LessThanToken: case ts.SyntaxKind.LessThanEqualsToken: case ts.SyntaxKind.GreaterThanToken: @@ -10699,7 +10600,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", @@ -10725,6 +10633,250 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr ); } +/** `left === right` (`!==` when negated) over lowered operands: JS strict + * equality for every pair of static kinds the program can compare. + * `node` anchors diagnostics, and `leftNode`/`rightNode` the coercions. */ +export function lowerStrictEquality( + lowerer: Lowerer, + node: ts.Node, + leftNode: ts.Node, + rightNode: ts.Node, + left: IrExpr, + right: IrExpr, + negated: boolean, + loc: SrcLoc, +): IrExpr { + const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64"; + const bothStr = left.type.kind === "string" && right.type.kind === "string"; + const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc); + if (indexed) return indexed; + if (plainBothNum) + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + if (bothStr) { + if (left.kind === "strLit" && right.kind === "strLit") + return { + kind: "boolLit", + value: (left.value === right.value) !== negated, + type: BOOL, + loc, + }; + return { kind: "strEq", negated, left, right, type: BOOL, loc }; + } + // bool === bool: a plain value compare (the config-drift checks' + // `desired.lanMode !== actual.lanMode` shape). + if (left.type.kind === "bool" && right.type.kind === "bool") { + return { kind: "bin", op: negated ? "!==" : "===", left, right, type: BOOL, loc }; + } + const unitTest = lowerer.lowerUnitComparison(left, right, negated, loc); + if (unitTest) return unitTest; + if (left.type.kind === "union" || right.type.kind === "union") { + const compared = lowerUnionEquality(lowerer, left, right, negated, false, loc); + if (compared) return compared; + const aliasRefusal = (type: IrType): void => { + if (tagEqualityMayMissAlias(lowerer, type)) + lowerer.unsupported( + "SC1090", + node, + `comparisons of related class values beside structural union members (${NARROW_FIRST})`, + ); + }; + + // JS strict equality of the ARM values, per union tag (a + // per-union helper): equal tags compare payloads (f64 ==, string + // bytes, bool ==, ref-arm POINTER identity), different tags are + // never equal. Two shapes lower: same union on both sides, and + // union vs plain arm value (`u === "text"` where the checker + // didn't narrow) — the plain side wraps into the union, which + // preserves payload identity. Different unions (tsc admits + // partially-overlapping ones) still need narrowing first. + const ut = left.type.kind === "union" ? left.type : (right.type as IrType & { kind: "union" }); + const bothUnion = left.type.kind === "union" && right.type.kind === "union"; + const sameUnion = bothUnion && typeEquals(left.type, right.type); + const plainFunc = left.type.kind === "func" ? left : right.type.kind === "func" ? right : null; + const unionArms = lowerer.unions.get(ut.unionId)?.arms ?? []; + const funcArm = unionArms.find((arm) => arm.kind === "func"); + // `listeners()` reports original prefix callbacks, but the + // checker views an indexed result as function | undefined. For + // identity comparison, retain the original closure pointer rather + // than adapting it to the tuple signature. + if ( + !bothUnion && + plainFunc !== null && + funcArm?.kind === "func" && + unionArms.filter((arm) => arm.kind === "func").length === 1 && + unionArms.some(isUnitType) + ) { + return { + kind: "unionFuncEq", + unionId: ut.unionId, + tag: lowerer.armTag(ut.unionId, funcArm), + union: left.type.kind === "union" ? left : right, + func: plainFunc, + negated, + type: BOOL, + loc, + }; + } + if ((sameUnion || !bothUnion) && lowerer.eqComparableUnion(ut.unionId)) { + aliasRefusal(ut); + return { + kind: "unionEq", + unionId: ut.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, ut), + right: lowerer.coerceInto(rightNode, right, ut), + type: BOOL, + loc, + }; + } + if (left.type.kind === "union" && right.type.kind === "union") { + const leftDef = lowerer.unions.get(left.type.unionId); + const rightDef = lowerer.unions.get(right.type.unionId); + if (leftDef && rightDef) { + const leftValues = leftDef.arms.filter((arm) => !isUnitType(arm)); + const rightValues = rightDef.arms.filter((arm) => !isUnitType(arm)); + const sameValueArms = + leftValues.length === rightValues.length && + leftValues.every((arm) => rightValues.some((candidate) => typeEquals(candidate, arm))); + if (sameValueArms) { + const arms = [...leftDef.arms]; + for (const arm of rightDef.arms) { + if (!arms.some((candidate) => typeEquals(candidate, arm))) arms.push(arm); + } + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + const common: IrType & { kind: "union" } = { + kind: "union", + unionId: lowerer.unions.intern(arms), + }; + aliasRefusal(common); + return { + kind: "unionEq", + unionId: common.unionId, + negated, + sameValue: false, + left: lowerer.coerceInto(leftNode, left, common), + right: lowerer.coerceInto(rightNode, right, common), + type: BOOL, + loc, + }; + } + } + } + lowerer.unsupported("SC1090", node, `comparisons of union-typed values (${NARROW_FIRST})`); + } + // Arrays, maps, functions, class instances and records compare by + // reference identity (pointer compare) — JS-exact object equality. + // Hierarchy-related classes compare after widening the derived side + // (same pointer either way — prefix layout). + let idLeft = left; + let idRight = right; + if (left.type.kind === "object" && right.type.kind === "object") { + if (lowerer.isSubclassOf(left.type.className, right.type.className)) { + idLeft = lowerer.upcastTo(left, right.type.className); + } else if (lowerer.isSubclassOf(right.type.className, left.type.className)) { + idRight = lowerer.upcastTo(right, left.type.className); + } + } + // Two function values compare by identity whatever their STATIC + // signatures (tsc admits the comparison when one side is + // assignable to the other — a tuple-typed listeners() element + // against a prefix-declared handler): the pointer answers it. + if (idLeft.type.kind === "func" && idRight.type.kind === "func") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Two CLASS values compare by identity whatever their static + // classes — one immortal object per class, one pointer compare + // (the function-identity rule verbatim; `X === D` through a + // base-typed slot answers exactly JS). + if (idLeft.type.kind === "classval" && idRight.type.kind === "classval") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if (idLeft.type.kind === "moduleNs" && idRight.type.kind === "moduleNs") { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + if ( + (idLeft.type.kind === "array" || + idLeft.type.kind === "map" || + idLeft.type.kind === "set" || + idLeft.type.kind === "regex" || + idLeft.type.kind === "url" || + idLeft.type.kind === "object" || + idLeft.type.kind === "record" || + // Symbols ARE identity: `Symbol('a') === Symbol('a')` is false, + // a symbol equals exactly itself, and Symbol.for's interned + // values compare equal across call sites — all one pointer + // compare, JS's spec without approximation. + idLeft.type.kind === "symbol" || + // Typed arrays / Buffers ARE objects to ===: pointer identity + // (buf === buf.swap16() — the in-place mutators return this). + idLeft.type.kind === "bytes" || + idLeft.type.kind === "promise") && + typeEquals(idLeft.type, idRight.type) + ) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // Runtime HANDLES are objects to === too: one handle per socket/ + // request/response, so pointer identity IS JS's object equality + // (`c.pause() === c` — Node's chaining assertions). */ + if (DYN_HANDLE_KINDS.has(idLeft.type.kind) && typeEquals(idLeft.type, idRight.type)) { + return { + kind: "bin", + op: negated ? "!==" : "===", + left: idLeft, + right: idRight, + type: BOOL, + loc, + }; + } + // 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), + right: lowerer.coerceToExpected(idRight, DYN), + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } + return lowerer.unsupported("SC1043", node); +} + function lowerLogicalChain(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr { // Logical chains have a left-nested AST. Keep the native stack bounded // while retaining each pair's checker type and lazy operand evaluation. @@ -11913,6 +12065,10 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); return { kind: "libCall", fn: "dyn.nativeUrlIs", args: [value], type: BOOL, loc }; } + { + const stack = lowerDisposableStackInstanceOf(lowerer, expr); + if (stack) return stack; + } if (lowerer.isStdlibGlobal(expr.right, "DataView")) { const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); return { kind: "libCall", fn: "dyn.dataViewIs", args: [value], type: BOOL, loc }; @@ -11932,6 +12088,8 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc if (blob) return blob; const keyObject = lowerKeyObjectInstanceof(lowerer, expr); if (keyObject) return keyObject; + const netAddress = lowerNetAddressInstanceof(lowerer, expr); + if (netAddress) return netAddress; } if (lowerer.isStdlibGlobal(expr.right, "SharedArrayBuffer")) { const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN); @@ -13152,10 +13310,22 @@ function lowerRuntimeKeyIn( return { kind: "call", callee: helper, args: [keyIr, recv], type: BOOL, loc }; } +/** RegExp.prototype's flag getters and the flag each one reads. */ +const REGEX_FLAG_GETTERS: ReadonlyMap = new Map([ + ["hasIndices", "d"], + ["global", "g"], + ["ignoreCase", "i"], + ["multiline", "m"], + ["dotAll", "s"], + ["unicode", "u"], + ["unicodeSets", "v"], + ["sticky", "y"], +]); + /** A regex literal `/ab+c/gi` → regexLit (fresh state per evaluation, * shared bytecode per (pattern, flags) pair). The TS parser has already syntax-checked the literal; - * what remains here is the flag-alphabet fence (d and v are - * declared-valid TS flags outside this slice). Named capture groups + * what remains here is the flag-alphabet fence (d is a + * declared-valid TS flag outside this slice). Named capture groups * `(?...)` and `\k` backreferences compile — libregexp * executes them natively, replace templates resolve `$` at * runtime, and `.groups` reads desugar at their access sites @@ -13166,20 +13336,19 @@ export function lowerRegexLiteral(lowerer: Lowerer, expr: ts.RegularExpressionLi const text = expr.text; const lastSlash = text.lastIndexOf("/"); const pattern = text.slice(1, lastSlash); - const flags = text.slice(lastSlash + 1); - for (const f of flags) { - if (!"gimsuy".includes(f)) { + const written = text.slice(lastSlash + 1); + for (const f of written) { + if (!"gimsuvy".includes(f)) { lowerer.unsupported( "SC1120", expr, - f === "d" - ? "the regex 'd' flag (match indices)" - : f === "v" - ? "the regex 'v' flag (unicode sets)" - : `the regex '${f}' flag`, + f === "d" ? "the regex 'd' flag (match indices)" : `the regex '${f}' flag`, ); } } + // `flags`, `toString()` and inspection list the flags in getter order, + // whatever order the literal spells them in. + const flags = [..."gimsuvy"].filter((f) => written.includes(f)).join(""); return { kind: "regexLit", pattern, flags, type: REGEX, loc: locOf(expr) }; } @@ -13386,6 +13555,15 @@ function matchProducerRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression if (ts.isElementAccessExpression(expr)) { return matchAllRegexOf(lowerer, expr.expression); } + // A row picked by `rows.find(cb)`, `rows.findLast(cb)` or `rows.at(i)`. + if ( + ts.isCallExpression(expr) && + ts.isPropertyAccessExpression(expr.expression) && + MATCH_ROW_PICK_METHODS.has(expr.expression.name.text) && + lowerer.isStdlibMember(expr.expression) + ) { + return matchAllRegexOf(lowerer, expr.expression.expression); + } if (ts.isIdentifier(expr)) { // The for-of binding over a matchAll drain: `for (const m of // s.matchAll(re))` (or over a stored const rows). @@ -13402,10 +13580,77 @@ function matchProducerRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression // A stored const match result: `const m = re.exec(s)`. const init = constInitializerOf(lowerer, expr); if (init !== null) return matchProducerRegexOf(lowerer, init); + // The element parameter of an array callback over matchAll rows: + // `[...s.matchAll(re)].map((m) => m.groups)`, and Array.from's + // mapping function over `s.matchAll(re)`. + if (decl !== undefined && ts.isParameter(decl)) return matchAllCallbackRegexOf(lowerer, decl); } return null; } +/** Array methods that return one row of the receiver. */ +const MATCH_ROW_PICK_METHODS = new Set(["find", "findLast", "at"]); + +/** Array methods whose result holds rows of the receiver. */ +const MATCH_ROW_SUBSET_METHODS = new Set(["filter", "slice", "toReversed", "toSorted"]); + +const MATCH_ROW_CALLBACK_METHODS = new Set([ + "map", + "forEach", + "filter", + "find", + "findLast", + "some", + "every", + "flatMap", +]); + +/** The regex behind `param` when it is the first parameter of an inline + * callback whose rows come from a matchAll drain. */ +function matchAllCallbackRegexOf( + lowerer: Lowerer, + param: ts.ParameterDeclaration, +): ts.Expression | null { + const fn = param.parent; + if (!(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) || fn.parameters[0] !== param) + return null; + // A reassigned parameter can hold a row of another regex. + if (ts.isIdentifier(param.name)) { + const sym = lowerer.checker.getSymbolAtLocation(param.name); + if (sym === undefined || !bindingNeverReassigned(lowerer, sym, param)) return null; + } + let site: ts.Node = fn; + while (ts.isParenthesizedExpression(site.parent)) site = site.parent; + const call = site.parent; + if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return null; + const method = call.expression.name.text; + if ( + MATCH_ROW_CALLBACK_METHODS.has(method) && + call.arguments[0] === site && + lowerer.isStdlibMember(call.expression) + ) + return matchAllRegexOf(lowerer, call.expression.expression); + if ( + method === "from" && + call.arguments[1] === site && + lowerer.isStdlibGlobal(call.expression.expression, "Array") && + call.arguments[0] !== undefined + ) + return matchAllRegexOf(lowerer, call.arguments[0]); + return null; +} + +/** The named groups of the rows `param` receives when it is the first + * parameter of an inline matchAll callback (matchAllCallbackRegexOf). */ +export function matchCallbackParamGroupsOf( + lowerer: Lowerer, + param: ts.ParameterDeclaration, +): { name: string; index: number }[] | null { + const reExpr = matchAllCallbackRegexOf(lowerer, param); + const pattern = reExpr === null ? null : staticRegexPatternOf(lowerer, reExpr); + return pattern === null ? null : namedCaptureGroupsOfPattern(pattern); +} + /** The regex argument of a (possibly const-stored) `s.matchAll(re)`. */ function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | null { let expr = e; @@ -13431,6 +13676,14 @@ function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | nu const only = expr.elements[0]!; if (ts.isSpreadElement(only)) return matchAllRegexOf(lowerer, only.expression); } + if ( + ts.isCallExpression(expr) && + ts.isPropertyAccessExpression(expr.expression) && + MATCH_ROW_SUBSET_METHODS.has(expr.expression.name.text) && + lowerer.isStdlibMember(expr.expression) + ) { + return matchAllRegexOf(lowerer, expr.expression.expression); + } if (ts.isIdentifier(expr)) { const init = constInitializerOf(lowerer, expr); if (init !== null) return matchAllRegexOf(lowerer, init); @@ -13454,11 +13707,11 @@ function matchAllRegexOf(lowerer: Lowerer, e: ts.Expression): ts.Expression | nu * - ES2025 duplicate names (distinct alternatives) project the first * participating occurrence, including a participating empty string; * - key order is group declaration order (declaredOrder), Node's own. - * The record has no prototype — Node's groups object is null-prototype, - * observable only through util.inspect's "[Object: null prototype]" - * prefix (the Object.groupBy stance, ledgered). Null when the receiver - * isn't a match slice or the regex isn't traceable — the caller's - * member fence (with the groups hint) names the gap. */ + * The record has a null prototype, like Node's groups object. A + * JavaScript callback parameter holds the row as a dynamic array; the + * projection reads its elements through dynamic reads. Null when the + * receiver isn't a match slice or the regex isn't traceable — the + * caller's member fence (with the groups hint) names the gap. */ function lowerMatchGroupsRead(lowerer: Lowerer, expr: ts.PropertyAccessExpression): IrExpr | null { if (expr.name.text !== "groups" || expr.questionDotToken !== undefined) return null; const loc = locOf(expr); @@ -13484,7 +13737,9 @@ function lowerMatchGroupsRead(lowerer: Lowerer, expr: ts.PropertyAccessExpressio return null; } const recv = lowerer.lowerExpr(expr.expression); - if (!isMatchSliceType(lowerer, recv.type)) return null; + // A JavaScript callback parameter holds the row as a dynamic array. + const dynRow = recv.type.kind === "dyn" && isJsSourceFile(expr.getSourceFile()); + if (!dynRow && !isMatchSliceType(lowerer, recv.type)) return null; return lowerGroupsProjection(lowerer, recv, groups, loc); } @@ -13530,6 +13785,21 @@ function matchSliceUnionArms( return { arrTag, units }; } +/** Node's TypeError for `.groups` read off null or undefined. */ +function groupsReadTypeError(unit: "null" | "undefined", loc: SrcLoc): IrStmt { + return { + kind: "throw", + value: { + kind: "libCall", + fn: "error.new", + args: [strLit(`Cannot read properties of ${unit} (reading 'groups')`, loc)], + type: { kind: "object", className: "%TypeError" }, + loc, + }, + loc, + }; +} + /** The groups-record projection holds string | undefined values, including * an own overflow entry for every nonparticipating capture. One lifted helper per * (index list, receiver representation) builds it; see @@ -13556,7 +13826,8 @@ export function lowerGroupsProjection( const valueType = arrayValueType(lowerer, STRING); const shapeId = lowerer.shapes.intern([], false, valueType); const recordT: IrType = { kind: "record", shapeId }; - const recvKey = recv.type.kind === "union" ? recv.type.unionId : "arr"; + const recvKey = + recv.type.kind === "union" ? recv.type.unionId : recv.type.kind === "dyn" ? "dyn" : "arr"; const key = `regexgroups:${shapeId}:${groups.map((g) => `${g.name}=${g.index}`).join(",")}:${recvKey}`; let helper = lowerer.valueHelpers.get(key); if (!helper) { @@ -13580,26 +13851,7 @@ export function lowerGroupsProjection( type: BOOL, loc, }, - then: [ - { - kind: "throw", - value: { - kind: "libCall", - fn: "error.new", - args: [ - { - kind: "strLit", - value: `Cannot read properties of ${u.unit} (reading 'groups')`, - type: STRING, - loc, - }, - ], - type: { kind: "object", className: "%TypeError" }, - loc, - }, - loc, - }, - ], + then: [groupsReadTypeError(u.unit, loc)], else_: null, loc, }); @@ -13615,10 +13867,33 @@ export function lowerGroupsProjection( locals.push({ id: "arr.0", name: "arr", type: arrayOf(STRING), mutable: false }); body.push({ kind: "varDecl", localId: "arr.0", init: narrowed, loc }); mRef = { kind: "varRef", localId: "arr.0", type: arrayOf(STRING), loc }; + } else if (recv.type.kind === "dyn") { + // A JavaScript callback parameter holds the row as a dynamic array: + // Node's TypeError for null and undefined, then dynamic element reads. + for (const unit of ["null", "undefined"] as const) { + body.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: mRef, type: BOOL, loc }, + then: [groupsReadTypeError(unit, loc)], + else_: null, + loc, + }); + } } const elem = (index: number): IrExpr => - arrayValueRead(lowerer, mRef, numLit(index, loc), STRING, loc); + mRef.type.kind === "dyn" + ? lowerer.coerceToExpected( + { + kind: "dynKeyGet", + value: mRef, + key: strLit(String(index), loc), + type: DYN, + loc, + }, + valueType, + ) + : arrayValueRead(lowerer, mRef, numLit(index, loc), STRING, loc); const values = new Map(); order.forEach((name, i) => { const idxs = indicesByName.get(name)!; @@ -13656,6 +13931,7 @@ export function lowerGroupsProjection( value: { kind: "recordLit", fields: order.map((name) => ({ name, value: values.get(name)!, overflow: true as const })), + nullProto: true, type: recordT, loc, }, @@ -13885,6 +14161,28 @@ export function lowerUnionProperty( } return null; } + // A class instance the checker sees through a wider union (`(concrete + // as A | B).value`): the value is that class's instance, so read its + // field directly. + if (value.type.kind === "object") { + const className = value.type.className; + const info = lowerer.classes.get(className) ?? null; + const field = expr.name.text; + const fieldType = info?.fields.get(field); + if (fieldType !== undefined) + return lowerer.fieldGetExpr( + { container: "class", obj: value, className, field, fieldType }, + locOf(expr), + expr, + ); + const getter = lowerer.findMethodOn(info, `get:${field}`); + if (getter) + return lowerer.fieldGetExpr( + { container: "accessor", obj: value, className, field, fieldType: getter.sig.ret }, + locOf(expr), + expr, + ); + } if (value.type.kind !== "union") { lowerer.unsupported( "SC1090", @@ -15551,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/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 7afa33662..2991c0d39 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -168,6 +168,27 @@ export function lowerLambda( | ts.SetAccessorDeclaration, checkedReceiver = false, ): IrExpr { + // A generic lambda used as a value has no per-call instantiation: it + // lowers once with its type parameters bound to `unknown`, the erased + // instance JavaScript itself runs. + if (node.typeParameters?.length && !lowerer.erasingLambdas.has(node)) { + const previous = lowerer.typeParamBindings; + const bindings = new Map(previous ?? []); + for (const parameter of node.typeParameters) { + const symbol = lowerer.checker.getSymbolAtLocation(parameter.name); + if (symbol) bindings.set(symbol, DYN); + } + lowerer.typeParamBindings = bindings; + lowerer.erasingLambdas.add(node); + lowerer.erasingDepth++; + try { + return lowerLambda(lowerer, node, checkedReceiver); + } finally { + lowerer.erasingDepth--; + lowerer.erasingLambdas.delete(node); + lowerer.typeParamBindings = previous; + } + } const prepared = ts.isArrowFunction(node) || ts.isFunctionExpression(node) ? lowerer.preparedClassFactories.get(node) diff --git a/packages/compiler/src/frontend/lowering/lower-generators.ts b/packages/compiler/src/frontend/lowering/lower-generators.ts index c61920c60..2eda4c4ce 100644 --- a/packages/compiler/src/frontend/lowering/lower-generators.ts +++ b/packages/compiler/src/frontend/lowering/lower-generators.ts @@ -32,7 +32,7 @@ export type GenType = IrType & { kind: "generator" }; /** The interned IteratorResult record of a generator type (never null for * a MAPPED generator — mapType required it to intern). */ -function resultRecordOf(lowerer: Lowerer, genT: GenType): IrType & { kind: "record" } { +export function resultRecordOf(lowerer: Lowerer, genT: GenType): IrType & { kind: "record" } { const rec = genResultRecord(genT.yieldT, genT.retT, lowerer.shapes, lowerer.unions); if (!rec) throw new InternalCompilerError("lowerer bug: mapped generator without a result record"); diff --git a/packages/compiler/src/frontend/lowering/lower-inspect.ts b/packages/compiler/src/frontend/lowering/lower-inspect.ts index 7c08a34f9..1d67cef92 100644 --- a/packages/compiler/src/frontend/lowering/lower-inspect.ts +++ b/packages/compiler/src/frontend/lowering/lower-inspect.ts @@ -135,12 +135,17 @@ function inspectSupport( case "regex": case "symbol": case "bigint": + case "date": case "dyn": return null; case "array": return inspectSupport(lowerer, t.elem, visiting, out); case "bytes": - return "typed arrays inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)"; + // A Uint8Array and a Buffer share the u8 representation and print + // differently; the other element kinds are never Buffers. + return t.elem === "u8" + ? "Uint8Array and Buffer values inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)" + : null; case "record": { if (visiting.has(t.shapeId)) { out.recursive = true; @@ -321,7 +326,20 @@ export function inspectExpr( return { kind: "libCall", fn: "sym.toString", args: [value], type: STRING, loc }; case "bigint": return { kind: "libCall", fn: "bigint.inspect", args: [value], type: STRING, loc }; + case "date": + // Node prints a Date's toISOString() at every depth, and its + // toString() ("Invalid Date") when the time value is NaN. + return { kind: "libCall", fn: "insp.date", args: [value], type: STRING, loc }; case "bytes": + if (t.elem !== "u8") { + return { + kind: "libCall", + fn: "insp.typedArray", + args: [value, recurse, depth], + type: STRING, + loc, + }; + } return { kind: "libCall", fn: "insp.buffer", args: [value], type: STRING, loc }; case "dyn": return { kind: "libCall", fn: "insp.dyn", args: [value, recurse, depth], type: STRING, loc }; @@ -1436,23 +1454,27 @@ export function lowerConsoleInspectArg( } if (t.kind === "bytes") { // One runtime representation serves Buffer AND Uint8Array; their - // renderings differ, so the argument's checker type picks — and only - // Buffer lowers (lowerInspectCall's stance). + // renderings differ, so the argument's checker type picks + // (lowerInspectCall's stance). const tname = lowerer.checker.typeToString( lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(node)), ); - const isBuffer = - tname === "Buffer" || - tname === "NonSharedBuffer" || - tname.startsWith("Buffer<") || - tname.startsWith("NonSharedBuffer<"); - if (t.elem !== "u8" || !isBuffer) { + if (isDataViewTypeName(tname)) { lowerer.unsupported( "SC1090", node, - `${surface} of '${tname}' values (Buffer's form is the lowered typed-array rendering; other typed arrays fence)`, + `${surface} of '${tname}' values (Buffer and the numeric typed arrays are the lowered byte renderings; DataView has none yet)`, ); } + if (!isBufferTypeName(tname)) { + return { + kind: "libCall", + fn: "insp.typedArray", + args: [value, numLit(0, loc), numLit(2, loc)], + type: STRING, + loc, + }; + } return inspectExpr(lowerer, t, value, numLit(0, loc), numLit(2, loc), loc); } if (t.kind === "void") { @@ -1464,6 +1486,10 @@ export function lowerConsoleInspectArg( `${surface} of a void call result (call it as its own statement, then ${surface}(undefined))`, ); } + // A read that always throws (an ambient name, a binding whose + // initializer threw) never produces a value to render. + if (value.kind === "libCall" && value.fn === "global.undefRead") + return formatValueExpr(lowerer, DYN, { ...value, type: DYN }, 2, loc); const walk = { recursive: false }; const reason = inspectSupport(lowerer, t, new Set(), walk); if (reason !== null) { @@ -1472,18 +1498,41 @@ export function lowerConsoleInspectArg( return formatValueExpr(lowerer, t, value, 2, loc); } +/** The checker's spelling of a Buffer, whose bytes print as . */ +function isBufferTypeName(tname: string): boolean { + return ( + tname === "Buffer" || + tname === "NonSharedBuffer" || + tname.startsWith("Buffer<") || + tname.startsWith("NonSharedBuffer<") + ); +} + +function isDataViewTypeName(tname: string): boolean { + return tname === "DataView" || tname.startsWith("DataView<"); +} + /* ── the callsite: options, direct function/class names ──────────────── */ /** The parsed options literal: the resolved depth (numeric; Infinity for - * `null`), with every non-default knob fenced by name. */ + * `null`), breakLength (numeric; default 80) and compact (numeric, 0 for + * false; default 3), with every other non-default knob fenced by name. */ +interface InspectOptions { + depth: number; + breakLength: number; + compact: number; + sorted: boolean; +} + function parseInspectOptions( lowerer: Lowerer, node: ts.Expression | undefined, recursive: boolean, dir = false, -): number { +): InspectOptions { const surface = dir ? "console.dir" : "util.inspect"; - if (!node || (ts.isIdentifier(node) && node.text === "undefined")) return 2; + const options: InspectOptions = { depth: 2, breakLength: 80, compact: 3, sorted: false }; + if (!node || (ts.isIdentifier(node) && node.text === "undefined")) return options; if (!ts.isObjectLiteralExpression(node)) { lowerer.noLowering( `${surface} with a non-literal options argument`, @@ -1491,7 +1540,6 @@ function parseInspectOptions( "the options must be an object literal ({ depth, colors: false, ... }) so the knobs resolve at compile time", ); } - let depth = 2; for (const prop of node.properties) { if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) { lowerer.noLowering( @@ -1522,7 +1570,7 @@ function parseInspectOptions( // machinery ([Circular *N]) cuts every cycle, so the walk is // bounded by the (finite) object graph — exactly Node. void recursive; - depth = Infinity; + options.depth = Infinity; } else if (!(ts.isIdentifier(value) && value.text === "undefined")) { const n = numeric(); if (n === null) { @@ -1532,7 +1580,7 @@ function parseInspectOptions( "depth must be a numeric literal (or null)", ); } - depth = n; + options.depth = n; } } else if (key === "colors") { if (value.kind !== ts.SyntaxKind.FalseKeyword) { @@ -1543,21 +1591,37 @@ function parseInspectOptions( ); } } else if (key === "compact") { - if (numeric() !== 3) { + // Node tries the single-line form and groups arrays only for a + // compact of at least 1; false and smaller numbers break every + // composite onto its own lines. compact: true is the older layout. + const n = value.kind === ts.SyntaxKind.FalseKeyword ? 0 : numeric(); + if (n === null || Number.isNaN(n)) { lowerer.noLowering( - `${surface} with a non-default compact option`, + `${surface} with this compact option`, value, - "only the default compact: 3 is lowered", + "compact must be false or a numeric literal (compact: true has no lowering)", ); } + options.compact = Math.max(n, 0); + } else if (key === "sorted") { + if (value.kind !== ts.SyntaxKind.TrueKeyword && value.kind !== ts.SyntaxKind.FalseKeyword) { + lowerer.noLowering( + `${surface} with this sorted option`, + value, + "sorted must be true or false (a comparator function has no lowering)", + ); + } + options.sorted = value.kind === ts.SyntaxKind.TrueKeyword; } else if (key === "breakLength") { - if (numeric() !== 80) { + const n = ts.isIdentifier(value) && value.text === "Infinity" ? Infinity : numeric(); + if (n === null || Number.isNaN(n)) { lowerer.noLowering( - `${surface} with a non-default breakLength option`, + `${surface} with a non-literal breakLength option`, value, - "only the default breakLength: 80 is lowered", + "breakLength must be a numeric literal or Infinity", ); } + options.breakLength = n; } else if (dir && key === "customInspect") { // console.dir's own default; only that value is lowered. if (value.kind !== ts.SyntaxKind.FalseKeyword) { @@ -1571,11 +1635,49 @@ function parseInspectOptions( lowerer.noLowering( `${surface} with the '${key}' option`, prop, - `the lowered options are depth (numeric literal or null), colors: false, compact: 3, ${dir ? "breakLength: 80, and customInspect: false" : "and breakLength: 80"}`, + `the lowered options are depth (numeric literal or null), colors: false, compact (false or a number), breakLength, ${dir ? "sorted (true or false), and customInspect: false" : "and sorted (true or false)"}`, ); } } - return depth; + return options; +} + +/** A render under non-default breakLength/compact/sorted: the value is evaluated + * first, the layout applies to this render only, and the defaults return + * afterwards. */ +function withInspectLayout( + lowerer: Lowerer, + options: InspectOptions, + value: IrExpr, + render: (value: IrExpr) => IrExpr, + loc: SrcLoc, +): IrExpr { + if (options.breakLength === 80 && options.compact === 3 && !options.sorted) return render(value); + const input = lowerer.declareHiddenLocal("%inspectInput", value.type); + const text = lowerer.declareHiddenLocal("%inspectText", STRING); + const layout = (breakLength: number, compact: number, sorted: boolean): IrStmt => ({ + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "insp.layout", + args: [numLit(breakLength, loc), numLit(compact, loc), boolLit(sorted, loc)], + type: VOID, + loc, + }, + loc, + }); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: input.id, init: value, loc }, + layout(options.breakLength, options.compact, options.sorted), + { kind: "varDecl", localId: text.id, init: render(varRef(input.id, value.type, loc)), loc }, + layout(80, 3, false), + ], + result: varRef(text.id, STRING, loc), + type: STRING, + loc, + }; } /** `util.inspect(fn)` / `util.inspect(Cls)` over a DIRECT identifier @@ -1664,33 +1766,45 @@ export function lowerInspectValue( if (value.type.kind === "bytes") { // One runtime representation serves Buffer AND Uint8Array; their // renderings differ ( vs Uint8Array(1) [ 170 ]), so the - // TOP-LEVEL argument's checker type picks — and only Buffer lowers. - // Nested bytes fence in inspectSupport (no checker type survives - // into shapes). + // TOP-LEVEL argument's checker type picks. Nested u8 bytes fence in + // inspectSupport (no checker type survives into shapes). const tname = lowerer.checker.typeToString( lowerer.checker.getBaseTypeOfLiteralType(lowerer.typeOf(valueNode)), ); - const isBuffer = - tname === "Buffer" || - tname === "NonSharedBuffer" || - tname.startsWith("Buffer<") || - tname.startsWith("NonSharedBuffer<"); - if (dir) { + const isBuffer = isBufferTypeName(tname); + if (dir && isBuffer) { lowerer.noLowering( `console.dir of '${tname}' values`, valueNode, "console.dir disables custom inspection, and the typed-array rendering it falls back to has no lowering yet — use console.log", ); } - if (value.type.elem !== "u8" || !isBuffer) { + if (isDataViewTypeName(tname)) { lowerer.noLowering( `util.inspect of '${tname}' values`, valueNode, - "Buffer's form is the lowered typed-array rendering; other typed arrays fence", + "Buffer and the numeric typed arrays are the lowered byte renderings; DataView has none yet", ); } - const bufDepth = parseInspectOptions(lowerer, optionsNode, false, dir); - void bufDepth; // Buffers render fully at any depth (custom inspect) + const options = parseInspectOptions(lowerer, optionsNode, false, dir); + if (!isBuffer) { + // A numeric typed array: Uint8Array(n) [ ... ] under the depth and + // layout options. + return withInspectLayout( + lowerer, + options, + value, + (input) => ({ + kind: "libCall", + fn: "insp.typedArray", + args: [input, numLit(0, loc), numLit(options.depth, loc)], + type: STRING, + loc, + }), + loc, + ); + } + // Buffers render fully at any depth and on one line (custom inspect). return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(2, loc), loc); } const walk = { recursive: false }; @@ -1698,17 +1812,30 @@ export function lowerInspectValue( if (reason !== null) { lowerer.noLowering(`${surface} of '${lowerer.fmt(value.type)}' values`, valueNode, reason); } - const depth = parseInspectOptions(lowerer, optionsNode, walk.recursive, dir); + const options = parseInspectOptions(lowerer, optionsNode, walk.recursive, dir); + const depth = options.depth; if (dir && value.type.kind === "dyn") { - return { - kind: "libCall", - fn: "console.dirDyn", - args: [value, numLit(depth, loc)], - type: STRING, + return withInspectLayout( + lowerer, + options, + value, + (input) => ({ + kind: "libCall", + fn: "console.dirDyn", + args: [input, numLit(depth, loc)], + type: STRING, + loc, + }), loc, - }; + ); } - return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(depth, loc), loc); + return withInspectLayout( + lowerer, + options, + value, + (input) => inspectExpr(lowerer, value.type, input, numLit(0, loc), numLit(depth, loc), loc), + loc, + ); } /* ── util.format / util.formatWithOptions ────────────────────────────── */ @@ -1811,6 +1938,34 @@ function formatOArg(lowerer: Lowerer, node: ts.Expression, depth: number, loc: S return inspectExpr(lowerer, value.type, value, numLit(0, loc), numLit(depth, loc), loc); } +/** %d and %i over the argument types Node formats without a number + * conversion: a bigint prints as `${n}n` (formatBigInt) and a symbol as NaN; + * null is Number(null) (0) under %d and parseInt("null") (NaN) under %i, and + * undefined (including a void call) is NaN under both. Other arguments + * still evaluate. Null for other types. */ +function formatIntegerFixed(value: IrExpr, spec: "%d" | "%i", loc: SrcLoc): IrExpr | null { + if (value.type.kind === "bigint") { + return { kind: "libCall", fn: "bigint.inspect", args: [value], type: STRING, loc }; + } + let text: string; + if ( + value.type.kind === "symbol" || + value.type.kind === "undefinedT" || + value.type.kind === "void" + ) + text = "NaN"; + else if (value.type.kind === "nullT") text = spec === "%d" ? "0" : "NaN"; + else return null; + if (value.kind === "unitLit") return strLit(text, loc); + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: value, loc }], + result: strLit(text, loc), + type: STRING, + loc, + }; +} + /** util.format(...) / util.formatWithOptions({}, ...): the compile-time * %-substitution over a literal format string (formatWithOptionsInternal * ported exactly — the arg cursor, the args-exhausted guard, %c's @@ -1880,6 +2035,8 @@ export function lowerFormatCall( // strings through the runtime's ECMA-exact StringToNumber // (num.fromString — the same lowering Number(aString) takes). const value = lowerer.lowerExpr(node); + const fixed = formatIntegerFixed(value, "%d", loc); + if (fixed) return fixed; if (value.type.kind === "f64") return { kind: "libCall", fn: "insp.f64", args: [value], type: STRING, loc }; if (value.type.kind === "string") { @@ -1935,6 +2092,8 @@ export function lowerFormatCall( case 105: { // %i — parseInt(ToString(arg)): the spec-exact composition. const value = lowerer.lowerExpr(node); + const fixed = formatIntegerFixed(value, "%i", loc); + if (fixed) return fixed; if ( value.type.kind === "f64" || value.type.kind === "bool" || @@ -1975,10 +2134,10 @@ export function lowerFormatCall( return strLit("undefined", loc); } // A checked-dynamic argument (`console.error('headers: %j', - // headers)` — the suite's http logging idiom): the runtime dyn - // walk stringifies JS-exactly (root undefined/function prints - // "undefined"; a handle in the tree throws the "not supported yet" - // Error). + // headers)` — the suite's http logging idiom): the runtime runs + // JSON.stringify's walk (toJSON hooks included; a root it drops + // prints "undefined") and prints "[Circular]" for a circular + // structure. A typed argument prints "[Circular]" the same way. if (value.type.kind === "dyn") { return { kind: "libCall", fn: "insp.jsonDyn", args: [value], type: STRING, loc }; } @@ -1989,7 +2148,7 @@ export function lowerFormatCall( "only JSON-safe static types lower", ); } - return { kind: "jsonStringify", value, type: STRING, loc }; + return { kind: "jsonStringify", value, circular: true, type: STRING, loc }; } case 102: { // %f — parseFloat(String(arg)) formatted: a number keeps its value 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-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 9300d19d4..d18c4f547 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -129,6 +129,7 @@ import { forkTargetPaths } from "../fork-target.js"; import { workerTargetPaths } from "../worker-target.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { handleEventReaderPrelude } from "./lower-event-target.js"; +import { jsHoldsDynObject } from "./lower-exprs.js"; /** One file's declarations, split for collection and init-body lowering. */ export interface FileParts { @@ -1993,6 +1994,36 @@ export function collectGlobals( } continue; } + // A JS file-scope ARRAY LITERAL holding an existing checked-dynamic + // object (a file-scope object literal binding above, directly or + // in a nested literal): the literal builds as a checked-dynamic + // array (lowerArrayLiteral), and the binding holds it, so the + // element keeps its identity. + if ( + isJsSourceFile(sf) && + ts.isIdentifier(decl.name) && + nameNode === decl.name && + !decl.type && + !hasJsTypeAnnotation(decl) && + decl.initializer !== undefined && + ts.isArrayLiteralExpression(decl.initializer) && + jsHoldsDynObject(lowerer, decl.initializer) + ) { + const symbol = lowerer.checker.getSymbolAtLocation(nameNode); + if (symbol && !lowerer.globalsBySymbol.has(symbol)) { + const g: IrGlobal = { + id: `%g.${tag}${nsPrefix}${nameNode.text}`, + name: nameNode.text, + type: DYN, + mutable: isLet, + source: bindingSource(nameNode), + }; + lowerer.globalsBySymbol.set(symbol, g); + lowerer.globalsList.push(g); + if (isVarDeclared(decl)) noteVarGlobalEntryInit(lowerer, sf, g); + } + continue; + } // A JS file-scope ANY-RESIDUE binding whose VALUE provably // lives in the checked-dynamic tree: no initializer (`let catchWarning;` — // test/common's warning ledger), a unit initializer (`let diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index 8c8639d43..54d9adeb5 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -2111,6 +2111,67 @@ function lowerNetServerMethodCall( ); } +/** Whether a setEncoding call can reach some net.Socket in this program: + * `x.setEncoding(...)` or `x["setEncoding"](...)` where `x` is a socket + * or an untyped value. Sockets that never get an encoding keep the Buffer + * chunk path; with one, Node's 'data' chunks are strings. */ +function socketEncodingReachable(lowerer: Lowerer): boolean { + if (lowerer.socketEncodingReachable !== null) return lowerer.socketEncodingReachable; + let reachable = false; + for (const file of lowerer.program.getSourceFiles()) { + if (file.isDeclarationFile || !file.text.includes("setEncoding")) continue; + ts.walkPreorder(file, (node) => { + if (reachable) return "skip"; + if (!ts.isCallExpression(node)) return undefined; + const callee = node.expression; + const receiver = + ts.isPropertyAccessExpression(callee) && callee.name.text === "setEncoding" + ? callee.expression + : ts.isElementAccessExpression(callee) && + ts.isStringLiteralLike(callee.argumentExpression) && + callee.argumentExpression.text === "setEncoding" + ? callee.expression + : null; + if (receiver === null) return undefined; + const kind = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(receiver))?.kind; + if (kind === undefined || kind === "netSocket" || kind === "dyn") reachable = true; + return undefined; + }); + if (reachable) break; + } + lowerer.socketEncodingReachable = reachable; + return reachable; +} + +/** An inline 'data' listener whose chunk parameter is typed Buffer, for a + * socket that may have an encoding set: the chunk binds as a checked + * dynamic value, so the body sees what Node passes (a string after + * setEncoding, a Buffer otherwise) whatever the annotation says. Null + * for other listener shapes. */ +function lowerEncodedDataListener(lowerer: Lowerer, node: ts.Expression): IrExpr | null { + let fn = node; + while (ts.isParenthesizedExpression(fn)) fn = fn.expression; + if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return null; + if (fn.parameters.length !== 1) return null; + const param = fn.parameters[0]!; + if (!ts.isIdentifier(param.name) || param.initializer || param.dotDotDotToken) return null; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (symbol === undefined) return null; + const previousBinding = lowerer.runtimeOptionalBindingTypes.get(symbol); + const previousImplicit = lowerer.implicitParamTypes; + const implicit = new Map(previousImplicit ?? []); + implicit.set(symbol, lowerer.checker.getUnknownType()); + lowerer.runtimeOptionalBindingTypes.set(symbol, DYN); + lowerer.implicitParamTypes = implicit; + try { + return lowerer.lowerExpr(fn); + } finally { + if (previousBinding === undefined) lowerer.runtimeOptionalBindingTypes.delete(symbol); + else lowerer.runtimeOptionalBindingTypes.set(symbol, previousBinding); + lowerer.implicitParamTypes = previousImplicit; + } +} + /** Method calls on net.Socket receivers: write/end/destroy/pipe and * on/once("data" | "end" | "close" | "error" | "connect"). Null for * other receivers. */ @@ -2565,6 +2626,27 @@ function lowerNetSocketMethodCall( loc, }; } + if (param !== null && param.kind === "bytes" && socketEncodingReachable(lowerer)) { + const encoded = lowerEncodedDataListener(lowerer, args[1]!); + // A listener defined elsewhere keeps its Buffer parameter; it takes + // the chunk through the checked boundary, so a string chunk fails + // the check instead of arriving as a Buffer. + const cb: IrExpr = + encoded ?? + ({ + kind: "dynCheck", + value: lowerSocketDynListener(lowerer, args[1]!, "data listeners"), + type: funcOf([DYN], VOID), + loc, + } as IrExpr); + return { + kind: "libCall", + fn: "net.sockOnData", + args: [receiver, cb, once], + type: VOID, + loc, + }; + } const { cb } = lowerCallbackArg( lowerer, args[1]!, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 289ab8fce..ad4fd5312 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -164,7 +164,11 @@ import { } from "./lower-exprs.js"; import { globalSymbolKey } from "./expressions/global-symbols.js"; import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js"; -import { lowerEnvironmentKey, lowerNativeFunctionAssignment } from "./lower-exprs.js"; +import { + lowerEnvironmentKey, + lowerNativeFunctionAssignment, + lowerStrictEquality, +} from "./lower-exprs.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; import { tryLowerExpression } from "./expressions/try-lower-expression.js"; import { lowerUnionFieldWrite } from "./expressions/union-fields.js"; @@ -1910,44 +1914,14 @@ export function lowerDestructuringDecl( init = { kind: "unionNarrow", unionId: init.type.unionId, tag, value: uref, type: arm, loc }; } } - // `const { groups } = re.exec(s)!` / `const { groups: { year } } = m` - // — every pattern element binds `groups` off a MATCH SLICE whose - // regex is statically known: the slice has no record shape of its - // own, so the groups projection (lowerGroupsProjection — the same - // record `m.groups` reads produce) wraps one level in a synthesized - // `{ groups: ... }` record and the ordinary record destructure below - // serves the pattern — aliases and nested patterns included. Only - // named-group regexes take the wrap: a group-less regex destructures - // `groups: undefined` in Node (a nested pattern then throws), which - // the fence reports honestly instead. - if ( - ts.isObjectBindingPattern(decl.name) && - decl.name.elements.length > 0 && - isMatchSliceType(lowerer, init.type) && - decl.name.elements.every((el) => { - if (el.dotDotDotToken !== undefined || el.name === undefined) return false; - const prop = el.propertyName; - if (prop === undefined) return ts.isIdentifier(el.name) && el.name.text === "groups"; - return (ts.isIdentifier(prop) || ts.isStringLiteral(prop)) && prop.text === "groups"; - }) - ) { - const groups = matchResultNamedGroupsOf(lowerer, decl.initializer!); - if (groups !== null && groups.length > 0) { - const proj = lowerGroupsProjection(lowerer, init, groups, loc); - const outerShape = lowerer.shapes.intern( - [{ name: "groups", type: proj.type }], - false, - undefined, - ["groups"], - ); - init = { - kind: "recordLit", - fields: [{ name: "groups", value: proj }], - type: { kind: "record", shapeId: outerShape }, - loc, - }; - } - } + const groupsSource = matchGroupsPatternSource( + lowerer, + decl.name, + init, + () => matchResultNamedGroupsOf(lowerer, decl.initializer!), + loc, + ); + if (groupsSource !== null) init = groupsSource; const tmp = lowerer.declareHiddenLocal("%destr", init.type); out.push({ kind: "varDecl", localId: tmp.id, init, loc }); // V8's destructuring TypeError spells STATIC sources: an identifier @@ -1974,6 +1948,55 @@ export function lowerDestructuringDecl( return out; } +/** `const { groups } = re.exec(s)!` / `const { groups: { year } } = m` + * — every pattern element binds `groups` off a match row whose regex is + * statically known. The slice (or a JavaScript callback's dynamic row) + * has no record shape of its own, so the groups projection + * (lowerGroupsProjection — the same record `m.groups` reads produce) + * wraps one level in a synthesized `{ groups: ... }` record and the + * ordinary record destructure serves the pattern — aliases and nested + * patterns included. Only named-group regexes take the wrap: a + * group-less regex destructures `groups: undefined` in Node (a nested + * pattern then throws), which the fence reports honestly instead. Null + * when the pattern or the row does not qualify. */ +export function matchGroupsPatternSource( + lowerer: Lowerer, + pattern: ts.BindingName, + row: IrExpr, + groupsOf: () => { name: string; index: number }[] | null, + loc: SrcLoc, +): IrExpr | null { + if ( + !ts.isObjectBindingPattern(pattern) || + pattern.elements.length === 0 || + !(row.type.kind === "dyn" + ? isJsSourceFile(pattern.getSourceFile()) + : isMatchSliceType(lowerer, row.type)) || + !pattern.elements.every((el) => { + if (el.dotDotDotToken !== undefined || el.name === undefined) return false; + const prop = el.propertyName; + if (prop === undefined) return ts.isIdentifier(el.name) && el.name.text === "groups"; + return (ts.isIdentifier(prop) || ts.isStringLiteral(prop)) && prop.text === "groups"; + }) + ) + return null; + const groups = groupsOf(); + if (groups === null || groups.length === 0) return null; + const proj = lowerGroupsProjection(lowerer, row, groups, loc); + const outerShape = lowerer.shapes.intern( + [{ name: "groups", type: proj.type }], + false, + undefined, + ["groups"], + ); + return { + kind: "recordLit", + fields: [{ name: "groups", value: proj }], + type: { kind: "record", shapeId: outerShape }, + loc, + }; +} + /** One pattern level: emits a declaration (or global assignment) per * bound name, recursing through hidden temps for nested patterns. */ export function lowerBindingPattern( @@ -5033,6 +5056,23 @@ function jsObjectBindingExpands( ) { const name = property(node.left); if (name === null || (name !== undefined && !names.has(name))) expands = true; + // `state = { _tag: "Done", right }` stores an object with properties + // the initializer's shape lacks; later reads of them must see them. + const target = unwrap(node.left); + const value = unwrap(node.right); + const targetSymbol = ts.isIdentifier(target) ? lowerer.resolveValueSymbol(target) : undefined; + if ( + targetSymbol && + aliases.has(targetSymbol) && + ts.isObjectLiteralExpression(value) && + value.properties.some( + (field) => + !(ts.isPropertyAssignment(field) || ts.isShorthandPropertyAssignment(field)) || + !(ts.isIdentifier(field.name) || ts.isStringLiteral(field.name)) || + !names.has(field.name.text), + ) + ) + expands = true; } if (ts.isDeleteExpression(node) && property(node.expression) !== undefined) expands = true; if ( @@ -5156,9 +5196,21 @@ export function lowerSwitch(lowerer: Lowerer, stmt: ts.SwitchStatement): IrStmt const dk = disc.type.kind; if (dk === "dyn") return lowerDynSwitch(lowerer, stmt, disc, labels); if (dk === "union") return lowerUnionSwitch(lowerer, stmt, disc, labels); - if (dk !== "f64" && dk !== "string" && dk !== "bool") { - lowerer.unsupported("SC1090", stmt.expression, "switch on non-primitive values"); - } + // Objects, arrays, functions and other references match their cases + // with ===, which compares them by identity. + if (dk !== "f64" && dk !== "string" && dk !== "bool") + return lowerBranchSwitch(lowerer, stmt, disc, labels, (stableDisc, test, expression) => + lowerStrictEquality( + lowerer, + expression, + stmt.expression, + expression, + stableDisc, + test, + false, + locOf(expression), + ), + ); // The whole case-body sequence is ONE lexical scope in JS: a bare `let` // in one case is visible in later cases. lowerer.scopes.push(new Map()); @@ -5250,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/frontend/lowering/lower-test.ts b/packages/compiler/src/frontend/lowering/lower-test.ts index 6b2563414..d18aec305 100644 --- a/packages/compiler/src/frontend/lowering/lower-test.ts +++ b/packages/compiler/src/frontend/lowering/lower-test.ts @@ -21,23 +21,26 @@ 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, 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. */ @@ -517,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( @@ -556,6 +563,195 @@ 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( + 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 +830,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), @@ -645,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/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/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 9516cb62e..12deec7ad 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -21,6 +21,7 @@ import { lowerWorkerMetadata } from "./builtins/workers.js"; import { fenceStaticBlobMember, lowerBlobNew, lowerStaticBlobCall } from "./builtins/blob.js"; import { lowerKeyObjectCall } from "./builtins/key-object.js"; import { hashStreamOf } from "./builtins/hash-stream.js"; +import { lowerNetAddressCall, lowerNetAddressNew } from "./builtins/net-address.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js"; import { iteratorSlotAdapter } from "./iterator-adapters.js"; import type { FieldLift } from "./coercions/structural-plans.js"; @@ -57,6 +58,7 @@ import { import { unionRetagMappable, narrowedRetagHelper, + uninhabitedArmRetagHelper, strandedUnitTrap, strandedCoercionTrap, recordUnionWrapHelper, @@ -195,6 +197,7 @@ import { cjsExportAssignmentOf, cjsExportDiscardReason, fallbackDtsPath, + isCheckedJsSourceFile, isCjsExportTableLiteral, isCjsJsFile, isJsSourceFile, @@ -338,6 +341,7 @@ import { } from "./call-signatures.js"; import { implicitAnyParamSymbolsOf, + implicitLocalFnNodeOf, type GenericFnInfo, type GenericInstance, implicitMonoFile, @@ -413,6 +417,7 @@ import { lowerTracingChannelMethodCall, lowerTracingChannelProperty, } from "./builtins/async-context.js"; +import { lowerDisposableStackNew } from "./builtins/disposable-stack.js"; import { lowerErrorCodeProperty } from "./builtins/errors.js"; import { lowerProcessProperty, @@ -429,7 +434,7 @@ import { } from "./builtins/console.js"; import { lowerConsoleNew } from "./lower-console.js"; import { lowerNumberStaticCall, lowerNumberStaticProperty } from "./builtins/numbers.js"; -import { lowerDateCall } from "./builtins/dates.js"; +import { dateJsonType, isDateJsonSource, lowerDateCall } from "./builtins/dates.js"; import { lowerFsConstantsProperty, lowerBuiltinConstantsProperty, @@ -519,6 +524,7 @@ import { lowerExprStatement, lowerForOf, lowerForStatement, + matchGroupsPatternSource, } from "./lower-stmts.js"; import { type FieldTarget, @@ -553,6 +559,7 @@ import { fieldSetStmt, lowerFieldCompound, uniqueSymbolKeyOf, + matchCallbackParamGroupsOf, } from "./lower-exprs.js"; import { finishOptionalChain, @@ -692,6 +699,8 @@ export interface LowerResult { export interface LowerOptions { /** Parser/checker services owned by the load; required for npm graph analysis. */ frontendServices?: FrontendServices | undefined; + /** The project's tsconfig sets `checkJs` (see isCheckedJsSourceFile). */ + projectCheckJs?: boolean; /** Coverage: additionally lower the unreached remainder (bodies nothing * on the entry path reaches) in a throwaway pass and report its * diagnostics and stats under `unreached` — the whole-program analysis @@ -777,6 +786,7 @@ type RuntimeFenceTarget = /** The Lowerer's pass configuration (see lowerToIr). */ export interface LowererMode { frontendServices?: FrontendServices | undefined; + projectCheckJs?: boolean; /** Names of bodies a prior reachability pass reached; null lowers everything. */ reachable?: ReadonlySet | null; /** Coverage remainder: lower ONLY bodies outside `reachable`, skip the @@ -901,6 +911,7 @@ export function lowerToIr( options.externalTypeSpecifiersByFile ?? directExternalTypeSpecifiersByFile(externalTypes); const validation = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, ffiImports, @@ -922,6 +933,7 @@ export function lowerToIr( ? validation : new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, targetPlatform, startupCrash, ffiImports, @@ -949,6 +961,7 @@ export function lowerToIr( if (reachableEmit.requiresHistoricalOrderRelower) { const emit = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, reachable, targetPlatform, startupCrash, @@ -1005,6 +1018,7 @@ export function lowerToIr( if (options.coverage !== true) return result; const remainder = new Lowerer(program, entry, moduleOrder, { frontendServices: options.frontendServices, + projectCheckJs: options.projectCheckJs ?? false, reachable, remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, @@ -1515,6 +1529,28 @@ function panicSafe(query: () => T, fallback: T): T { } } +/** The function's class expressions lower once per implicit-any instance. + * A function whose only implicit slots are destructured options and that + * is bound to a variable the implicit-instance probe rejects runs as one + * shared body, so its classes keep a single name. */ +function monomorphizedImplicit( + lowerer: Lowerer, + fn: ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration, +): boolean { + const slots = implicitAnyParamSymbolsOf(lowerer, fn); + if (!slots) return false; + if (slots.some((slot) => slot !== null)) return true; + let binding: ts.Node | undefined = fn.parent; + while (binding && ts.isParenthesizedExpression(binding)) binding = binding.parent; + if ( + (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && + binding && + ts.isVariableDeclaration(binding) + ) + return implicitLocalFnNodeOf(lowerer, binding) !== null; + return true; +} + export class Lowerer { readonly frontendServices: FrontendServices | undefined; readonly checker: ts.TypeChecker; @@ -1738,6 +1774,13 @@ export class Lowerer { /** Non-null while an instance body lowers: type-parameter symbol → * concrete IR type, consulted inside mapType's recursion. */ typeParamBindings: Map | null = null; + /** Generic lambdas lowering as erased values (lowerLambda): their own + * type parameters are bound to `unknown`, so lambdaSignature admits + * them. */ + readonly erasingLambdas = new Set(); + /** Nesting depth of erased generic bodies being lowered (erased lambdas + * and erased instances). */ + erasingDepth = 0; localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = []; /** The ts-level twin of typeParamBindings, non-null while a CALL-keyed * instance body lowers: type-parameter symbol → the bound CHECKER type, @@ -1752,6 +1795,10 @@ export class Lowerer { * typeParamBindings — the checker has no `T` to substitute, so the * binding rides the node-type accessor instead of mapType. */ implicitParamTypes: Map | null = null; + /** Whether some net.Socket in this program may get an encoding: a + * setEncoding call whose receiver is a socket or an untyped value. + * Computed once on first use (null until then). */ + socketEncodingReachable: boolean | null = null; /** IMPLICIT-ANY instances lowered EAGERLY at first demand (their return * types are inferred from the body — the call site needs them settled), * collected here for run()'s function list (the liftedFns discipline). */ @@ -1832,6 +1879,8 @@ export class Lowerer { /** Record fields promoted to an undefined-armed union by an indexed-read * value. The key is the concrete emitted shape and field name. */ readonly runtimeOptionalFields = new Set(); + /** Bindings initialized by a Date's `toJSON()` (analyzeDateJsonResults). */ + readonly dateJsonBindings = new Set(); /** Record bindings whose flow class reaches storage outside the class: * they keep the declared layout, so an initializer from unchecked reads * converts once instead of the binding adopting the widened variant. */ @@ -2364,6 +2413,9 @@ export class Lowerer { * base vtable signature is collected. Unused checked slots carry undefined. */ readonly virtualJsMethodArity = new Map(); readonly checkedVirtualJsMethods = new Set(); + /** Overrides whose return narrows part of the base's union return; they + * return through the base's union ABI (overrideReturnAbi). */ + readonly unionReturnOverrides = new Set(); readonly checkedVirtualJsAccessors = new Set< ts.GetAccessorDeclaration | ts.SetAccessorDeclaration >(); @@ -2621,6 +2673,9 @@ export class Lowerer { readonly targetPlatform: string; /** LowererMode.startupCrash — buildMain opens %main with the throw. */ readonly startupCrash: StartupCrash | null; + /** The project's tsconfig sets `checkJs`. */ + readonly projectCheckJs: boolean; + private readonly checkedJsFiles = new Map(); /** Outbound native bindings by their source-level ambient name. */ readonly ffiImports: readonly IrFfiImport[]; readonly libraryCallbacks: boolean; @@ -2683,6 +2738,7 @@ export class Lowerer { this.alreadyFlushed = mode.alreadyFlushed ?? new Set(); this.targetPlatform = mode.targetPlatform ?? process.platform; this.startupCrash = mode.startupCrash ?? null; + this.projectCheckJs = mode.projectCheckJs ?? false; this.ffiImports = mode.ffiImports ?? []; this.libraryCallbacks = mode.libraryCallbacks ?? false; this.workerTargets = mode.workerTargets ?? []; @@ -2706,6 +2762,7 @@ export class Lowerer { classNamer: this.classNamer, resolveTypeParam: this.typeParamResolver, resolveTypeParamTs: this.typeParamTsResolver, + isErasing: () => this.erasingDepth > 0, genericClassInstance: (decl, ref, mapArgument) => this.genericClassInstanceType(decl, ref, mapArgument), mixinClassInstance: (decl) => @@ -2762,7 +2819,7 @@ export class Lowerer { if (this.localClassInstantiations.some((entry) => entry.owner === parent)) break; if ( parent.typeParameters?.length || - (implicitMonoFile(parent.getSourceFile()) && implicitAnyParamSymbolsOf(this, parent)) + (implicitMonoFile(parent.getSourceFile()) && monomorphizedImplicit(this, parent)) ) return null; } @@ -2808,6 +2865,18 @@ export class Lowerer { registerBuiltinEventTargetClasses(this); } + /** A JavaScript file TypeScript type-checks (`// @ts-check`, or the + * project's `checkJs`). Its JSDoc types are enforced like TypeScript + * annotations, so its classes use them as a TypeScript file would. */ + checkedJsFile(sf: ts.SourceFile): boolean { + let checked = this.checkedJsFiles.get(sf); + if (checked === undefined) { + checked = isCheckedJsSourceFile(sf, this.projectCheckJs); + this.checkedJsFiles.set(sf, checked); + } + return checked; + } + registerBuiltinErrorClasses(): void { return registerBuiltinErrorClasses(this); } @@ -2820,10 +2889,33 @@ export class Lowerer { } /** Declared class fields may store a DYN value only when its static type - * names checked protocol identity or an owned runtime handle (a Worker - * or MessagePort); `unknown` and `any` fields stay refused. */ + * is `unknown` or `object` (also with `null` or `undefined`, or an array + * of those), names checked protocol identity, or is an owned runtime + * handle (a Worker or MessagePort); `any` fields stay refused. */ classFieldStoresDyn(type: ts.Type): boolean { - return this.classImplementedProtocol(type) || isWorkerHandleType(type, this.typeCtx); + return ( + this.opaqueValueType(type) || + this.classImplementedProtocol(type) || + isWorkerHandleType(type, this.typeCtx) + ); + } + + /** `unknown`, `object`, an array of those, or a union of those with + * `null` and `undefined`: types whose values use checked-dynamic storage + * as they do in JavaScript class fields. */ + private opaqueValueType(type: ts.Type): boolean { + const opaque = (part: ts.Type): boolean => + (part.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.NonPrimitive)) !== 0 || + (this.checker.isArrayType(part) && + this.checker.getTypeArguments(part as ts.TypeReference).every(opaque)); + if (!type.isUnionType()) return opaque(type); + const parts = ts.constituentTypes(type); + return ( + parts.some(opaque) && + parts.every( + (part) => (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0 || opaque(part), + ) + ); } /** The nominal identity of a checker module-namespace type. Program @@ -3760,6 +3852,62 @@ export class Lowerer { * TypeScript's default indexed-access type is a useful source annotation, * but it is not a runtime proof; promoting only the affected slots keeps * the rest of the dense ABI unchanged. */ + /** A Date's `toJSON()` answers null for an invalid date while the lib + * types say `string`: unannotated bindings initialized by such a call, + * and object-literal fields holding one, keep `string | null`. */ + analyzeDateJsonResults(parts: FileParts[]): void { + const nullable = dateJsonType(this); + for (const { sf } of parts) { + if (!sf.text.includes("toJSON")) continue; + ts.walkPreorder(sf, (node) => { + // `x = d.toJSON()`: the checker narrows x to string after the + // assignment, so its reads must stay checked. + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isIdentifier(node.left) && + isDateJsonSource(this, node.right) + ) { + const symbol = this.checker.getSymbolAtLocation(node.left); + if (symbol) this.dateJsonBindings.add(symbol); + return undefined; + } + if (!ts.isVariableDeclaration(node) || !node.initializer) return undefined; + if (!ts.isIdentifier(node.name)) return undefined; + const symbol = this.checker.getSymbolAtLocation(node.name); + if (!symbol) return undefined; + let init: ts.Expression = node.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (isDateJsonSource(this, init)) { + this.dateJsonBindings.add(symbol); + // An annotation keeps its declared storage. + if (!node.type) this.runtimeOptionalBindingTypes.set(symbol, nullable); + return undefined; + } + if (node.type) return undefined; + if (!ts.isObjectLiteralExpression(init)) return undefined; + const declared = this.mapTypeOf(this.typeOf(node.name)); + if (declared?.kind !== "record") return undefined; + let widened: IrType = declared; + for (const property of init.properties) { + const value = ts.isPropertyAssignment(property) + ? property.initializer + : ts.isShorthandPropertyAssignment(property) + ? property.name + : null; + const name = + property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) + ? property.name.text + : null; + if (value && name !== null && isDateJsonSource(this, value)) + widened = this.runtimeOptionalRecordField(widened, name, nullable); + } + if (widened !== declared) this.runtimeOptionalBindingTypes.set(symbol, widened); + return undefined; + }); + } + } + analyzeRuntimeOptionalArrayReads(parts: FileParts[]): void { const provenance = RuntimeOptionalProvenance.fromEnvironment(); // The site whose value the current promotion follows (debug only). @@ -4123,11 +4271,35 @@ export class Lowerer { // Such a binding reads `T | undefined`, so a typed parameter, local or // field it flows into widens like any other missing element read. const holeyIterationSymbols = new Set(); + // `xs[i] as number` and `stack.pop() as number`: an assertion to a type + // without undefined checks the value (lowerAsExpression narrows to the + // asserted type and throws a TypeError for undefined), so its result is + // never undefined. + const assertsDefined = (node: ts.Expression): boolean => { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (!ts.isAsExpression(e) && !ts.isTypeAssertion(e)) return false; + const asserted = typeOrNull(e); + const admits = (type: ts.Type): boolean => + (type.flags & + (ts.TypeFlags.Any | + ts.TypeFlags.Unknown | + ts.TypeFlags.Undefined | + ts.TypeFlags.Void | + ts.TypeFlags.TypeParameter)) !== + 0; + return ( + asserted !== null && + !admits(asserted) && + !(asserted.isUnionType() && ts.constituentTypes(asserted).some(admits)) + ); + }; const mayBeOptional = (node: ts.Expression): boolean => { // `xs[i]!` is the explicit proven-present form. Its array read keeps // the established dense bounds trap and must not promote the enclosing // function/global ABI to `T | undefined`. if (explicitlyNonNull(node)) return false; + if (assertsDefined(node)) return false; const e = peel(node); if (isIndexedRead(e)) return true; if (ts.isPropertyAccessExpression(e) && isOptionalProcessStreamProperty(this, e)) return true; @@ -5885,6 +6057,7 @@ export class Lowerer { } this.collectProgram(parts); this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the // class, and reference lowering needs each class's rebindability @@ -6269,6 +6442,7 @@ export class Lowerer { }; this.collectProgram(parts); this.analyzeRuntimeOptionalArrayReads(parts); + this.analyzeDateJsonResults(parts); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct // thunks) reachable emit must see. @@ -8419,7 +8593,9 @@ export class Lowerer { // node's CHECKER type proves those arms away, they trap instead — the // sub-union narrowing bridge (narrowedRetagHelper). if (e.type.kind === "union" && expected.kind === "union" && !typeEquals(e.type, expected)) { - const helper = this.narrowedRetagHelper(node, e.type.unionId, expected.unionId, e.loc); + const helper = + this.narrowedRetagHelper(node, e.type.unionId, expected.unionId, e.loc) ?? + uninhabitedArmRetagHelper(this, node, e.type.unionId, expected.unionId, e.loc); if (helper) { e = { kind: "call", callee: helper, args: [e], type: expected, loc: e.loc }; } @@ -9870,6 +10046,22 @@ export class Lowerer { const pickedT = shape.bodyType; srcType = pickedT; srcRef = () => ({ kind: "varRef", localId: src.id, type: pickedT, loc }); + } else { + // `({ groups }) => ...` over matchAll rows. + const groupsSource = matchGroupsPatternSource( + this, + decl.name, + srcRef(), + () => matchCallbackParamGroupsOf(this, decl), + loc, + ); + if (groupsSource !== null) { + const src = this.declareHiddenLocal("%psrc", groupsSource.type); + prologue.push({ kind: "varDecl", localId: src.id, init: groupsSource, loc }); + const groupsT = groupsSource.type; + srcType = groupsT; + srcRef = () => ({ kind: "varRef", localId: src.id, type: groupsT, loc }); + } } this.lowerBindingPattern(decl.name, srcRef, srcType, true, prologue); return; @@ -10735,6 +10927,7 @@ export class Lowerer { lowerStaticResponseCall(this, expr) ?? lowerStaticBlobCall(this, expr) ?? lowerKeyObjectCall(this, expr) ?? + lowerNetAddressCall(this, expr) ?? lowerFetchElementMethodCall(this, expr) ?? lowerDynamicImportCall(this, expr) ?? lowerCall(this, expr) @@ -10949,10 +11142,12 @@ export class Lowerer { lowerLifetimeRegionNew(this, expr) ?? lowerInternTableNew(this, expr) ?? lowerAbortControllerNew(this, expr) ?? + lowerDisposableStackNew(this, expr) ?? lowerEventTargetNew(this, expr) ?? lowerFetchWebNew(this, expr) ?? lowerResponseNew(this, expr) ?? lowerBlobNew(this, expr) ?? + lowerNetAddressNew(this, expr) ?? lowerStaticReadableStreamNew(this, expr) ?? lowerConsoleNew(this, expr); return ( @@ -11181,8 +11376,29 @@ export class Lowerer { return { module: "fs", member: "realpathSync.native" }; } } + // timers/promises scheduler.wait / scheduler.yield: methods of the + // module's scheduler object, dispatched as module members. + if (access.name.text === "wait" || access.name.text === "yield") { + const owner = access.expression; + const imported = ts.isIdentifier(owner) + ? this.builtinImportOf(owner) + : ts.isPropertyAccessExpression(owner) + ? this.builtinMemberOf(owner) + : null; + if (imported?.module === "timers/promises" && imported.member === "scheduler") { + return { module: "timers/promises", member: `scheduler.${access.name.text}` }; + } + } const module = this.builtinNamespaceModuleOf(access.expression); if (module !== null) return { module, member: access.name.text }; + // The events module object IS the EventEmitter class, so the class's + // defaultMaxListeners is the module property. + if (access.name.text === "defaultMaxListeners" && ts.isIdentifier(access.expression)) { + const imported = this.builtinImportOf(access.expression); + if (imported?.module === "events" && imported.member === "EventEmitter") { + return { module: "events", member: "defaultMaxListeners" }; + } + } // The RE-EXPORT FACADE's namespace spelling: `import * as assert from // "./facade.js"` over `export { ok } from "node:assert"` (the formatter idiom's // universal/assert). The member's symbol is the facade's diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts index 2a537d6de..f7023cc52 100644 --- a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -191,6 +191,7 @@ const TYPED_ARRAYS = new Set([ "Uint16Array", "Int32Array", "Uint32Array", + "Float16Array", "Float32Array", "Float64Array", ]); diff --git a/packages/compiler/src/frontend/lowering/narrowing-stability.ts b/packages/compiler/src/frontend/lowering/narrowing-stability.ts index 9c7850ceb..4ae8eec67 100644 --- a/packages/compiler/src/frontend/lowering/narrowing-stability.ts +++ b/packages/compiler/src/frontend/lowering/narrowing-stability.ts @@ -834,6 +834,7 @@ const INERT_CONSTRUCTORS = new Set([ "Int16Array", "Uint32Array", "Int32Array", + "Float16Array", "Float32Array", "Float64Array", "BigInt64Array", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index ec931350a..848b34afe 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -664,6 +664,7 @@ export const STDLIB_SURFACE = { cosh: ISL_N1, exp: ISL_N1, expm1: ISL_N1, + f16round: ISL_N1, fround: ISL_N1, hypot: ISL_N2, imul: ISL_N2, @@ -751,6 +752,7 @@ export const STATIC_MATH_FNS: Record { + // The program fails to typecheck against the package's inferred surface + // and against its declarations alike: the errors are the program's. + preflight.mockReturnValueOnce([]).mockReturnValue([typeError]); + const frontend = runFrontend("/main.ts", loader, ["fixture"]); + expect(frontend.npmStatic).toEqual([]); + expect(frontend.preflight).toEqual([typeError]); + frontend.dispose(); +}); + +test("reports program type errors that name the package's module", () => { + const missingExport: ScrDiagnostic = { + code: "SC0001", + message: `Module '"fixture"' has no exported member 'open'.`, + loc: { file: "/main.ts", start: 9, end: 13 }, + }; + preflight.mockReturnValueOnce([]).mockReturnValue([missingExport]); + const frontend = runFrontend("/main.ts", loader, ["fixture"]); + expect(frontend.npmStatic).toEqual([]); + expect(frontend.preflight).toEqual([missingExport]); + frontend.dispose(); +}); diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index db2b57af8..2ec9553f6 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -337,6 +337,9 @@ function loadFrontend( } } const effective = new Set(requested); + // Packages refused only because the program does not typecheck against + // their inferred surface (no offender, no package-anchored diagnostic). + const typecheckRefused = new Set(); while (effective.size > 0) { const reasons = new Map(npmStaticOffenders()); for (const d of preflight) { @@ -352,6 +355,7 @@ function loadFrontend( } } for (const [pkg, count] of named) { + typecheckRefused.add(pkg); reasons.set( pkg, `its inferred export surface breaks ${count} import site${count === 1 ? "" : "s"} in program files (bundler-emitted surfaces type only as far as inference reaches)`, @@ -384,6 +388,7 @@ function loadFrontend( if (effective.size > 0 && preflight.some((d) => d.code === "SC0001")) { const dropWithNote = (p: string): void => { effective.delete(p); + typecheckRefused.add(p); statuses.push({ package: p, status: "refused", @@ -418,6 +423,16 @@ function loadFrontend( for (const p of requested) { if (effective.has(p)) statuses.push({ package: p, status: "static" }); } + // The final program types the refused packages by their own + // declarations. If it still fails to typecheck, the type errors are the + // program's, not the packages': report them at their own locations + // instead of a package refusal that hides them. + if (typecheckRefused.size > 0 && preflight.some((d) => d.code === "SC0001")) { + for (let i = statuses.length - 1; i >= 0; i--) { + const s = statuses[i]!; + if (s.status === "refused" && typecheckRefused.has(s.package)) statuses.splice(i, 1); + } + } const finalLoad = load; return { @@ -461,6 +476,7 @@ function loadFrontend( ...opts, frontendServices: finalLoad.services, startupCrash: finalLoad.startupCrash ?? null, + projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false, externalTypes: finalLoad.externalTypes, externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, }), diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 3a4ffd747..3a4ec1647 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -138,11 +138,16 @@ const FORCED_OPTIONS: ts.Ts7CompilerOptions = { // classification is intentionally independent below; this option is only // the binder's scope decision. moduleDetection: ts.ModuleDetectionKind.Force, - // Explicit resource management and Array.fromAsync are ESNext library - // slices in TS 7 even though Node 24 ships them. Include those focused - // libs without widening the rest of the standard-library target past - // ES2025. - lib: ["lib.es2025.d.ts", "lib.esnext.disposable.d.ts", "lib.esnext.array.d.ts"], + // Explicit resource management, Array.fromAsync and Error.isError are + // ESNext library slices in TS 7 even though Node 24 ships them. Include + // those focused libs without widening the rest of the standard-library + // target past ES2025. + lib: [ + "lib.es2025.d.ts", + "lib.esnext.disposable.d.ts", + "lib.esnext.array.d.ts", + "lib.esnext.error.d.ts", + ], types: [], allowImportingTsExtensions: true, allowJs: true, @@ -232,6 +237,7 @@ function adoptProjectConfig7( diags: ScrDiagnostic[]; projectLib?: readonly string[]; forcedStrictNullChecks?: boolean; + checkJs?: boolean; } { const configFile = ts.findConfigFile(dirname(entryPath), ts.sys.fileExists) ?? null; if (!configFile) { @@ -295,6 +301,7 @@ function adoptProjectConfig7( diags, ...(projectLib === undefined ? {} : { projectLib }), forcedStrictNullChecks, + checkJs: parsed.options["checkJs"] === true, }; } @@ -313,6 +320,9 @@ export interface TypeWorldDifferences { projectLib: readonly string[] | null; /** The project disables strictNullChecks; scriptc forces it on. */ forcedStrictNullChecks: boolean; + /** The project's tsconfig sets `checkJs` (scriptc's own checker always + * reads JavaScript files; this records whether the project's does). */ + projectCheckJs: boolean; } export interface LoadResult { @@ -729,6 +739,7 @@ function loadProgram7( nodeFallback: nodeTypes === null, projectLib: config.projectLib ?? null, forcedStrictNullChecks: config.forcedStrictNullChecks ?? false, + projectCheckJs: config.checkJs ?? false, }, externalTypes, externalTypeSpecifiersByFile, @@ -983,6 +994,14 @@ export function checkPreflight(load: T): ScrDiagnostic[] { // lowering separately verifies the actual constructor implementation. const TS7_JS_RELAXED_EXTRA: ReadonlySet = new Set([2309, 2323, 2507]); +/** Relaxed JavaScript codes that checked JavaScript files keep: values of + * another type in a typed slot (2322 and its elaborations 2740/2741/2559, + * 2345, 2769), call arity (2554-2556) and incompatible subclass members + * (2415). */ +const CHECKED_JS_ENFORCED_CODES: ReadonlySet = new Set([ + 2322, 2345, 2415, 2554, 2555, 2556, 2559, 2740, 2741, 2769, +]); + function suppressedJsStrictness7(d: ts.Diagnostic): boolean { const file = d.fileName; if (!file || !isJsSourceFileName(file)) return false; @@ -3487,6 +3506,16 @@ function preflight7(load: LoadResult): { : spec.split("/")[0]!; return isWorkspacePackageName(prefix); }; + // A JavaScript file that TypeScript checks keeps the type errors that + // protect its JSDoc types: a value of another type reaching a typed slot, + // a call with the wrong arity, and a subclass member that does not + // match its base. + const projectCheckJs = load.typeWorld?.projectCheckJs ?? false; + const enforcedInCheckedJs = (p: ts.Program, d: ts.Diagnostic): boolean => { + if (!CHECKED_JS_ENFORCED_CODES.has(d.code) || d.fileName === undefined) return false; + const sf = p.getSourceFile(d.fileName); + return sf !== undefined && isCheckedJsSourceFile(sf, projectCheckJs); + }; const errorsOf = (p: ts.Program): ts.Diagnostic[] => { const all = ts.getPreEmitDiagnostics(p); // First pass: every comment-side 2300's (file, name) — the partners @@ -3503,7 +3532,7 @@ function preflight7(load: LoadResult): { return all.filter( (d) => d.category === ts.DiagnosticCategory.Error && - !suppressedJsStrictness7(d) && + (!suppressedJsStrictness7(d) || enforcedInCheckedJs(p, d)) && !npmStaticFileSuppressed(d) && !nodeModulesJsSuppressed(d) && !namespaceCalleeSuppressed(p, d) && @@ -4910,6 +4939,21 @@ export function isJsSourceFile(sf: ts.SourceFile): boolean { return isJsSourceFileName(sf.fileName); } +/** True for a JavaScript file of the program that TypeScript type-checks: + * one with a `// @ts-check` comment before its first statement, or any + * file of a project whose tsconfig sets `checkJs` unless the file has a + * `// @ts-nocheck` comment. Files under node_modules are never checked. */ +export function isCheckedJsSourceFile(sf: ts.SourceFile, projectCheckJs: boolean): boolean { + if (!isJsSourceFile(sf) || isNodeModulesPath(sf.fileName)) return false; + let checked = projectCheckJs; + const head = sf.text.slice(0, sf.statements[0]?.getStart(sf) ?? sf.text.length); + for (const line of head.split("\n")) { + const directive = /^\s*\/\/\s*@ts-(no)?check\b/.exec(line); + if (directive) checked = directive[1] === undefined; + } + return checked; +} + /** `import type …` declarations and named clauses whose every element is * `type`-qualified are ERASED by tsc at emit — Node's module graph has no * edge there, so module order, cycle detection, init calls, and the diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index a329ef588..52964e1bd 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -130,6 +130,7 @@ export const FETCH_AMBIENT_TYPES = [ "RequestInit", "Event", "CustomEvent", + "MessageEvent", "EventTarget", "AddEventListenerOptions", "EventListenerOptions", @@ -995,6 +996,13 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null; * one new field here, not a new parameter at every call site. */ export interface TypeMapperCtx { checker: ts.TypeChecker; + /** Set while mapping a generic callable member's erased type + * (erasedGenericMemberType): the signature's own type parameters + * resolve to `unknown` instead of refusing the mapping. */ + erasingSignature?: ts.Signature; + /** True while an erased generic body lowers (its type parameters are + * bound to `unknown`). */ + isErasing?: () => boolean; shapes: ShapeRegistry; unions: UnionRegistry; classNamer: ClassNamer; @@ -1236,7 +1244,8 @@ function isWorkerHandleSymbol( return ( (symbol?.name === "Worker" || symbol?.name === "MessagePort" || - symbol?.name === "MessageChannel") && + symbol?.name === "MessageChannel" || + symbol?.name === "BroadcastChannel") && ctx.checker .declarationsOf(symbol) .some( @@ -1382,6 +1391,21 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // still the T it maps. const immutableSource = immutableSourceType(type, ctx); if (immutableSource !== null) return mapType(immutableSource, ctx); + // Inside an erased generic signature or body, every type that mentions + // an erased type parameter is `unknown` (`T[]`, `Map`, + // `[A, B]`, `T[K]`, callbacks over `T`): its values cross through the + // checked-dynamic boundary, as JavaScript sees them. The erased + // signature itself maps its parameters and result this way. + if ( + (ctx.erasingSignature || ctx.isErasing?.()) && + !(type.flags & ts.TypeFlags.TypeParameter) && + !checker.getCallSignatures(type).some((sig) => sig === ctx.erasingSignature) && + !ERASURE_KEPT_CONTAINERS.has(type.getSymbol()?.name ?? "") && + mentionsErasedTypeParam(type, ctx, 0) + ) { + contextResolutions++; + return DYN; + } if (resolveTypeParam && type.flags & ts.TypeFlags.TypeParameter) { const bound = resolveTypeParam(type); if (bound) { @@ -1420,6 +1444,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { contextResolutions++; return viaIndexed; } + // A conditional type over a bound parameter (`Infer`, a + // mapped type's member): evaluate it against the bindings. + if (type.isConditionalType()) { + const evaluated = evalMappedTemplate(type, new Map(), new Map(), ctx); + if (evaluated && evaluated !== type) { + const viaConditional = mapType(evaluated, ctx); + if (viaConditional !== null) { + contextResolutions++; + return viaConditional; + } + } + } } const widened = checker.getBaseTypeOfLiteralType(type); const flags = widened.flags; @@ -2302,6 +2338,20 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (!elem) return null; return genResultRecord(elem, VOID, ctx.shapes, ctx.unions); } + // node:net's SocketAddress and BlockList: native handles (scr_net.c) + // whose getters and methods dispatch through the handle. + if ( + (psym?.name === "SocketAddress" || psym?.name === "BlockList") && + checker + .declarationsOf(psym) + .some( + (d) => + (ts.isClassDeclaration(d) || ts.isInterfaceDeclaration(d)) && + ctx.isStdlibFile(d.getSourceFile()), + ) + ) { + return DYN; + } // node:crypto's KeyObject: secret keys are native handles (scr_lib.c) // whose getters and methods dispatch through the handle. The JsonWebKey // that export() returns is a plain checked-dynamic object. @@ -2341,9 +2391,13 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // representation and consumed by the console method lowerings. The // stdlib Disposable (events.addAbortListener's result) and // AsyncDisposable are checked objects carrying their Symbol.dispose or - // Symbol.asyncDispose method. + // Symbol.asyncDispose method. A DisposableStack is a native handle whose + // methods are function values of its checked-dynamic view. if ( - (psym?.name === "Console" || psym?.name === "Disposable" || psym?.name === "AsyncDisposable") && + (psym?.name === "Console" || + psym?.name === "Disposable" || + psym?.name === "AsyncDisposable" || + psym?.name === "DisposableStack") && checker .declarationsOf(psym) .some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())) @@ -2469,6 +2523,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // ToInt32-wrap) — the Atomics.wait sleep idiom constructs one over a // SharedArrayBuffer, and the i32 semantics hold for every other use. if (isStdlibInterface("Int32Array")) return bytesOf("i32"); + if (isStdlibInterface("Float16Array")) return bytesOf("f16"); if (isStdlibInterface("Float32Array")) return bytesOf("f32"); if (isStdlibInterface("Float64Array")) return bytesOf("f64"); // DataView: the ONE view kind — a u8 bytes value whose runtime @@ -3787,7 +3842,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const callSigs = checker.getCallSignatures(widened); if (callSigs.length === 1) { const sig = callSigs[0]!; - if (sig.getTypeParameters().length) return null; + if (sig.getTypeParameters().length && ctx.erasingSignature !== sig) return null; // A SYNTHESIZED rest param (tsc's JS inference for a function body // reading `arguments` — `(...args: any[]) => any` whose args symbol // has no declaration): the dotDotDot check below can't see it, and a @@ -4050,6 +4105,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (childShaped && core >= 2) return CHILD_T; } if (flags & (ts.TypeFlags.Object | ts.TypeFlags.Intersection) && callSigs.length === 0) { + const viaMapped = mapBoundMappedType(widened, ctx); + if (viaMapped) return viaMapped; const viaAlias = mapGenericUtilityAlias(widened, ctx); if (viaAlias) return viaAlias; const record = mapRecordType(widened, ctx); @@ -4689,6 +4746,187 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu return covered.every((t) => typeEquals(t, first)) ? first : null; } +/** A mapped type over a type parameter of the instance being lowered (`{ + * [K in keyof S]: S[K] }`, `{ [K in keyof S]: Infer }`): the checker + * keeps it symbolic inside the generic body, but the instance binds `S`, + * so each property of the bound type maps through the template evaluated + * with `K` set to that property's key. Optionality follows the `?` + * modifier, or the bound property's own for a homomorphic mapping. Null + * when the shape is anything else or a template form does not evaluate. */ +function mapBoundMappedType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { + const { checker, resolveTypeParamTs, shapes, unions } = ctx; + if (!resolveTypeParamTs || !isMappedShape(widened)) return null; + const symbol = widened.getSymbol(); + const node = symbol + ? checker.declarationsOf(symbol).find((d): d is ts.MappedTypeNode => ts.isMappedTypeNode(d)) + : undefined; + const constraint = node?.typeParameter.constraint; + if ( + !node?.type || + !constraint || + !ts.isTypeOperatorNode(constraint) || + constraint.operator !== ts.SyntaxKind.KeyOfKeyword + ) + return null; + const source = checker.getTypeFromTypeNode(constraint.type); + if (!(source.flags & ts.TypeFlags.TypeParameter)) return null; + const bound = resolveTypeParamTs(source); + const key = checker.getSymbolAtLocation(node.typeParameter.name); + if (!bound || !key) return null; + const template = checker.getTypeFromTypeNode(node.type); + const modifier = node.questionToken?.kind; + const fields: { name: string; type: IrType }[] = []; + const order: string[] = []; + for (const property of checker.getPropertiesOfType(bound)) { + const name = property.name; + const evaluated = evalMappedTemplate(template, new Map([[key, name]]), new Map(), ctx); + let type = evaluated ? mapType(evaluated, ctx) : null; + if (!type) return null; + const optional = + modifier === ts.SyntaxKind.MinusToken + ? false + : modifier !== undefined || (property.flags & ts.SymbolFlags.Optional) !== 0; + if (optional) { + const armed = withUndefinedArm(type, unions); + if (!armed) return null; + type = armed; + } + fields.push({ name, type }); + order.push(name); + } + fields.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + return { kind: "record", shapeId: shapes.intern(fields, false, undefined, order) }; +} + +/** The checker type a mapped type's template stands for at one key: + * `keys` binds the mapped type parameter to a property name, `aliases` + * binds type-alias and `infer` parameters met on the way. Handles type + * parameters (through the instance's bindings), indexed accesses + * (`S[K]`, `S[K]["_output"]`) and conditional types whose `extends` side + * is a reference to the same generic type as the checked type + * (`S extends Schema ? T : never`); answers the type itself + * when it mentions none of the bound parameters, null otherwise. */ +function evalMappedTemplate( + type: ts.Type, + keys: Map, + aliases: Map, + ctx: TypeMapperCtx, +): ts.Type | null { + const { checker, resolveTypeParamTs } = ctx; + if (type.flags & ts.TypeFlags.TypeParameter) { + const symbol = type.getSymbol(); + if (symbol && keys.has(symbol)) return null; + if (symbol && aliases.has(symbol)) return aliases.get(symbol)!; + return resolveTypeParamTs?.(type) ?? null; + } + if (type.isIndexedAccessType()) { + const object = type.getObjectType(); + const index = type.getIndexType(); + if (!object || !index) return null; + const receiver = evalMappedTemplate(object, keys, aliases, ctx); + const indexSymbol = index.flags & ts.TypeFlags.TypeParameter ? index.getSymbol() : undefined; + const name = + indexSymbol && keys.has(indexSymbol) + ? keys.get(indexSymbol)! + : index.isStringLiteralType() + ? index.value + : index.isNumberLiteralType() + ? String(index.value) + : null; + if (!receiver || name === null) return null; + const property = checker.getPropertyOfType(receiver, name); + return property ? checker.getTypeOfSymbol(property) : null; + } + if (type.isConditionalType()) { + // A conditional reached through an alias (`Infer`) checks the + // alias's own parameters: bind them to the evaluated arguments. + let scope = aliases; + const alias = type.getAliasSymbol(); + const aliasArgs = type.getAliasTypeArguments(); + const aliasDecl = alias + ? checker + .declarationsOf(alias) + .find((d): d is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(d)) + : undefined; + if (aliasDecl?.typeParameters && aliasArgs.length === aliasDecl.typeParameters.length) { + scope = new Map(aliases); + for (let i = 0; i < aliasArgs.length; i++) { + const param = checker.getSymbolAtLocation(aliasDecl.typeParameters[i]!.name); + const arg = evalMappedTemplate(aliasArgs[i]!, keys, aliases, ctx); + if (!param || !arg) return null; + scope.set(param, arg); + } + } + const checkType = type.getCheckType(); + const extendsType = type.getExtendsType(); + if (!checkType || !extendsType) return null; + const checked = evalMappedTemplate(checkType, keys, scope, ctx); + if (!checked) return null; + const inferred = matchInferReference(checked, extendsType, ctx); + if (inferred === undefined) return null; + const branch = inferred ? type.getTrueType() : type.getFalseType(); + const branchScope = inferred ? new Map([...scope, ...inferred]) : scope; + return evalMappedTemplate(branch, keys, branchScope, ctx); + } + return mentionsMappedParams(type, keys, aliases) ? null : type; +} + +/** `checked extends G<..., infer T, ...>`: the infer bindings when + * `checked` is a reference to the same generic `G` (its arguments bind the + * infer positions; other positions must be the same types), false when it + * is a reference to another type or a primitive, undefined when the match + * cannot be decided this way. */ +function matchInferReference( + checked: ts.Type, + extendsType: ts.Type, + ctx: TypeMapperCtx, +): Map | false | undefined { + const { checker } = ctx; + const target = (t: ts.Type): ts.Type | undefined => + t.isTypeReference?.() ? (t as ts.TypeReference).getTarget?.() : undefined; + const wantTarget = target(extendsType) ?? extendsType.getAliasSymbol(); + if (!wantTarget) return undefined; + const gotTarget = target(checked) ?? checked.getAliasSymbol(); + if (!gotTarget) return checked.flags & ts.TypeFlags.Object ? undefined : false; + if (gotTarget !== wantTarget) return undefined; + const wantArgs = target(extendsType) + ? checker.getTypeArguments(extendsType as ts.TypeReference) + : extendsType.getAliasTypeArguments(); + const gotArgs = target(checked) + ? checker.getTypeArguments(checked as ts.TypeReference) + : checked.getAliasTypeArguments(); + if (wantArgs.length !== gotArgs.length) return undefined; + const inferred = new Map(); + for (let i = 0; i < wantArgs.length; i++) { + const want = wantArgs[i]!; + const symbol = want.flags & ts.TypeFlags.TypeParameter ? want.getSymbol() : undefined; + const isInfer = symbol + ? checker + .declarationsOf(symbol) + .some((d) => d.parent !== undefined && ts.isInferTypeNode(d.parent)) + : false; + if (isInfer && symbol) inferred.set(symbol, gotArgs[i]!); + else if (want !== gotArgs[i]) return undefined; + } + return inferred; +} + +/** The type mentions a mapped key or bound alias parameter (a template + * shape evalMappedTemplate does not reduce). */ +function mentionsMappedParams( + type: ts.Type, + keys: Map, + aliases: Map, +): boolean { + if (type.flags & ts.TypeFlags.TypeParameter) { + const symbol = type.getSymbol(); + return symbol !== undefined && (keys.has(symbol) || aliases.has(symbol)); + } + if (type.isUnionType() || type.isIntersectionType()) + return ts.constituentTypes(type).some((part) => mentionsMappedParams(part, keys, aliases)); + return false; +} + function mapGenericUtilityAlias(widened: ts.Type, ctx: TypeMapperCtx): IrType | null { const { resolveTypeParam, shapes, unions } = ctx; if (!resolveTypeParam) return null; @@ -5200,6 +5438,67 @@ export function isGenericCallableMemberType(t: ts.Type, checker: ts.TypeChecker) ); } +/** Result containers an erased async function or generator must keep: + * their contents are erased instead (`Promise` maps as + * `Promise`). */ +const ERASURE_KEPT_CONTAINERS = new Set(["Promise", "Generator", "AsyncGenerator"]); + +/** The type mentions a type parameter that the active erasure binds to + * `unknown` (erasedGenericMemberType, erased bodies). */ +function mentionsErasedTypeParam(type: ts.Type, ctx: TypeMapperCtx, depth: number): boolean { + if (depth > 8) return false; + const { checker } = ctx; + if (type.flags & ts.TypeFlags.TypeParameter) return ctx.resolveTypeParam?.(type)?.kind === "dyn"; + if ( + type.flags & + (ts.TypeFlags.IndexedAccess | ts.TypeFlags.Conditional | ts.TypeFlags.Substitution) + ) + return true; + if (type.isUnionType() || type.isIntersectionType()) + return ts.constituentTypes(type).some((part) => mentionsErasedTypeParam(part, ctx, depth + 1)); + if ((type.flags & ts.TypeFlags.Object) === 0) return false; + if ( + (type as ts.Type).isTypeReference() && + checker + .getTypeArguments(type as ts.TypeReference) + .some((arg) => mentionsErasedTypeParam(arg, ctx, depth + 1)) + ) + return true; + for (const sig of checker.getCallSignatures(type)) { + if (mentionsErasedTypeParam(checker.getReturnTypeOfSignature(sig), ctx, depth + 1)) return true; + for (const param of sig.getParameters()) + if (mentionsErasedTypeParam(checker.getTypeOfSymbol(param), ctx, depth + 1)) return true; + } + const symbol = type.getSymbol(); + const anonymous = + symbol !== undefined && + (symbol.flags & (ts.SymbolFlags.TypeLiteral | ts.SymbolFlags.ObjectLiteral)) !== 0; + if (anonymous) + for (const property of checker.getPropertiesOfType(type)) + if (mentionsErasedTypeParam(checker.getTypeOfSymbol(property), ctx, depth + 1)) return true; + return false; +} + +/** A generic callable member's runtime type: its one call signature with + * every own type parameter bound to `unknown`, the erased instance a stored + * or escaping definition carries (JavaScript erases type parameters the + * same way). Undefined when the member has overloads or the erased + * signature does not map: the member then stays out of the shape, and only + * calls on a provable definition compile. */ +export function erasedGenericMemberType(t: ts.Type, ctx: TypeMapperCtx): IrType | null | undefined { + const sigs = ctx.checker.getCallSignatures(t); + if (sigs.length !== 1) return undefined; + const sig = sigs[0]!; + const own = new Set(sig.getTypeParameters()); + const outer = ctx.resolveTypeParam; + const erased = mapType(t, { + ...ctx, + erasingSignature: sig, + resolveTypeParam: (param) => (own.has(param) ? DYN : (outer?.(param) ?? null)), + }); + return erased?.kind === "func" ? erased : undefined; +} + /** The computed pieces of a record shape mapRecordTypeInner hands back for * the OUTER frame to intern — or, when a back-reference minted a recursive * placeholder for the type mid-construction, to finalize INTO that @@ -5581,8 +5880,10 @@ function mapRecordTypeInner( // can hold them — see isGenericCallableMemberType): the shape keeps // its data fields, and calls of the member monomorphize per call // site against the defining object literal's declaration. - if (isGenericCallableMemberType(fieldTs, checker)) continue; - let pt = mapType(fieldTs, ctx); + let pt = isGenericCallableMemberType(fieldTs, checker) + ? erasedGenericMemberType(fieldTs, ctx) + : mapType(fieldTs, ctx); + if (pt === undefined) continue; // tsgo PANICS computing `readonly []` through the symbol-type query // (the TupleType conversion — the facade's panic fence answers // `any`), which would absorb the whole shape into the dynamic tier. @@ -5902,8 +6203,10 @@ export function describeRecordMemberBlocker(widened: ts.Type, ctx: TypeMapperCtx return null; } const fieldTs = checker.getTypeOfSymbol(p); - if (isGenericCallableMemberType(fieldTs, checker)) continue; - let pt = mapType(fieldTs, ctx); + let pt = isGenericCallableMemberType(fieldTs, checker) + ? erasedGenericMemberType(fieldTs, ctx) + : mapType(fieldTs, ctx); + if (pt === undefined) continue; if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)) pt = unitOnlyUnion(ctx.unions); if (!pt || pt.kind === "void") { diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 3c35b4e05..c3009665a 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -635,6 +635,7 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD error.code === "unsupported" ? "SC3002" : "SC3003", error.message, entryPath, + error.hint, ); } diff --git a/packages/compiler/src/ir/analysis.ts b/packages/compiler/src/ir/analysis.ts index 850783d4e..88f6f394f 100644 --- a/packages/compiler/src/ir/analysis.ts +++ b/packages/compiler/src/ir/analysis.ts @@ -219,6 +219,7 @@ const BORROW_SAFE_MATH = new Set([ "math.sign", "math.exp", "math.expm1", + "math.f16round", "math.fround", "math.log", "math.log1p", diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 3935f3703..1bb9cbd14 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -16,6 +16,22 @@ 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.ctxSnapshot", + "test.fileSnapshot", + "test.snapshotSerialize", + "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", @@ -24,8 +40,12 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "worker.new", "worker.data", "worker.parentPort", + "worker.resourceLimits", "worker.channel", "worker.receiveMessageOnPort", + "worker.setEnvironmentData", + "worker.getEnvironmentData", + "worker.broadcastChannel", "process.exit", "text.encodeInto", "buffer.isAscii", @@ -34,11 +54,13 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "cp.spawnDynamic", "child.onDyn", "text.decoderEncoding", + "strdec.encoding", "dyn.construct", "dyn.reflectApply", "dyn.reflectGet", "dyn.usingMethod", "dyn.usingDispose", + "disposable.stackUseCallback", "dyn.reflectSet", "dyn.reflectDefine", "dyn.abstractEq", @@ -73,6 +95,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "arrayBuffer.viewU8", "arrayBuffer.viewU32", "arrayBuffer.viewI32", + "arrayBuffer.viewF16", "arrayBuffer.viewF32", "arrayBuffer.viewF64", "arrayBuffer.viewDV", @@ -545,6 +568,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.freeze", "dyn.isFrozen", "dyn.toString", + "dyn.valueOf", "string.fromCodePoint", "dyn.defineProps", "dyn.definePrototypeProps", @@ -555,6 +579,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.arrayProtoCall", "process.chdir", "os.getPriority", + "os.setPriority", // Pattern length TypeError, brace-sequence RangeError, and the // SyntaxError of a segment regex invalid under the u flag. "path.matchesGlob", @@ -734,6 +759,9 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "net.connectCb", // resetAndDestroy on a TLS socket throws ERR_INVALID_HANDLE_TYPE. "net.sockResetAndDestroy", + // SocketAddress's option checks and parse()'s input check. + "net.socketAddressNew", + "net.socketAddressParse", // node:dgram state errors (Node throws synchronously): bind on a bound // socket, connect on a connected one, send/close/address on a closed or // never-bound one. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index bfc7d95c2..a9bc17ea6 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -213,6 +213,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * numbers, booleans, arrays, objects answer JS-exactly; undefined and * null throw the catchable TypeError). */ "dyn.toString": { argTypes: [DYN, DYN, STRING], result: STRING }, + "dyn.valueOf": { argTypes: [DYN, STRING], result: DYN }, /** Object.defineProperties over dyn values (args: target, descriptors — * both borrowed dyn; result: the target, +1 — JS's return value). * Value descriptors become own properties on OBJ and FUNC targets; @@ -549,6 +550,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ /** os.getPriority(pid): validateInt32 (RangeError ERR_OUT_OF_RANGE), * then the priority; a failure throws Node's SystemError. */ "os.getPriority": { argTypes: [F64], result: F64 }, + /** os.setPriority(pid, priority): validateInt32 for both (priority in + * -20..19, RangeError ERR_OUT_OF_RANGE), then the priority; a failure + * throws Node's SystemError. */ + "os.setPriority": { argTypes: [F64, F64], result: VOID }, /** os.cpus(): cpusTake snapshots every CPU and answers the count; * cpusModel(i) and cpusField(i, k) (k: 0 speed, 1 user, 2 nice, 3 sys, * 4 idle, 5 irq) read the snapshot. Never throw. */ @@ -643,6 +648,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "math.sign": { argTypes: [F64], result: F64 }, "math.exp": { argTypes: [F64], result: F64 }, "math.expm1": { argTypes: [F64], result: F64 }, + "math.f16round": { argTypes: [F64], result: F64 }, "math.fround": { argTypes: [F64], result: F64 }, "math.sqrt": { argTypes: [F64], result: F64 }, "math.log": { argTypes: [F64], result: F64 }, @@ -1108,10 +1114,33 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "worker.root": { argTypes: [], result: F64 }, "worker.data": { argTypes: [], result: DYN }, "worker.parentPort": { argTypes: [], result: DYN }, + /** worker_threads.resourceLimits: {} in the main thread; a refusal in a + * worker (scr_worker_events.c). */ + "worker.resourceLimits": { argTypes: [], result: DYN }, + /** worker_threads.threadName: the Worker's name in a worker, "" in the + * main thread (scr_worker.c). */ + "worker.threadName": { argTypes: [], result: DYN }, /** new MessageChannel() ({ port1, port2 }) and receiveMessageOnPort(port) * (scr_worker_events.c; the latter may throw). */ "worker.channel": { argTypes: [], result: DYN }, "worker.receiveMessageOnPort": { argTypes: [DYN], result: DYN }, + /** setEnvironmentData(key, value) and getEnvironmentData(key) + * (scr_worker_events.c; arguments borrowed). */ + "worker.setEnvironmentData": { argTypes: [DYN, DYN], result: VOID }, + "worker.getEnvironmentData": { argTypes: [DYN], result: DYN }, + /** markAsUntransferable(value) and isMarkedAsUntransferable(value) + * (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 }, + /** new BroadcastChannel(...args) (scr_worker_events.c; may throw), after + * event.broadcastInstall, which installs its EventTarget side. */ + "worker.broadcastChannel": { argTypes: [DYN], result: DYN }, + "event.broadcastInstall": { argTypes: [], result: VOID }, /** child_process.spawnSync (scr_child.c): posix_spawn + waitpid + piped * utf8 capture — cmd borrowed, args one borrowed string[] (the frontend * completes an omitted list to an empty literal), result an owned (+1) @@ -1442,6 +1471,32 @@ 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. + // 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 }, + "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 @@ -1819,6 +1874,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "net.serverMaxConnectionsSet": { argTypes: [NETSERVER_T, DYN], result: VOID }, "net.serverDropMaxConnectionGet": { argTypes: [NETSERVER_T], result: DYN }, "net.serverDropMaxConnectionSet": { argTypes: [NETSERVER_T, DYN], result: VOID }, + /** net.SocketAddress and net.BlockList (scr_net.c): the constructors + * take the argument array; the handles are dyn values. */ + "net.socketAddressNew": { argTypes: [DYN], result: DYN }, + "net.socketAddressParse": { argTypes: [DYN], result: DYN }, + "net.isSocketAddress": { argTypes: [DYN], result: BOOL }, + "net.blockListNew": { argTypes: [DYN], result: DYN }, + "net.isBlockList": { argTypes: [DYN], result: BOOL }, + "net.blockListPrivateRanges": { argTypes: [], result: DYN }, "net.serverOnDrop": { argTypes: [NETSERVER_T, DYN, BOOL], result: VOID }, "net.serverGetConnections": { argTypes: [NETSERVER_T, DYN], result: VOID }, "net.sockReadable": { argTypes: [NETSOCKET_T], result: BOOL }, @@ -2834,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 @@ -3150,6 +3216,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "arrayBuffer.viewU8": { argTypes: [DYN, DYN, DYN], result: BYTES_U8 }, "arrayBuffer.viewU32": { argTypes: [DYN, DYN, DYN], result: bytesOf("u32") }, "arrayBuffer.viewI32": { argTypes: [DYN, DYN, DYN], result: bytesOf("i32") }, + "arrayBuffer.viewF16": { argTypes: [DYN, DYN, DYN], result: bytesOf("f16") }, "arrayBuffer.viewF32": { argTypes: [DYN, DYN, DYN], result: bytesOf("f32") }, "arrayBuffer.viewF64": { argTypes: [DYN, DYN, DYN], result: bytesOf("f64") }, "arrayBuffer.viewDV": { argTypes: [DYN, DYN, DYN], result: BYTES_U8 }, @@ -3201,6 +3268,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * `await using`. Both throw Node's TypeErrors or the method's error. */ "dyn.usingMethod": { argTypes: [DYN, BOOL], result: DYN }, "dyn.usingDispose": { argTypes: [DYN, DYN], result: DYN }, + /** DisposableStack (scr_symbol.c): a new stack handle; whether a value is + * one; and `stack.use(value)` for a class instance, registering the + * compiler's disposal function for it. The last throws Node's + * ReferenceError on a disposed stack. */ + "disposable.stackNew": { argTypes: [], result: DYN }, + "disposable.stackIs": { argTypes: [DYN], result: BOOL }, + "disposable.stackUseCallback": { argTypes: [DYN, DYN, DYN], result: BOOL }, "dyn.reflectSet": { argTypes: [DYN, DYN, DYN, DYN], result: BOOL }, "dyn.reflectDefine": { argTypes: [DYN, DYN, DYN], result: BOOL }, "dyn.abstractEq": { argTypes: [DYN, DYN], result: BOOL }, @@ -3733,11 +3807,18 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "insp.jsonDyn": { argTypes: [DYN], result: STRING }, "insp.str": { argTypes: [STRING], result: STRING }, "insp.regex": { argTypes: [REGEX], result: STRING }, + /** A Date's inspect text: toISOString(), or "Invalid Date". */ + "insp.date": { argTypes: [DATE_T], result: STRING }, "insp.buffer": { argTypes: [BYTES_U8], result: STRING }, + /** A numeric typed array that is not a Buffer (any element kind). */ + "insp.typedArray": { argTypes: [null, F64, F64], result: STRING }, "insp.error": { argTypes: [null, F64, F64], result: STRING }, "insp.dyn": { argTypes: [DYN, F64, F64], result: STRING }, "insp.dynS": { argTypes: [DYN, F64], result: STRING }, "insp.begin": { argTypes: [F64], result: VOID }, + /** util.inspect's breakLength, compact (0 for false) and sorted for the + * renders that follow; the call site restores 80, 3 and false afterwards. */ + "insp.layout": { argTypes: [F64, F64, BOOL], result: VOID }, "insp.entry": { argTypes: [STRING, BOOL], result: VOID }, // Circular references: the receiver slot is program-dependent (any // cycle-capable record/array/map/class type — the insp.error precedent). @@ -3776,6 +3857,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "strdec.write": { argTypes: [STRING, F64, BYTES_U8], result: STRING }, "strdec.next": { argTypes: [STRING, F64, BYTES_U8], result: F64 }, "strdec.end": { argTypes: [STRING, F64], result: STRING }, + /** A runtime encoding argument's canonical name (Node's normalizeEncoding: + * nullish and "" are utf8); an unknown name throws ERR_UNKNOWN_ENCODING. */ + "strdec.encoding": { argTypes: [DYN], result: STRING }, // node:readline: the interface handle is f64; the callbacks' func types // are program-dependent (the emitter picks the adapter by parameter // count), so those slots are null like child.onExit's. @@ -3880,6 +3964,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 @@ -3891,6 +3978,15 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * type follows the value promise. */ "tp.setTimeoutValue": { argTypes: [F64, null, BOOL], result: VOID }, "tp.setImmediateValue": { argTypes: [null, BOOL], result: VOID }, + /** The same with options.signal (a checked-dynamic AbortSignal or + * undefined) last: an abort clears the timer and rejects with an + * 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/byte-numbers.ts b/packages/compiler/src/ir/byte-numbers.ts index a6a079af6..89ffaaa98 100644 --- a/packages/compiler/src/ir/byte-numbers.ts +++ b/packages/compiler/src/ir/byte-numbers.ts @@ -25,6 +25,7 @@ const FORMATS: Readonly> = { i16: { width: 2, signed: true, floating: false }, u32: { width: 4, signed: false, floating: false }, i32: { width: 4, signed: true, floating: false }, + f16: { width: 2, signed: false, floating: true }, f32: { width: 4, signed: false, floating: true }, f64: { width: 8, signed: false, floating: true }, u64: { width: 8, signed: false, floating: false }, @@ -38,6 +39,7 @@ const VIEW_FORMATS: Readonly> = { dvGetInt16: "i16", dvGetUint32: "u32", dvGetInt32: "i32", + dvGetFloat16: "f16", dvGetFloat32: "f32", dvGetFloat64: "f64", dvGetBigUint64Number: "u64", @@ -48,6 +50,7 @@ const VIEW_FORMATS: Readonly> = { dvSetInt16: "i16", dvSetUint32: "u32", dvSetInt32: "i32", + dvSetFloat16: "f16", dvSetFloat32: "f32", dvSetFloat64: "f64", }; diff --git a/packages/compiler/src/ir/callback-specialization.test.ts b/packages/compiler/src/ir/callback-specialization.test.ts new file mode 100644 index 000000000..ec0a8d1a3 --- /dev/null +++ b/packages/compiler/src/ir/callback-specialization.test.ts @@ -0,0 +1,214 @@ +import { expect, test } from "vitest"; +import { specializeCallbackCalls } from "./callback-specialization.js"; +import { BOOL, F64, VOID, funcOf, type IrExpr, type IrFunction, type IrModule } from "./ir.js"; +import { everyStmtList } from "./traverse.js"; + +const loc = { file: "callbacks.ts", start: 0, end: 0 }; +const callback = funcOf([F64], BOOL); +const ref = (localId: string, type = callback): IrExpr => ({ kind: "varRef", localId, type, loc }); +const closure = (fnName: string, captures: string[] = []): IrExpr => ({ + kind: "closure", + fnName, + captures, + type: callback, + loc, +}); +const call = (callee: string, args: IrExpr[]): IrExpr => ({ + kind: "call", + callee, + args, + type: BOOL, + loc, +}); +const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc }; + +function predicate(name: string): IrFunction { + return { + name, + params: [{ localId: "n", name: "n", type: F64 }], + locals: [{ id: "n", name: "n", type: F64, mutable: true }], + returnType: BOOL, + body: [{ kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }], + loc, + }; +} +/** `consume(test)` calls `test(1)`; `main` calls it once per target. */ +function fixture(targets: string[]): IrModule { + const consumer: IrFunction = { + name: "consume", + sourceName: "consume", + ownsPrototype: true, + params: [{ localId: "test", name: "test", type: callback }], + locals: [{ id: "test", name: "test", type: callback, mutable: true }], + returnType: BOOL, + body: [ + { + kind: "return", + value: { kind: "callValue", callee: ref("test"), args: [one], type: BOOL, loc }, + loc, + }, + ], + loc, + }; + const entry: IrFunction = { + name: "main", + params: [], + locals: [], + returnType: VOID, + body: targets.map((target) => ({ + kind: "exprStmt" as const, + expr: call("consume", [closure(target)]), + loc, + })), + loc, + }; + return { + irVersion: 16, + sourceFile: loc.file, + entry: "main", + functions: [...new Set(targets)].map(predicate).concat([consumer, entry]), + }; +} +function calleesOf(fn: IrFunction | undefined): string[] { + const out: string[] = []; + everyStmtList(fn?.body ?? [], { + stmt: () => true, + expr: (expr) => { + if (expr.kind === "call") out.push(expr.callee); + if (expr.kind === "closure") out.push(`closure:${expr.fnName}`); + return true; + }, + }); + return out; +} +const named = (mod: IrModule, name: string) => mod.functions.find((fn) => fn.name === name); + +test("each callback target gets its own copy and the unreachable original goes", () => { + const mod = specializeCallbackCalls(fixture(["a", "b", "a"])); + expect(calleesOf(named(mod, "main"))).toEqual([ + "consume.cb0", + "closure:a", + "consume.cb1", + "closure:b", + "consume.cb0", + "closure:a", + ]); + expect(named(mod, "consume")).toBeUndefined(); + const copy = named(mod, "consume.cb0")!; + expect(copy).toMatchObject({ sourceName: "consume", ownsPrototype: true }); + // Copies own their nodes: analyses key facts by node identity. + expect(copy.body[0]).not.toBe(named(mod, "consume.cb1")!.body[0]); + expect(copy.locals[0]).not.toBe(named(mod, "consume.cb1")!.locals[0]); +}); + +test("one target, rebound parameters and oversized bodies keep the module", () => { + const single = fixture(["a", "a"]); + expect(specializeCallbackCalls(single)).toBe(single); + const rebound = fixture(["a", "b"]); + named(rebound, "consume")!.body.unshift({ + kind: "assign", + localId: "test", + value: closure("a"), + loc, + }); + expect(specializeCallbackCalls(rebound)).toBe(rebound); + const large = fixture(["a", "b"]); + const body = named(large, "consume")!.body; + for (let i = 0; i < 200; i++) body.unshift({ kind: "exprStmt", expr: one, loc }); + expect(specializeCallbackCalls(large)).toBe(large); +}); + +test("a function value or an unknown callback keeps the original next to the copies", () => { + const valued = fixture(["a", "b"]); + named(valued, "main")!.body.push({ + kind: "exprStmt", + expr: { ...closure("consume"), type: funcOf([callback], BOOL) }, + loc, + }); + const specialized = specializeCallbackCalls(valued); + expect(named(specialized, "consume")).toBeDefined(); + expect(named(specialized, "consume.cb1")).toBeDefined(); + + const unknown = fixture(["a"]); + const entry = named(unknown, "main")!; + entry.params = [{ localId: "p", name: "p", type: callback }]; + entry.locals = [{ id: "p", name: "p", type: callback, mutable: true }]; + entry.body.push({ kind: "exprStmt", expr: call("consume", [ref("p")]), loc }); + const mixed = specializeCallbackCalls(unknown); + expect(calleesOf(named(mixed, "main"))).toEqual(["consume.cb0", "closure:a", "consume"]); +}); + +test("recursion forwarding the callback stays in its copy", () => { + const mod = fixture(["a", "b"]); + named(mod, "consume")!.body.unshift({ + kind: "exprStmt", + expr: call("consume", [ref("test")]), + loc, + }); + const out = specializeCallbackCalls(mod); + expect(calleesOf(named(out, "consume.cb0"))).toEqual(["consume.cb0"]); + expect(calleesOf(named(out, "consume.cb1"))).toEqual(["consume.cb1"]); + expect(named(out, "consume")).toBeUndefined(); +}); + +test("closures over a specialized callback get copies; recursion never copies per level", () => { + const mod = fixture(["a", "b"]); + const consumer = named(mod, "consume")!; + consumer.locals[0]!.boxed = true; + const inner: IrFunction = { + name: "%inner", + params: [{ localId: "n", name: "n", type: F64 }], + captures: [{ localId: "c", name: "c", type: callback }], + locals: [ + { id: "n", name: "n", type: F64, mutable: true }, + { id: "c", name: "c", type: callback, mutable: true, boxed: true }, + ], + returnType: BOOL, + body: [ + { + kind: "return", + value: { kind: "callValue", callee: ref("c"), args: [ref("n", F64)], type: BOOL, loc }, + loc, + }, + ], + loc, + }; + mod.functions.push(inner); + // consume(test) also recurses with `(n) => test(n)`. + consumer.body.unshift({ + kind: "exprStmt", + expr: call("consume", [closure("%inner", ["test"])]), + loc, + }); + const out = specializeCallbackCalls(mod); + const forA = calleesOf(named(out, "main"))[0]!; + const [recursion, made] = calleesOf(named(out, forA)); + // The original handles the recursion's new closures. + expect(recursion).toBe("consume"); + expect(named(out, "consume")).toBeDefined(); + expect(made).toMatch(/^closure:%inner\.cb\d$/); + const innerCopy = named(out, made!.slice("closure:".length))!; + expect(innerCopy.captures).toEqual(inner.captures); + expect(innerCopy.captures).not.toBe(inner.captures); +}); + +test("copies stay within the per-function budget", () => { + const targets = Array.from({ length: 10 }, (_, i) => `t${i}`); + const out = specializeCallbackCalls(fixture(targets)); + const callees = calleesOf(named(out, "main")).filter((name) => !name.startsWith("closure:")); + expect(callees.filter((name) => name.startsWith("consume.cb"))).toHaveLength(4); + expect(callees.slice(4)).toEqual(Array(6).fill("consume")); +}); + +test("function-keyed contracts extend to the copies", () => { + const mod = fixture(["a", "b"]); + mod.contracts = { noThrow: ["consume"], int32Locals: ["consume\0test", "main\0x"] }; + const out = specializeCallbackCalls(mod); + expect(out.contracts?.noThrow).toEqual(["consume", "consume.cb0", "consume.cb1"]); + expect(out.contracts?.int32Locals).toEqual([ + "consume\0test", + "main\0x", + "consume.cb0\0test", + "consume.cb1\0test", + ]); +}); diff --git a/packages/compiler/src/ir/callback-specialization.ts b/packages/compiler/src/ir/callback-specialization.ts new file mode 100644 index 000000000..00432621d --- /dev/null +++ b/packages/compiler/src/ir/callback-specialization.ts @@ -0,0 +1,477 @@ +import type { IrContracts, IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js"; +import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; +import { reparseExpr, reparseStmt } from "./serialize.js"; +import { everyStmtList, mapExprChildren, mapStmtChildren } from "./traverse.js"; + +/** Largest body (IR nodes) that is copied per callback target. */ +const MAX_FUNCTION_NODES = 300; +/** Copies of one function, its own and those of closures it creates. */ +const MAX_COPIES_PER_FUNCTION = 4; +/** Module-wide copied nodes: at least this many, or a quarter of the module. */ +const MIN_COPIED_NODES = 6000; +const COPIED_NODE_FRACTION = 0.25; + +/** Locals each function writes after declaring them, including bindings a + * closure it creates writes through its environment (and transitively the + * closures those create). A parameter or capture absent from its function's + * set holds the value it entered with for the whole call. */ +export function writtenLocals(mod: IrModule): Map> { + return rebindings(mod).written; +} + +function rebindings(mod: IrModule): { + written: Map>; + /** Bindings of each function that a closure it creates writes. */ + closureWritten: Map>; +} { + const written = new Map>(); + const closureWritten = new Map>(); + const sites: { owner: string; fnName: string; captures: string[] }[] = []; + for (const fn of mod.functions) { + const ids = new Set(); + everyStmtList(fn.body, { + stmt: (stmt) => { + if (stmt.kind === "assign" || stmt.kind === "forOf") ids.add(stmt.localId); + if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) ids.add(stmt.catchLocalId); + return true; + }, + expr: (expr) => { + if (expr.kind === "assignExpr" || expr.kind === "incDec") ids.add(expr.localId); + if (expr.kind === "closure" && expr.captures.length) + sites.push({ owner: fn.name, fnName: expr.fnName, captures: expr.captures }); + return true; + }, + }); + written.set(fn.name, ids); + } + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + for (let changed = true; changed;) { + changed = false; + for (const site of sites) { + const lifted = functions.get(site.fnName)?.captures; + const inner = written.get(site.fnName); + const outer = written.get(site.owner); + if (!lifted || !inner || !outer) continue; + site.captures.forEach((id, index) => { + const capture = lifted[index]; + if (capture && inner.has(capture.localId) && !outer.has(id)) { + outer.add(id); + const set = closureWritten.get(site.owner) ?? new Set(); + set.add(id); + closureWritten.set(site.owner, set); + changed = true; + } + }); + } + } + return { written, closureWritten }; +} + +function countNodes(fn: IrFunction): number { + let count = 0; + everyStmtList(fn.body, { + stmt: () => ++count <= MAX_FUNCTION_NODES, + expr: () => ++count <= MAX_FUNCTION_NODES, + }); + return count; +} + +interface Candidate { + fn: IrFunction; + /** Indexes of callback parameters the body never rebinds. */ + slots: number[]; + nodes: number; +} + +/** Copies of callback-taking helpers per known callback target. + * + * A helper such as `mapInPlace(values, f)` or `sortBy(items, key)` called + * from several sites with different callbacks has one body, so each call + * of `f` inside it stays indirect: the backend's constant-callback analysis + * (backend/llvm/constant-callbacks.ts) needs every incoming call to agree. + * Here each direct call whose callback arguments resolve to known functions + * (a closure literal, a declared function, a single-definition binding, or + * a parameter of an enclosing copy) calls a copy of the helper made for + * exactly that tuple of targets; every incoming call of a copy then agrees, + * and LLVM sees direct calls it can inline. + * + * A copy is the original body verbatim under a new name: the same + * parameters, locals, source name (stack frames), evaluation order, + * exceptions and ownership. Callers still evaluate and pass the callback + * value, and the backend still checks its code pointer before entering the + * known target, so the choice of copy never affects behavior. Inside a + * copy, calls that forward a specialized callback (recursion, helper + * chains) call the copy for the same targets, and closures the copy creates + * over a specialized callback get copies of their own, so a callback + * captured by an inner arrow (`items.reduce((s, x) => s + key(x), 0)`) + * resolves as well. The original stays for every other entry (unknown + * callbacks, function values, exports) and is dropped once nothing can + * reach it. + * + * Code growth is bounded: bodies above MAX_FUNCTION_NODES are never + * copied, each function gets at most MAX_COPIES_PER_FUNCTION copies, and + * the module's copied nodes stay within a fixed share of its size. A helper + * whose direct calls all pass the same targets keeps its single body, which + * the backend already specializes. */ +export function specializeCallbackCalls( + mod: IrModule, + symbolName: (name: string) => string = (name) => name, +): IrModule { + const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); + const { written, closureWritten } = rebindings(mod); + const copies = new Map(); + const candidates = new Map(); + let totalNodes = 0; + for (const fn of mod.functions) { + const nodes = countNodes(fn); + totalNodes += nodes; + if ( + !(fn.ownsPrototype || fn.name.startsWith("%arr.")) || + fn.name === mod.entry || + fn.async || + fn.generator !== undefined || + fn.captures !== undefined || + fn.classCaptures?.length || + fn.fastRead !== undefined || + fn.speculativeDispatch || + nodes > MAX_FUNCTION_NODES + ) + continue; + const rebound = written.get(fn.name)!; + const slots: number[] = []; + fn.params.forEach((param, index) => { + if (param.type.kind === "func" && !rebound.has(param.localId)) slots.push(index); + }); + if (slots.length) candidates.set(fn.name, { fn, slots, nodes }); + } + if (!candidates.size) return mod; + + // Function values, exports, entry points and methods: an original + // reachable this way stays even when every direct call moves to a copy. + const referenced = new Set(moduleFunctionReferences(mod)); + for (const cls of mod.classes ?? []) + for (const method of ["constructor", ...(cls.methods ?? [])]) + referenced.add(`%${cls.name}.${method}`); + // Single definitions of function-typed locals and globals. + const definitions = new Map>(); + const globalIds = new Set(mod.globals?.map((global) => global.id)); + const globalWrites = new Map(); + for (const fn of mod.functions) { + const values = new Map(); + const counts = new Map(); + const record = (id: string, value: IrExpr | null, weight: number): void => { + counts.set(id, (counts.get(id) ?? 0) + weight); + if (value) values.set(id, [...(values.get(id) ?? []), value]); + }; + everyStmtList(fn.body, { + stmt: (stmt) => { + if (stmt.kind === "varDecl") record(stmt.localId, stmt.init, stmt.init ? 1 : 0); + if (stmt.kind === "assign") { + if (globalIds.has(stmt.localId)) + globalWrites.set(stmt.localId, [...(globalWrites.get(stmt.localId) ?? []), stmt.value]); + record(stmt.localId, stmt.value, 1); + } + if (stmt.kind === "forOf") record(stmt.localId, null, 2); + if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) + record(stmt.catchLocalId, null, 2); + return true; + }, + expr: (expr) => { + if (expr.kind === "assignExpr" || expr.kind === "incDec") { + record(expr.localId, null, 2); + if (globalIds.has(expr.localId)) globalWrites.set(expr.localId, []); + } + const name = exprFunctionReference(expr); + if (name !== undefined) referenced.add(name); + return true; + }, + }); + const single = new Map(); + const locals = new Set(fn.locals.map((local) => local.id)); + const shared = closureWritten.get(fn.name); + for (const [id, list] of values) + if ( + locals.has(id) && + counts.get(id) === 1 && + list.length === 1 && + list[0]!.kind === "closure" && + !shared?.has(id) + ) + single.set(id, list[0]!); + definitions.set(fn.name, single); + } + const globalDefinitions = new Map(); + for (const global of mod.globals ?? []) + if (global.type.kind === "func" && !global.mutable) { + const list = globalWrites.get(global.id); + if (list?.length === 1 && list[0]!.kind === "closure") + globalDefinitions.set(global.id, list[0]!); + } + + /** A callback target: a function a closure value is created over. */ + function target(value: IrExpr): string | null { + if (value.kind !== "closure" || value.adapts) return null; + const fn = functions.get(value.fnName) ?? copies.get(value.fnName); + return fn && !fn.async && fn.generator === undefined ? value.fnName : null; + } + function resolve(owner: IrFunction, context: ReadonlyMap, value: IrExpr) { + if (value.kind === "closure") return target(value); + if (value.kind !== "varRef") return null; + const known = context.get(value.localId); + if (known !== undefined) return known; + const local = definitions.get(owner.name)?.get(value.localId); + if (local) return target(local); + const global = globalDefinitions.get(value.localId); + return global && !owner.locals.some((l) => l.id === value.localId) ? target(global) : null; + } + /** Whether a call passes a callback its (copied) caller specializes. */ + function forwardsContext( + context: ReadonlyMap, + call: IrExpr & { kind: "call" }, + candidate: Candidate, + ): boolean { + return candidate.slots.some((index) => { + const arg = call.args[index]; + return arg?.kind === "varRef" && context.has(arg.localId); + }); + } + /** The targets a call passes in its callee's callback slots ("" when + * none resolves), or null for a call that forwards its own callbacks. */ + function callKey( + owner: IrFunction, + context: ReadonlyMap, + call: IrExpr & { kind: "call" }, + candidate: Candidate, + ): string | null { + const self = + context.size === 0 && + owner.name === candidate.fn.name && + candidate.slots.every((index) => { + const arg = call.args[index]; + return arg?.kind === "varRef" && arg.localId === owner.params[index]!.localId; + }); + if (self) return null; + const targets = candidate.slots.map((index) => { + const arg = call.args[index]; + return arg ? resolve(owner, context, arg) : null; + }); + return targets.some((name) => name !== null) + ? targets.map((name) => name ?? "").join("\0") + : ""; + } + + // Which originals specialize their direct calls from ordinary bodies: + // those whose calls pass several target tuples, or one tuple next to + // unknown callbacks or function-value entries. + const keysOf = new Map>(); + const unknown = new Set(); + for (const fn of mod.functions) + everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => { + if (expr.kind !== "call") return true; + const candidate = candidates.get(expr.callee); + if (!candidate) return true; + const key = callKey(fn, new Map(), expr, candidate); + if (key === "") unknown.add(expr.callee); + else if (key !== null) { + const keys = keysOf.get(expr.callee) ?? new Set(); + keys.add(key); + keysOf.set(expr.callee, keys); + } + return true; + }, + }); + const specialized = new Set(); + for (const [name, keys] of keysOf) + if (keys.size > 1 || unknown.has(name) || referenced.has(name)) specialized.add(name); + if (!specialized.size) return mod; + + const names = new Set(mod.functions.map((fn) => symbolName(fn.name))); + const budget = Math.max(MIN_COPIED_NODES, Math.floor(totalNodes * COPIED_NODE_FRACTION)); + let copiedNodes = 0; + const perOrigin = new Map(); + const memo = new Map(); + const pending: { + fn: IrFunction; + source: IrFunction; + context: Map; + lineage: ReadonlySet; + }[] = []; + const sourceOf = new Map(); + /** The copy of `source` for `key`, made on first use. `lineage` names + * the sources of the copies whose bodies led here: a copy never makes a + * new copy of its own source, so recursion that passes a new closure on + * each level (`f(x, (v) => g(v))` inside `f`) calls the original instead + * of copying once per level. */ + function copy( + source: IrFunction, + context: Map, + key: string, + lineage: ReadonlySet, + ): string | null { + const memoKey = `${source.name}\0${key}`; + const existing = memo.get(memoKey); + if (existing !== undefined) return existing; + if (lineage.has(source.name)) return null; + const nodes = candidates.get(source.name)?.nodes ?? countNodes(source); + const count = perOrigin.get(source.name) ?? 0; + if ( + nodes > MAX_FUNCTION_NODES || + count >= MAX_COPIES_PER_FUNCTION || + copiedNodes + nodes > budget + ) + return null; + perOrigin.set(source.name, count + 1); + copiedNodes += nodes; + let name = `${source.name}.cb${count}`; + while (names.has(symbolName(name))) name += "_"; + names.add(symbolName(name)); + memo.set(memoKey, name); + sourceOf.set(name, source.name); + const fn: IrFunction = { + ...source, + name, + params: source.params.map((param) => ({ ...param! })), + locals: source.locals.map((local) => ({ ...local! })), + }; + if (source.captures) fn.captures = source.captures.map((c) => ({ ...c! })); + copies.set(name, fn); + pending.push({ fn, source, context, lineage: new Set([...lineage, source.name]) }); + return name; + } + + /** Rewrite `owner`'s body for a copy specialized to `context` (fresh + * nodes throughout), or in place for the original (empty context). */ + function rewriteBody( + owner: IrFunction, + context: Map, + lineage: ReadonlySet, + fresh: boolean, + ): IrStmt[] { + const ownWritten = written.get(owner.name)!; + const expr = (node: IrExpr): IrExpr => { + // Nodes with children come back copied; a copy re-reads its leaves. + let next = mapExprChildren(node, expr, stmt); + if (fresh && next === node) next = reparseExpr(node); + if (next.kind === "call") { + const candidate = candidates.get(next.callee); + if ( + !candidate || + !(specialized.has(next.callee) || forwardsContext(context, next, candidate)) + ) + return next; + const key = callKey(owner, context, next, candidate); + if (!key) return next; + const targets = key.split("\0"); + const calleeContext = new Map(); + candidate.slots.forEach((index, i) => { + if (targets[i]) calleeContext.set(candidate.fn.params[index]!.localId, targets[i]!); + }); + const name = copy(candidate.fn, calleeContext, key, lineage); + return name ? { ...next, callee: name } : next; + } + if (next.kind === "closure" && context.size && !next.adapts) { + const lifted = functions.get(next.fnName); + if (!lifted?.captures || lifted.async || lifted.generator !== undefined) return next; + const liftedWritten = written.get(lifted.name)!; + const inner = new Map(); + next.captures.forEach((id, index) => { + const known = context.get(id); + const capture = lifted.captures![index]; + if (known && capture && !ownWritten.has(id) && !liftedWritten.has(capture.localId)) + inner.set(capture.localId, known); + }); + if (!inner.size) return next; + const key = [...inner].map(([id, name]) => `${id}=${name}`).join("\0"); + const name = copy(lifted, inner, key, lineage); + return name ? { ...next, fnName: name } : next; + } + return next; + }; + const stmt = (node: IrStmt): IrStmt => { + const next = mapStmtChildren(node, expr, stmt); + return fresh && next === node ? reparseStmt(node) : next; + }; + return owner.body.map(stmt); + } + + const functionsOut = mod.functions.map((fn) => { + // Most bodies call no specialized original; keep their nodes. + const untouched = everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => expr.kind !== "call" || !specialized.has(expr.callee), + }); + return untouched ? fn : { ...fn, body: rewriteBody(fn, new Map(), new Set(), false) }; + }); + for (let i = 0; i < pending.length; i++) { + const { fn, source, context, lineage } = pending[i]!; + fn.body = rewriteBody(source, context, lineage, true); + functionsOut.push(fn); + } + if (!copies.size) return mod; + + // Drop originals no remaining code reaches: every direct call moved to a + // copy and nothing names them as a value. + const callers = new Map>(); + for (const fn of functionsOut) + everyStmtList(fn.body, { + stmt: () => true, + expr: (expr) => { + if (expr.kind === "call" && specialized.has(expr.callee)) { + const set = callers.get(expr.callee) ?? new Set(); + set.add(fn.name); + callers.set(expr.callee, set); + } + if (expr.kind === "closure" && specialized.has(expr.fnName)) referenced.add(expr.fnName); + return true; + }, + }); + const live = new Set(); + const work: string[] = []; + for (const fn of functionsOut) + if (!specialized.has(fn.name) || referenced.has(fn.name)) { + live.add(fn.name); + work.push(fn.name); + } + const calls = new Map(); + for (const [callee, set] of callers) + for (const caller of set) calls.set(caller, [...(calls.get(caller) ?? []), callee]); + while (work.length) { + const name = work.pop()!; + for (const callee of calls.get(name) ?? []) + if (!live.has(callee)) { + live.add(callee); + work.push(callee); + } + } + const out: IrModule = { ...mod, functions: functionsOut.filter((fn) => live.has(fn.name)) }; + if (mod.contracts) out.contracts = copyContracts(mod.contracts, sourceOf); + return out; +} + +/** Contract facts keyed by function name hold for each copy of that body. */ +function copyContracts(contracts: IrContracts, sourceOf: ReadonlyMap): IrContracts { + const sources = new Map(); + for (const [copy, source] of sourceOf) { + const list = sources.get(source); + if (list) list.push(copy); + else sources.set(source, [copy]); + } + const keyed = (list: string[]): string[] => { + const out = [...list]; + for (const entry of list) { + const split = entry.indexOf("\0"); + const fn = split < 0 ? entry : entry.slice(0, split); + for (const copy of sources.get(fn) ?? []) + out.push(split < 0 ? copy : copy + entry.slice(split)); + } + return out; + }; + const out: IrContracts = { ...contracts }; + if (contracts.int32Params) out.int32Params = keyed(contracts.int32Params); + if (contracts.int32Returns) out.int32Returns = keyed(contracts.int32Returns); + if (contracts.int32Locals) out.int32Locals = keyed(contracts.int32Locals); + if (contracts.noThrow) out.noThrow = keyed(contracts.noThrow); + return out; +} diff --git a/packages/compiler/src/ir/function-references.test.ts b/packages/compiler/src/ir/function-references.test.ts index c3a03f046..0b1f9fb49 100644 --- a/packages/compiler/src/ir/function-references.test.ts +++ b/packages/compiler/src/ir/function-references.test.ts @@ -193,6 +193,7 @@ const MODULE_FIELDS: Record>, Role> = { "lib.exports[].fnName": "reference", "lib.exports[].inboundBytesTrap": "other", "lib.exports[].inboundIntTrap": "other", + "lib.exports[].inboundCstringTrap": "other", "lib.trapOverlays[].code": "other", "lib.trapOverlays[].teaching": "other", "lib.trapOverlays[].remediation": "other", @@ -203,6 +204,7 @@ const MODULE_FIELDS: Record>, Role> = { "contracts.int32Params[]": "other", "contracts.int32Returns[]": "fact", "contracts.int32Locals[]": "other", + "contracts.int32Elements[]": "other", "contracts.noThrow[]": "fact", "contracts.acyclic[]": "other", "contracts.immortalGlobals[]": "other", diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 42f1c1d1c..a6a10eaa7 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -12,6 +12,41 @@ import { exprFunctionReference, moduleFunctionReferences } from "./function-refe import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; +/** The storage slot an array-valued expression reads, as an + * IrContracts.int32Elements key: a local or parameter + * (`l:${function}\0${localId}`), a module global (`g:${globalId}`), a class + * field family (`f:${family}`), a record field of every record of its shape + * (`s:${shapeId}\0${field}`) or a direct call's result (`r:${callee}`), + * looking through retypings of the same value. Null for anything else. */ +export function int32ElementSlot( + fnName: string, + e: IrExpr, + family: (className: string, field: string) => string | null, +): string | null { + for (;;) { + switch (e.kind) { + case "upcast": + case "downcast": + case "unionNarrow": + case "unionRetype": + e = e.value; + continue; + case "varRef": + return e.localId.startsWith("%g.") ? `g:${e.localId}` : `l:${fnName}\0${e.localId}`; + case "fieldGet": { + const key = family(e.className, e.field); + return key === null ? null : `f:${key}`; + } + case "recordGet": + return `s:${e.shapeId}\0${e.field}`; + case "call": + return `r:${e.callee}`; + default: + return null; + } + } +} + /** Whole-program int32 specialization of numeric storage. * * A class field typed `number` whose every write provably stores an int32 @@ -66,6 +101,9 @@ export class Int32Slots { private readonly pinned = new Set(); /** Int32 contract locals (`function\0localId`) and globals (`\0id`). */ private readonly int32Locals = new Set(); + /** Number-array slots whose arrays hold only int32 elements (Int32[] + * contracts, IrContracts.int32Elements keys). */ + private readonly int32Elements = new Set(); /** Drops the analysis attempted on pinned slots. The contract check and * this analysis use the same range proofs, so this stays empty. */ readonly pinViolations: string[] = []; @@ -111,7 +149,8 @@ export class Int32Slots { this.returns.size === 0 && this.paramFacts.size === 0 && this.boxes.size === 0 && - this.int32Locals.size === 0 + this.int32Locals.size === 0 && + this.int32Elements.size === 0 ) return {}; const level = (key: string): IntegerRange => @@ -130,6 +169,7 @@ export class Int32Slots { this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`) ? INT32_RANGE : null; + if (this.int32Elements.size > 0) result.elements = (arr) => this.hasInt32Elements(fnName, arr); const params = this.paramFacts.get(fnName); if (params) result.params = @@ -191,6 +231,16 @@ export class Int32Slots { addInt32Local(key: string): void { this.int32Locals.add(key); } + addInt32Elements(key: string): void { + this.int32Elements.add(key); + } + /** Whether every present element of the number array `arr` evaluates to + * (read inside `fnName`) is an int32. */ + hasInt32Elements(fnName: string, arr: IrExpr): boolean { + if (this.int32Elements.size === 0) return false; + const key = int32ElementSlot(fnName, arr, (cls, field) => this.family(cls, field)); + return key !== null && this.int32Elements.has(key); + } private refusePinned(key: string): boolean { if (!this.pinned.has(key)) return false; this.pinViolations.push(key); @@ -286,6 +336,7 @@ const BOUNDARY_KINDS = new Set([ * property, except literal-keyed `dyn.keySet`/`dyn.keyDelete`. */ const DYN_READ_ONLY = new Set([ "arrayBuffer.new", + "arrayBuffer.viewF16", "arrayBuffer.viewF32", "arrayBuffer.viewF64", "arrayBuffer.viewI16", @@ -666,6 +717,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots { slots.addReturn(fnName); } for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key); + for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key); /* ── candidate fields ── */ const children = new Map(); diff --git a/packages/compiler/src/ir/integer-ranges.test.ts b/packages/compiler/src/ir/integer-ranges.test.ts index 70ef75649..e9d1797d1 100644 --- a/packages/compiler/src/ir/integer-ranges.test.ts +++ b/packages/compiler/src/ir/integer-ranges.test.ts @@ -52,6 +52,28 @@ test("tracks exact additions and bitwise conversions through assignments", () => expect(ranges.get(unsigned)).toEqual({ min: 0, max: 4294967295 }); }); +test("element reads of Int32[] arrays that found a value are int32s", () => { + const arrType = { kind: "array", elem: F64 } as const; + const read = (localId: string): IrExpr => ({ + loc, + kind: "arrayGet", + arr: { loc, kind: "varRef", localId, type: arrType }, + index: num(0), + type: F64, + }); + const checked = read("ints"); + const other = read("floats"); + const ranges = analyzeIntegerRanges( + fn([ + { loc, kind: "exprStmt", expr: checked }, + { loc, kind: "exprStmt", expr: other }, + ]), + { elements: (arr) => arr.kind === "varRef" && arr.localId === "ints" }, + ); + expect(ranges.get(checked)).toEqual({ min: -2147483648, max: 2147483647 }); + expect(ranges.get(other) ?? null).toBeNull(); +}); + test("rejects negative zero, fractions, nonfinite values and imprecise sums", () => { for (const value of [-0, 0.5, NaN, Infinity, -Infinity, 9007199254740992]) { const sum = bin("+", num(value), num(0)); diff --git a/packages/compiler/src/ir/integer-ranges.ts b/packages/compiler/src/ir/integer-ranges.ts index 5fe8362fd..5c365e2ca 100644 --- a/packages/compiler/src/ir/integer-ranges.ts +++ b/packages/compiler/src/ir/integer-ranges.ts @@ -210,6 +210,9 @@ export interface IntegerSlotFacts { /** Locals and globals an Int32 contract covers (ir/contract-check.ts): * every write anywhere is proven int32, so every read is one. */ locals?: (localId: string) => IntegerRange | null; + /** Number arrays whose every present element is an int32 (Int32[] + * contracts): an element read that found a value reads an int32. */ + elements?: (arr: IrExpr) => boolean; } /** Structured local dataflow. Calls cannot write uncaptured local slots, @@ -248,7 +251,9 @@ export function analyzeIntegerRanges(fn: IrFunction, slots: IntegerSlotFacts = { ? (slots.field?.(e.className, e.field) ?? null) : e.kind === "call" ? (slots.call?.(e.callee) ?? null) - : null; + : e.kind === "arrayGet" && slots.elements?.(e.arr) === true + ? INT32_RANGE + : null; /** Every write of a local in the given nodes: the written value, or null * for writes whose value is not an ordinary expression (++/--, for-of diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 82bd924a9..9591c684a 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -26,9 +26,19 @@ export interface SrcLoc { /* ── types ─────────────────────────────────────────────────────────────── */ -/** Numeric typed-array element kinds with a native representation. Float16 - * and BigInt typed arrays remain frontend-fenced. */ -export type IrBytesElem = "u8" | "u8c" | "i8" | "u16" | "i16" | "u32" | "i32" | "f32" | "f64"; +/** Numeric typed-array element kinds with a native representation. BigInt + * typed arrays remain frontend-fenced. */ +export type IrBytesElem = + | "u8" + | "u8c" + | "i8" + | "u16" + | "i16" + | "u32" + | "i32" + | "f16" + | "f32" + | "f64"; export const BYTES_ELEMENT_SIZE: Record = { u8: 1, @@ -38,13 +48,14 @@ export const BYTES_ELEMENT_SIZE: Record = { i16: 2, u32: 4, i32: 4, + f16: 2, f32: 4, f64: 8, }; /** Integer element kinds: a valid element read is never NaN. */ export function isIntegerBytesElem(elem: IrBytesElem): boolean { - return elem !== "f32" && elem !== "f64"; + return elem !== "f16" && elem !== "f32" && elem !== "f64"; } export const BYTES_ELEMENT_NAME: Record = { @@ -55,6 +66,7 @@ export const BYTES_ELEMENT_NAME: Record = { i16: "Int16Array", u32: "Uint32Array", i32: "Int32Array", + f16: "Float16Array", f32: "Float32Array", f64: "Float64Array", }; @@ -977,6 +989,14 @@ export interface IrContracts { /** Int32 locals (`${function}\0${localId}`, including every capture of a * captured binding) and module globals (`\0${globalId}`). */ int32Locals?: string[]; + /** Number-array slots whose arrays hold only int32 elements (Int32[]): + * every element store into any array that can reach them was proven an + * int32, so a present element read through them is an int32 (holes and + * missing indices still read as undefined). Keys: `l:${function}\0${localId}` + * (locals and parameters), `g:${globalId}`, `f:${field family}`, + * `s:${shapeId}\0${field}` (record fields) and `r:${function}` (results); + * see int32-slots.ts int32ElementSlot. */ + int32Elements?: string[]; /** Functions that cannot throw (NoThrow). */ noThrow?: string[]; /** Acyclic classes and object types, as cycle-analysis units @@ -1168,8 +1188,19 @@ export interface IrLibExport { /** i64/u64 (ask 4): int64_t/uint64_t at the C edge — inbound values * range-check in the wrapper (`inboundIntTrap`), internal call sites * and returns are compile-time proven before this lands on the IR. */ - params: ("f64" | "bool" | "string" | "bytes" | "u8" | "u32" | "i32" | "i64" | "u64")[]; - returns: "f64" | "bool" | "string" | "bytes" | "void" | "i64" | "u64"; + params: ( + | "f64" + | "bool" + | "string" + | "cstring" + | "bytes" + | "u8" + | "u32" + | "i32" + | "i64" + | "u64" + )[]; + returns: "f64" | "bool" | "string" | "cstring" | "bytes" | "void" | "i64" | "u64"; /** The exact sink-message bytes this wrapper passes to the inbound-bytes * marshalling helper's trap — present exactly when a parameter is * bytes-classed. Already the assembled structured trap-teaching form @@ -1184,6 +1215,10 @@ export interface IrLibExport { * value cannot ride f64 exactly (|v| past 2^53−1). Same assembled * structured form and the same SC4012 code — one host-contract story. */ inboundIntTrap?: string; + /** The sibling message for a NULL cstring parameter: present exactly + * when a parameter is cstring-classed, passed to scr_library_cstr_in. + * Same assembled structured form and the same SC4012 code. */ + inboundCstringTrap?: string; } /** One runtime-trap overlay row: the profile's teaching (replaces the @@ -1210,7 +1245,7 @@ export interface IrLibCallback { name: string; /** The runtime channel slot (profile declaration order). */ slot: number; - params: ("f64" | "bool" | "string" | "bytes" | "u8" | "u32" | "i32")[]; + params: ("f64" | "bool" | "string" | "cstring" | "bytes" | "u8" | "u32" | "i32")[]; returns: "f64" | "bool" | "u8" | "u32" | "i32" | "i64" | "u64" | "pointer" | "void"; /** The unregistered-call trap text (a DETECTED trap: plain bytes the * library funnel classifies SC4025 and assembles with the current @@ -2219,6 +2254,7 @@ export type IrBytesIntrinsicMethod = | "dvGetInt16" | "dvGetUint32" | "dvGetInt32" + | "dvGetFloat16" | "dvGetFloat32" | "dvGetFloat64" | "dvGetBigUint64Number" @@ -2229,6 +2265,7 @@ export type IrBytesIntrinsicMethod = | "dvSetInt16" | "dvSetUint32" | "dvSetInt32" + | "dvSetFloat16" | "dvSetFloat32" | "dvSetFloat64"; @@ -2260,6 +2297,7 @@ export const MAY_THROW_BYTES_METHODS: ReadonlySet = new "dvGetInt16", "dvGetUint32", "dvGetInt32", + "dvGetFloat16", "dvGetFloat32", "dvGetFloat64", "dvGetBigUint64Number", @@ -2270,6 +2308,7 @@ export const MAY_THROW_BYTES_METHODS: ReadonlySet = new "dvSetInt16", "dvSetUint32", "dvSetInt32", + "dvSetFloat16", "dvSetFloat32", "dvSetFloat64", ]); @@ -2525,7 +2564,7 @@ export type IrExpr = } /** A regex literal `/ab+c/gi`. `pattern` is the text between the slashes * exactly as written (escapes UNprocessed — the regex engine parses them), - * `flags` the trailing flags in source order (alphabet fenced to gimsuy by + * `flags` the trailing flags in getter order (alphabet fenced to gimsuvy by * the frontend). Backends intern ONE immortal static per (pattern, flags) * pair — like string literals, so repeated evaluation is free and * `re === re` would hold — and compile the pattern lazily at first use @@ -3548,6 +3587,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 @@ -3565,8 +3609,17 @@ export type IrExpr = * `null` and -0 as `0`, exactly like JS; a record field holding the * undefined arm of its union (an optional field) is DROPPED from the * output — Node's rule for undefined-valued properties. The value is - * BORROWED; the result string is owned (+1). Never throws. */ - | { kind: "jsonStringify"; value: IrExpr; indent?: string; type: IrType; loc: SrcLoc } + * BORROWED; the result string is owned (+1). Never throws. `circular` + * is util.format's %j (Node's tryStringify): a circular structure gives + * the text "[Circular]" instead of the TypeError. */ + | { + kind: "jsonStringify"; + value: IrExpr; + indent?: string; + circular?: true; + type: IrType; + loc: SrcLoc; + } /** The dynamic-boundary check — a CHECKED cast `dynValue as T`: validate * the dyn value's JSON dyn against `type` (a non-dyn, JSON-representable * IR type) and BUILD the typed value (+1), or THROW a catchable @@ -3705,6 +3758,8 @@ function isJsonSafeAt( if (visiting.has(t.shapeId)) return true; visiting.add(t.shapeId); for (const field of shape.fields) { + // Stringification omits a function-valued property of an object. + if (stringify && !shape.tuple && field.type.kind === "func") continue; if ( !isJsonSafeAt( field.type, @@ -4000,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/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 96a449d05..d16edfabd 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, @@ -245,11 +249,15 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run features.legacyTextDecoder = true; if (fn.startsWith("fetch.")) features.fetch = true; if (fn.startsWith("event.") || fn === "events.addAbortListener") features.eventTarget = true; - // Signal-taking readline entry points call addAbortListener. + // Signal-taking readline, stream and timers/promises entry points + // register abort listeners. if ( fn === "rl.createSignal" || fn === "rl.questionAsyncSignal" || fn === "http.clientSignal" || + fn === "tp.setTimeoutSignal" || + fn === "tp.schedulerWaitSignal" || + fn === "tp.setImmediateSignal" || fn === "stream.addAbortSignal" || fn === "stream.addAbortSignalFn" || fn === "sp.finishedOptsSignal" || @@ -285,6 +293,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run fn === "stream.destroy" || fn === "stream.destroyErr" || fn === "stream.prop" || + fn === "stream.workerStdio" || fn === "stream.errored" || fn === "sp.finished" || fn === "sp.pipeline" || @@ -374,7 +383,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run fn.startsWith("sym.") || fn === "util.isDeepStrictEqual" || fn === "dyn.usingMethod" || - fn === "dyn.usingDispose" + fn === "dyn.usingDispose" || + fn.startsWith("disposable.") ) features.symbol = true; if (fn === "process.hrtimeValue") features.bigint = true; @@ -406,6 +416,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; @@ -790,6 +803,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 ce5ccf457..388abf0a7 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -357,7 +357,7 @@ export function validateModule(mod: IrModule): IrValidationError[] { const fits = (cls: string, t: IrType): boolean => cls === "bool" ? t.kind === "bool" - : cls === "string" + : cls === "string" || cls === "cstring" ? t.kind === "string" : cls === "bytes" ? t.kind === "bytes" && t.elem === "u8" @@ -1932,8 +1932,8 @@ function validateFunction( } case "regexLit": { if (e.type.kind !== "regex") err("regexLit must be regex-typed", e.loc); - if (!/^[gimsuy]*$/.test(e.flags)) { - err(`regexLit flags "${e.flags}" outside the supported alphabet (gimsuy)`, e.loc); + if (!/^[gimsuvy]*$/.test(e.flags)) { + err(`regexLit flags "${e.flags}" outside the supported alphabet (gimsuvy)`, e.loc); } if (new Set(e.flags).size !== e.flags.length) { err(`regexLit flags "${e.flags}" contain a duplicate`, e.loc); @@ -4112,8 +4112,16 @@ function validateFunction( } return true; } - if (e.fn === "tp.setTimeoutValue" || e.fn === "tp.setImmediateValue") { - const value = e.args[e.fn === "tp.setTimeoutValue" ? 1 : 0]; + if ( + e.fn === "tp.setTimeoutValue" || + e.fn === "tp.setImmediateValue" || + e.fn === "tp.setTimeoutSignal" || + e.fn === "tp.setImmediateSignal" || + e.fn === "tp.schedulerWaitValue" || + e.fn === "tp.schedulerWaitSignal" + ) { + 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/src/library/ir-policy.ts b/packages/compiler/src/library/ir-policy.ts index 51a5c3b9c..3c3855e5c 100644 --- a/packages/compiler/src/library/ir-policy.ts +++ b/packages/compiler/src/library/ir-policy.ts @@ -66,6 +66,11 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ ["worker.new", "native worker threads"], ["worker.channel", "native worker threads"], ["worker.receiveMessageOnPort", "native worker threads"], + ["worker.setEnvironmentData", "native worker threads"], + ["worker.getEnvironmentData", "native worker threads"], + ["worker.markUntransferable", "native worker threads"], + ["worker.isMarkedUntransferable", "native worker threads"], + ["worker.broadcastChannel", "native worker threads"], ]; /** Value/type kinds whose mere presence means an excluded unit's code (or @@ -191,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/src/library/library-profile.ts b/packages/compiler/src/library/library-profile.ts index f0126c25f..019dbb7e1 100644 --- a/packages/compiler/src/library/library-profile.ts +++ b/packages/compiler/src/library/library-profile.ts @@ -10,7 +10,7 @@ * * Ratified shape (the ratified library-mode design §5.1 + the ABI ratification session): * { - * "profile_format": 1, + * "profile_format": 1 | 2, // 2 adds the cstring class * "name": "", * "entry": "src/lib.ts", // ONE module, profile-relative * "emission": "llvm", // pins the emission; no fallback @@ -268,6 +268,7 @@ export const LIB_PARAM_CLASSES = [ "f64", "bool", "string", + "cstring", "bytes", "u8", "u32", @@ -282,7 +283,16 @@ export const LIB_PARAM_CLASSES = [ * u8/u32/i32 plumbing classes stay param-only: they were ratified as * inbound plumbing, and outbound integers are the proven classes' * business. */ -export const LIB_RETURN_CLASSES = ["f64", "bool", "string", "bytes", "void", "i64", "u64"] as const; +export const LIB_RETURN_CLASSES = [ + "f64", + "bool", + "string", + "cstring", + "bytes", + "void", + "i64", + "u64", +] as const; /** Ask 4's declared integer classes (the prove-or-refuse pair). */ export type LibIntClass = "i64" | "u64"; @@ -297,7 +307,16 @@ export type LibReturnClass = (typeof LIB_RETURN_CLASSES)[number]; * declared integer classes stay export-map surface: an outbound i64/u64 * demands the prove-or-refuse machinery, which is scoped to export * returns today. */ -const LIB_CALLBACK_PARAM_CLASSES = ["f64", "bool", "string", "bytes", "u8", "u32", "i32"] as const; +const LIB_CALLBACK_PARAM_CLASSES = [ + "f64", + "bool", + "string", + "cstring", + "bytes", + "u8", + "u32", + "i32", +] as const; /** Marshalling classes legal in a CALLBACK RETURN position (inbound: host * → compiled code). Scalars only — every conversion to f64 is exact by * construction; a buffer return would need an ownership contract the mode @@ -377,7 +396,7 @@ export interface LibrarySidecarConfig { } export interface LibraryProfile { - profileFormat: 1; + profileFormat: 1 | 2; name: string; /** The profile file that produced this configuration. Semantic * designation errors anchor here, where the offending name is written. */ @@ -516,8 +535,16 @@ export function loadLibraryProfile( } const p = raw as Record; const format = req(p["profile_format"], "profile_format", "number"); - if (format !== 1) - throw new ProfileError(`unsupported profile_format ${format} (this scriptc reads format 1)`); + if (format !== 1 && format !== 2) + throw new ProfileError( + `unsupported profile_format ${format} (this scriptc reads formats 1 and 2)`, + ); + // The cstring class (a NUL-terminated `const char *`) is format 2. + const cstringClass = (cls: string, path: string): void => { + if (cls === "cstring" && format < 2) { + throw new ProfileError(`'${path}' class 'cstring' requires profile_format 2`); + } + }; // Root keys are strict (checked AFTER the format gate, so a future // format's profile refuses as the wrong format, not as a typo). The // ask-5 key names get the pointed message: a `fences` array pasted at @@ -656,6 +683,7 @@ export function loadLibraryProfile( `'${path}.params[${j}]' must be one of ${LIB_PARAM_CLASSES.join("/")}, got ${JSON.stringify(c)}`, ); } + cstringClass(c, `${path}.params[${j}]`); return c as LibParamClass; }); const returns = req(ee["returns"], `${path}.returns`, "string"); @@ -666,6 +694,7 @@ export function loadLibraryProfile( : `'${path}.returns' must be one of ${LIB_RETURN_CLASSES.join("/")}, got '${returns}'`; throw new ProfileError(detail); } + cstringClass(returns, `${path}.returns`); entries.push({ export: exportName, symbol, params, returns: returns as LibReturnClass }); }); @@ -709,12 +738,13 @@ export function loadLibraryProfile( `'${path}.params[${j}]' must be one of ${LIB_CALLBACK_PARAM_CLASSES.join("/")}, got ${JSON.stringify(cls)}`, ); } + cstringClass(cls, `${path}.params[${j}]`); return cls as LibCallbackParamClass; }); const cbReturns = req(cc["returns"], `${path}.returns`, "string"); if (!(LIB_CALLBACK_RETURN_CLASSES as readonly string[]).includes(cbReturns)) { const detail = - cbReturns === "string" || cbReturns === "bytes" + cbReturns === "string" || cbReturns === "cstring" || cbReturns === "bytes" ? `'${path}.returns': a callback return stays scalar — a buffer flowing host → library needs an ownership contract the mode does not define; pass buffers as callback parameters (library → host, borrowed for the call) or entry parameters instead` : `'${path}.returns' must be one of ${LIB_CALLBACK_RETURN_CLASSES.join("/")}, got '${cbReturns}'`; throw new ProfileError(detail); @@ -1010,7 +1040,7 @@ export function loadLibraryProfile( return { ok: true, profile: { - profileFormat: 1, + profileFormat: format as 1 | 2, name, profilePath, entry, diff --git a/packages/compiler/src/library/prepare.ts b/packages/compiler/src/library/prepare.ts index 9c6fe03fc..94f20198a 100644 --- a/packages/compiler/src/library/prepare.ts +++ b/packages/compiler/src/library/prepare.ts @@ -65,6 +65,7 @@ function libClassFits(cls: string, t: IrType): boolean { case "bool": return t.kind === "bool"; case "string": + case "cstring": return t.kind === "string"; case "bytes": return t.kind === "bytes" && t.elem === "u8"; @@ -178,6 +179,18 @@ function resolveLibrarySection( profileRemediation(profile, LIB_INBOUND_BYTES_TRAP_CODE), ); } + if (e.params.includes("cstring")) { + // The host-contract trap for a NULL cstring parameter: a C string + // argument is a pointer to NUL-terminated text, never NULL. Same + // code and assembly-once discipline as the bytes and integer traps. + resolvedExport.inboundCstringTrap = assembleTrapTeaching( + profileTeaching(profile, LIB_INBOUND_BYTES_TRAP_CODE) ?? + "scriptc: library inbound cstring parameter is NULL\n", + LIB_INBOUND_BYTES_TRAP_CODE, + e.symbol, + profileRemediation(profile, LIB_INBOUND_BYTES_TRAP_CODE), + ); + } if (e.params.includes("i64") || e.params.includes("u64")) { // The sibling host-contract trap for inbound declared-integer // parameters (ask 4): a value past ±(2^53−1) cannot ride f64 @@ -524,10 +537,12 @@ export function prepareLibrary( // SC4024 diagnostics, unused channels legal, undeclared references // refused with the callback teaching. The library lane never loads a // native-FFI manifest, so the channel set owns the surface outright. + // A cstring channel parameter is a string on the TypeScript side; the + // channel's own classes (lib.callbacks) drive the marshalling. const cbImports: IrFfiImport[] = profile.callbacks.map((cb) => ({ name: cb.name, symbol: cb.name, - params: [...cb.params], + params: cb.params.map((cls) => (cls === "cstring" ? "string" : cls)), returns: cb.returns, })); const integerSlotRoots: string[] = []; diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 1ce842d26..63e015f98 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -495,6 +495,7 @@ export class NativeCompiler { error.code === "unsupported" ? "SC3002" : "SC3003", error.message, entry, + error.hint, ), ], sourceTexts, @@ -657,6 +658,7 @@ export class NativeCompiler { error.code === "unsupported" ? "SC3002" : "SC3003", error.message, options.profilePath, + error.hint, ), ], sourceTexts, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 735e41281..219095bc0 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "compilerVersion": "0.3.5", + "compilerVersion": "0.3.6", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", @@ -192,7 +192,7 @@ "name": "this regex feature", "status": "unsupported", "code": "SC1120", - "note": "supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \\k), .source/.flags, and string replace/replaceAll/split with string replacement templates" + "note": "supported: literal regexes with the g/i/m/s/u/v/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \\k), .source/.flags, and string replace/replaceAll/split with string replacement templates" }, { "id": "diagnostic.sc2001", @@ -1512,6 +1512,12 @@ "name": "os.release", "status": "static" }, + { + "id": "node-builtin.os.setPriority", + "kind": "node-builtin", + "name": "os.setPriority", + "status": "static" + }, { "id": "node-builtin.os.tmpdir", "kind": "node-builtin", @@ -1938,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", @@ -2221,6 +2233,18 @@ "status": "static", "note": "recognized module (bare and node:-prefixed specifiers)" }, + { + "id": "node-builtin.timers.promises.scheduler.wait", + "kind": "node-builtin", + "name": "timers/promises.scheduler.wait", + "status": "static" + }, + { + "id": "node-builtin.timers.promises.scheduler.yield", + "kind": "node-builtin", + "name": "timers/promises.scheduler.yield", + "status": "static" + }, { "id": "node-builtin.timers.promises.setImmediate", "kind": "node-builtin", @@ -2489,12 +2513,66 @@ "name": "util/types.isAsyncFunction", "status": "static" }, + { + "id": "node-builtin.util.types.isBigInt64Array", + "kind": "node-builtin", + "name": "util/types.isBigInt64Array", + "status": "static" + }, + { + "id": "node-builtin.util.types.isBigIntObject", + "kind": "node-builtin", + "name": "util/types.isBigIntObject", + "status": "static" + }, + { + "id": "node-builtin.util.types.isBigUint64Array", + "kind": "node-builtin", + "name": "util/types.isBigUint64Array", + "status": "static" + }, + { + "id": "node-builtin.util.types.isBooleanObject", + "kind": "node-builtin", + "name": "util/types.isBooleanObject", + "status": "static" + }, + { + "id": "node-builtin.util.types.isBoxedPrimitive", + "kind": "node-builtin", + "name": "util/types.isBoxedPrimitive", + "status": "static" + }, + { + "id": "node-builtin.util.types.isCryptoKey", + "kind": "node-builtin", + "name": "util/types.isCryptoKey", + "status": "static" + }, { "id": "node-builtin.util.types.isDataView", "kind": "node-builtin", "name": "util/types.isDataView", "status": "static" }, + { + "id": "node-builtin.util.types.isDate", + "kind": "node-builtin", + "name": "util/types.isDate", + "status": "static" + }, + { + "id": "node-builtin.util.types.isExternal", + "kind": "node-builtin", + "name": "util/types.isExternal", + "status": "static" + }, + { + "id": "node-builtin.util.types.isFloat16Array", + "kind": "node-builtin", + "name": "util/types.isFloat16Array", + "status": "static" + }, { "id": "node-builtin.util.types.isFloat32Array", "kind": "node-builtin", @@ -2537,18 +2615,36 @@ "name": "util/types.isInt8Array", "status": "static" }, + { + "id": "node-builtin.util.types.isKeyObject", + "kind": "node-builtin", + "name": "util/types.isKeyObject", + "status": "static" + }, { "id": "node-builtin.util.types.isMap", "kind": "node-builtin", "name": "util/types.isMap", "status": "static" }, + { + "id": "node-builtin.util.types.isMapIterator", + "kind": "node-builtin", + "name": "util/types.isMapIterator", + "status": "static" + }, { "id": "node-builtin.util.types.isNativeError", "kind": "node-builtin", "name": "util/types.isNativeError", "status": "static" }, + { + "id": "node-builtin.util.types.isNumberObject", + "kind": "node-builtin", + "name": "util/types.isNumberObject", + "status": "static" + }, { "id": "node-builtin.util.types.isPromise", "kind": "node-builtin", @@ -2573,6 +2669,30 @@ "name": "util/types.isSet", "status": "static" }, + { + "id": "node-builtin.util.types.isSetIterator", + "kind": "node-builtin", + "name": "util/types.isSetIterator", + "status": "static" + }, + { + "id": "node-builtin.util.types.isSharedArrayBuffer", + "kind": "node-builtin", + "name": "util/types.isSharedArrayBuffer", + "status": "static" + }, + { + "id": "node-builtin.util.types.isStringObject", + "kind": "node-builtin", + "name": "util/types.isStringObject", + "status": "static" + }, + { + "id": "node-builtin.util.types.isSymbolObject", + "kind": "node-builtin", + "name": "util/types.isSymbolObject", + "status": "static" + }, { "id": "node-builtin.util.types.isTypedArray", "kind": "node-builtin", @@ -4488,6 +4608,13 @@ "status": "static", "note": "compiles statically at arity 1; other declared call shapes are refused with SC2012" }, + { + "id": "stdlib.math.f16round", + "kind": "stdlib", + "name": "Math.f16round", + "status": "static", + "note": "compiles statically at arity 1; other declared call shapes are refused with SC2012" + }, { "id": "stdlib.math.floor", "kind": "stdlib", diff --git a/packages/compiler/test/await-call-emission.test.ts b/packages/compiler/test/await-call-emission.test.ts index 7e9b457e2..b3867c928 100644 --- a/packages/compiler/test/await-call-emission.test.ts +++ b/packages/compiler/test/await-call-emission.test.ts @@ -28,19 +28,29 @@ function definition(llvm: string, symbol: string): string { return match![0]; } -const source = ` +// Consuming an async generator keeps a body on its fiber: the await-call +// entries serve fiber bodies (stackless bodies suspend at their awaits). +const tick = ` +async function* tick(): AsyncGenerator { + yield 0; +} +`; + +const source = `${tick} const cache = new Map(); async function leaf(x: number): Promise { const hit = cache.get(x); return hit ?? x + 1; } async function middle(x: number): Promise { + for await (const t of tick()) x += t; const y = await leaf(x); return y * 2; } async function main(): Promise { const pending = leaf(1); let sum = await middle(2); + for await (const t of tick()) sum += t; sum += await pending; console.log(sum); } @@ -70,11 +80,11 @@ test("awaiting a direct call to a suspending body spawns and awaits in one call" expect(definition(llvm, "sc_aw_middle")).toContain( "call double @scr_async_spawn_await_f64(ptr @sc_tr_middle, ptr %ap)", ); - // middle awaits, so it keeps its fiber. + // middle consumes an async generator, so it keeps its fiber. expect(definition(llvm, "sc_as_middle")).toContain("@scr_async_spawn("); }); -const adoptSource = ` +const adoptSource = `${tick} async function leaf(x: number): Promise { return x + 1; } @@ -83,12 +93,15 @@ async function slow(x: number): Promise { return x + 1; } async function relay(x: number): Promise { + for await (const t of tick()) x += t; return leaf(x); } async function relaySlow(x: number): Promise { + for await (const t of tick()) x += t; return slow(x); } async function guarded(x: number): Promise { + for await (const t of tick()) x += t; try { return leaf(x); } catch { @@ -132,9 +145,66 @@ test("a returned promise is adopted after finally regions, outside catch handler // adoption turn and the await. const call = guarded.indexOf("call ptr @sc_as_leaf(double"); const log = guarded.indexOf("@scr_console_log", call); - const hop = guarded.indexOf("call void @scr_await_hop()"); + const hop = guarded.indexOf("call void @scr_await_hop()", call); expect(call).toBeGreaterThan(0); expect(log).toBeGreaterThan(call); expect(hop).toBeGreaterThan(log); expect(guarded.indexOf("call double @scr_await_f64(", hop)).toBeGreaterThan(hop); }); + +const stacklessSource = `${tick} +async function leaf(x: number): Promise { + return x + 1; +} +async function slow(x: number): Promise { + await null; + return x + 1; +} +async function middle(x: number): Promise { + const y = await slow(x); + const z = await leaf(y); + return y * 2 + z; +} +async function fibered(): Promise { + let s = 0; + for await (const t of tick()) s += t; + return s + (await middle(1)); +} +async function main(): Promise { + console.log((await middle(2)) + (await fibered())); +} +void main(); +`; + +test("bodies that suspend only at their own awaits compile to stackless coroutines", async () => { + const llvm = await emit(stacklessSource); + const middle = definition(llvm, "sc_f_middle"); + expect(middle).toMatch(/^define internal double @sc_f_middle\(double [^)]*\) #1 /); + // Each await prepares its continuation and suspends the coroutine, then + // extracts the settled result; nothing parks the stack it runs on. + expect(middle).toContain("call zeroext i1 @scr_coro_await_prepare(ptr"); + expect(middle).toContain("call double @scr_coro_await_f64(ptr"); + expect(middle).toContain("call ptr @sc_as_slow(double"); + // A callee that never suspends runs in place; the coroutine takes the + // await's turn itself and awaits a rejected promise only on a throw. + expect(middle).toContain("call double @sc_ai_leaf(double"); + const inline = definition(llvm, "sc_ai_leaf"); + expect(inline).toContain("call double @sc_f_leaf(double %a0)"); + expect(inline).toContain("call void @scr_async_inline_exit(ptr %frame)"); + expect(middle).toContain("call zeroext i1 @scr_coro_hop_prepare(ptr"); + expect(middle).toContain("call void @scr_promise_reject_pending(ptr"); + expect(middle).toContain("call void @scr_coro_async_finish(ptr"); + expect(middle).not.toContain("@sc_aw_"); + expect(middle).not.toContain("@scr_await_f64("); + expect(definition(llvm, "sc_as_middle")).toContain("@scr_async_spawn_stackless("); + expect(definition(llvm, "sc_f_main")).toContain("@scr_coro_await_prepare("); + // A body that keeps its fiber still awaits a stackless callee in one + // call: spawn, then the ordinary await of its promise. + expect(definition(llvm, "sc_as_fibered")).toContain("@scr_async_spawn("); + expect(definition(llvm, "sc_f_fibered")).toContain("call double @sc_aw_middle(double"); + const entry = definition(llvm, "sc_aw_middle"); + expect(entry).toContain("call ptr @sc_as_middle(double %a0)"); + expect(entry).toContain("call double @scr_await_f64(ptr %p)"); + // A body that never suspends stays fiberless. + expect(definition(llvm, "sc_as_leaf")).toContain("@scr_async_inline_enter("); +}); diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index ee099c496..ad47cc2a7 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -302,9 +302,10 @@ test("receiver assignments nested in numeric operands retain the receiver snapsh const ll = emitLlvmModule(mod); - // Two receiver snapshots plus two retains per yielded bytes assignment - // (one for the RHS temp and one for the binding's stored reference). - expect(ll.match(/call ptr @scr_bytes_retain_v/g)).toHaveLength(6); + // Two receiver snapshots plus one retain per yielded bytes assignment for + // the binding's stored reference. The RHS temp takes over the + // replacement's own reference: the assignment is its last use. + expect(ll.match(/call ptr @scr_bytes_retain_v/g)).toHaveLength(4); }); test("canonical byte loops keep their induction variable and indices integral", () => { diff --git a/packages/compiler/test/class-method-mutations.test.ts b/packages/compiler/test/class-method-mutations.test.ts index 37a99dc0e..50c8a2ae2 100644 --- a/packages/compiler/test/class-method-mutations.test.ts +++ b/packages/compiler/test/class-method-mutations.test.ts @@ -55,3 +55,35 @@ console.log(reporter.count); `; expect(await callersOf(relabeled, "%dyn.class.properties")).not.toEqual([]); }); + +const counter = ` +class Counter { + n = 0; + bump(): void { this.n++; } +} +function clear(slots: (Counter | null)[], positions: Map, key: string): void { + const at = positions.get(key); + if (at !== undefined || slots.length > 0) KEY; +} +const counter = new Counter(); +clear([counter], new Map([["a", 0]]), "a"); +counter.bump(); +console.log(counter.n); +`; + +test("an asserted number key leaves method calls direct", async () => { + for (const write of [ + "slots[at as number] = null", + "slots[at!] = null", + "slots[at] = null", + ]) + expect(await callersOf(counter.replace("KEY", write), "%dyn.class.properties")).toEqual([]); +}); + +test("an asserted string key keeps checked dispatch", async () => { + const source = counter.replace( + "KEY", + "(slots as unknown as Record)[String(at) as string] = null", + ); + expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]); +}); diff --git a/packages/compiler/test/class-protocol-boundaries.test.ts b/packages/compiler/test/class-protocol-boundaries.test.ts index 841773f1c..62fcb15f5 100644 --- a/packages/compiler/test/class-protocol-boundaries.test.ts +++ b/packages/compiler/test/class-protocol-boundaries.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { expect, test } from "vitest"; import { compile } from "../src/index.js"; -test("generic protocol dispatch without a concrete body fails during compilation", async () => { +test("generic protocol dispatch without a concrete body calls the erased method", async () => { const directory = mkdtempSync(join(tmpdir(), "scriptc-protocol-boundary-")); try { const entry = join(directory, "main.ts"); @@ -23,17 +23,9 @@ console.log(read(new Value()));`, outPath: join(directory, "program.ir"), outputKind: "ir", }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected generic protocol diagnostic"); - expect(result.diagnostics).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: "SC1090", - message: expect.stringContaining("runtime class must be provable"), - }), - ]), - ); - expect(result.diagnostics.some((diagnostic) => diagnostic.code === "SC9001")).toBe(false); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + expect(result.ok).toBe(true); } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/packages/compiler/test/int32-array-contracts.test.ts b/packages/compiler/test/int32-array-contracts.test.ts new file mode 100644 index 000000000..4e9e60d17 --- /dev/null +++ b/packages/compiler/test/int32-array-contracts.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +const repoRoot = join(import.meta.dirname, "../../.."); + +const source = ` +import type { Int32 } from "@scriptc/types"; +function make(n: number): Int32[] { + const out: Int32[] = []; + for (let i = 0; i < n; i++) out.push((i * 7) & 255); + return out; +} +function sum(a: Int32[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + a[i]) | 0; + return s; +} +function xorAll(a: Int32[]): number { + let h = 0; + for (let i = 0; i < a.length; i++) h = h ^ a[i]; + return h; +} +function plainSum(a: number[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + a[i]) | 0; + return s; +} +function first(a: Int32[]): number { + return a.length > 0 ? a[0] : -1; +} +const xs = make(100); +const floats = [0.5, 1.5]; +console.log(sum(xs), xorAll(xs), plainSum(floats), plainSum([1, 2]), first(xs)); +`; + +const body = (ll: string, name: string): string => + ll.match( + new RegExp( + `define internal [^\\n]+ @sc_(?:b)?f_${name}(?:\\.i32)?\\([^\\n]*\\) #0 \\{ ;([\\s\\S]*?)\\n\\}`, + ), + )![1]!; + +test("Int32[] element reads compute integer kernels in 32 bits", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-arrays-")); + try { + await symlink(join(repoRoot, "node_modules"), join(dir, "node_modules")); + const entry = join(dir, "main.ts"); + const outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const mod = deserializeModule(await readFile(outPath, "utf8")); + const facts = mod.contracts?.int32Elements ?? []; + expect(facts).toContain("l:sum\0a.0"); + expect(facts).toContain("l:xorAll\0a.0"); + expect(facts).toContain("r:make"); + // The unannotated function also receives a float array: no fact. + expect(facts).not.toContain("l:plainSum\0a.0"); + + const ll = emitLlvmModule(mod, { pointerBits: 64, wasi: false }); + // (s + a[i]) | 0 over an Int32[] element: a wrapping i32 add that + // selects 0 when the read found no value (NaN), with no ToInt32 slow + // path on the loop-carried accumulator. + const sum = body(ll, "sum"); + expect(sum).toMatch(/ = add i32 /); + expect(sum).toMatch(/ = fcmp ord double /); + expect(sum).not.toContain("uint32.coerce.slow"); + // h ^ a[i]: the element's ToInt32 is its low 32 bits, or 0 for NaN. + const xor = body(ll, "xorAll"); + expect(xor).toMatch(/ = select i1 %\w+, i32 %\w+, i32 0/); + expect(xor).not.toContain("uint32.coerce.slow"); + // A number[] that may hold fractions keeps the full conversion. + expect(body(ll, "plainSum")).toContain("uint32.coerce.slow"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/js-class-layouts.test.ts b/packages/compiler/test/js-class-layouts.test.ts new file mode 100644 index 000000000..c8491f3e8 --- /dev/null +++ b/packages/compiler/test/js-class-layouts.test.ts @@ -0,0 +1,178 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-js-class-layouts-")); + try { + const entry = join(dir, "main.js"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + const module = deserializeModule(await readFile(output, "utf8")); + expect(validateModule(module)).toEqual([]); + return module; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** Field name → IR kind for a class, and whether it tracks field creation. */ +function layout(module: IrModule, name: string): { fields: string; tracks: boolean } { + const cls = module.classes.find((candidate) => candidate.name === name); + expect(cls, name).toBeDefined(); + return { + fields: cls!.fields + .filter((field) => !field.name.startsWith("%")) + .map((field) => `${field.name}:${field.type.kind}`) + .join(" "), + tracks: cls!.tracksOwnFields === true, + }; +} + +function params(module: IrModule, name: string): string { + const fn = module.functions.find((candidate) => candidate.name === name); + expect(fn, name).toBeDefined(); + return `(${fn!.params.map((param) => param.type.kind).join(", ")}): ${fn!.returnType.kind}`; +} + +function calls(module: IrModule, name: string): string[] { + const fn = module.functions.find((candidate) => candidate.name === name); + expect(fn, name).toBeDefined(); + return [...JSON.stringify(fn!.body).matchAll(/"callee":"([^"]+)"/g)].map((match) => match[1]!); +} + +const SHAPES = ` +class Shape { + constructor() { + this.sides = 0; + this.label = "shape"; + } + reset() { + this.sides = 0; + } +} +class Square extends Shape { + constructor() { + super(); + this.sides = 4; + this.size = 2; + } +} +const shapes = [new Shape(), new Square()]; +for (let i = 0; i < shapes.length; i++) shapes[i].reset(); +console.log(shapes.length); +`; + +test("JavaScript fields created before the instance escapes need no creation tracking", async () => { + const module = await lower(SHAPES); + expect(layout(module, "Shape")).toEqual({ fields: "sides:f64 label:string", tracks: false }); + expect(layout(module, "Square")).toEqual({ + fields: "sides:f64 label:string size:f64", + tracks: false, + }); +}); + +test("JavaScript fields created after the instance escapes keep creation tracking", async () => { + const escaped = await lower(` +class Node { + constructor(list) { + list.push(this); + this.id = list.length; + } + touch() { + this.id++; + } +} +const node = new Node([]); +node.touch(); +console.log(node.id); +`); + expect(layout(escaped, "Node").tracks).toBe(true); + const deleted = await lower(`${SHAPES} +const loose = /** @type {any} */ (shapes[0]); +delete loose.label; +`); + expect(layout(deleted, "Shape").tracks).toBe(true); +}); + +test("JavaScript subclass fields keep their types when the base constructor only assigns fields", async () => { + const module = await lower(` +class Base { + constructor() { + this.ready = true; + this.report(); + } + report() {} +} +class Late extends Base { + constructor() { + super(); + this.count = 1; + } +} +console.log(new Late().count); +${SHAPES}`); + expect(layout(module, "Square").fields).toBe("sides:f64 label:string size:f64"); + expect(layout(module, "Late").fields).toBe("ready:bool count:dyn"); +}); + +test("method calls on possibly missing JavaScript array elements dispatch natively", async () => { + const module = await lower(SHAPES); + expect(calls(module, "%init.0")).toContain("%Shape.reset"); + expect(calls(module, "%init.0").some((callee) => callee.startsWith("%dyn.class.call"))).toBe( + false, + ); +}); + +const ACCOUNTS = ` +class Account { + /** @param {string} owner @param {number} balance */ + constructor(owner, balance) { + this.owner = owner; + this.balance = balance; + } + /** @returns {number} */ + fee() { + return this.balance > 100 ? 0 : 1; + } + close() { + this.balance = 0; + } +} +class Savings extends Account { + /** @param {string} owner @param {number} balance */ + constructor(owner, balance) { + super(owner, balance); + this.rate = 2; + } + /** @returns {number} */ + fee() { + return 0; + } +} +/** @type {Account[]} */ +const accounts = [new Account("a", 1), new Savings("b", 2)]; +let fees = 0; +for (let i = 0; i < accounts.length; i++) { + fees += accounts[i].fee(); + accounts[i].close(); +} +console.log(fees); +`; + +test("a JavaScript file TypeScript checks uses its JSDoc types for class layouts", async () => { + const checked = await lower(`// @ts-check\n${ACCOUNTS}`); + expect(layout(checked, "Account").fields).toBe("owner:string balance:f64"); + expect(params(checked, "%Account.constructor")).toBe("(object, string, f64): void"); + expect(params(checked, "%Savings.fee")).toBe("(object): f64"); + expect(calls(checked, "%init.0").some((callee) => callee.startsWith("%dyn.class."))).toBe(false); + const unchecked = await lower(ACCOUNTS); + expect(layout(unchecked, "Account").fields).toBe("owner:dyn balance:dyn"); + expect(params(unchecked, "%Account.constructor")).toBe("(object, dyn, dyn): void"); +}); diff --git a/packages/compiler/test/json-schema-parse-emission.test.ts b/packages/compiler/test/json-schema-parse-emission.test.ts index 29ccc3721..b96c9a032 100644 --- a/packages/compiler/test/json-schema-parse-emission.test.ts +++ b/packages/compiler/test/json-schema-parse-emission.test.ts @@ -16,12 +16,22 @@ test("JSON.parse casts to plain records and arrays fuse into the schema parser", interface Item { sku: string; qty: number; ok: boolean } interface Order { id: number; items: Item[]; tags: string[] } interface Loose { name: string; note?: string } +interface Meta { source: string } +interface Mixed { id: number; meta: Meta | null; score: number | null; key: string | number } +interface Shape { kind: string; a: number } +interface Other { kind: string; b: string } +interface Either { s: Shape | Other } +interface Pkg { name: string; deps: Record; counts: { [k: string]: number } } const text = process.argv[2] ?? "[]"; const orders = JSON.parse(text) as Order[]; const typed: Item = JSON.parse(text); const loose = JSON.parse(text) as Loose; +const mixed = JSON.parse(text) as Mixed[]; const pair = JSON.parse(text) as [string, number]; -console.log(orders.length, typed.sku, loose.name, pair[0]); +const either = JSON.parse(text) as Either; +const holes = JSON.parse(text) as (string | null)[]; +const pkg = JSON.parse(text) as Pkg; +console.log(orders.length, typed.sku, loose.name, mixed.length, pair[0], either.s.kind, holes.length, pkg.name); `, ); const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); @@ -30,9 +40,11 @@ console.log(orders.length, typed.sku, loose.name, pair[0]); const mod = deserializeModule(await readFile(outPath, "utf8")); const llvm = emitLlvmModule(mod); - // Order[] and Item (an annotated binding) take the fused path; the - // record schema lists fields in declaration order with struct offsets. - expect(llvm.match(/^define internal ptr @sc_jp_\d+(?:\.i32)?\(ptr %text\)/gm)).toHaveLength(2); + // Order[], Item (an annotated binding), the optional and nullable + // members of Loose and Mixed[], and Pkg's index signatures take the + // fused path; the record schema lists fields in declaration order with + // struct offsets. + expect(llvm.match(/^define internal ptr @sc_jp_\d+(?:\.i32)?\(ptr %text\)/gm)).toHaveLength(5); expect(llvm).toContain("call ptr @scr_json_parse_schema(ptr %text, ptr @sc_jsch_"); expect(llvm).toMatch( /@sc_jsch_\d+_fields = internal constant \[3 x \{ ptr, i64, i64, ptr \}\] \[\{ ptr, i64, i64, ptr \} \{ ptr @sc_cs_\d+, i64 3, i64 ptrtoint/, @@ -42,11 +54,31 @@ console.log(orders.length, typed.sku, loose.name, pair[0]); /%d = call ptr @scr_json_parse\(ptr %text\)\n {2}%bad = icmp eq ptr %d, null/, ); expect(llvm).toMatch(/%v = call ptr @sc_dc_\d+\(ptr %d, ptr null\)/); - // Optional fields (unions) and tuples keep the checked-dynamic route. - expect(llvm.match(/call ptr @scr_json_parse\(ptr /g)!.length).toBeGreaterThanOrEqual(4); + // Union members describe their slot representation: the nullable + // record pointer (0), the inline scalar pair (1), and a tagged box built + // through the schema's box function (2), with arm tags by JSON kind. + expect(llvm).toMatch( + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 0, /, + ); + expect(llvm).toMatch( + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 1, /, + ); + expect(llvm).toMatch( + /@sc_jsch_\d+_union = internal constant \{ i64, \[7 x i64\], \[7 x ptr\], ptr, ptr, ptr \} \{ i64 2, .*ptr @sc_jsch_\d+_box \}/, + ); + expect(llvm).toMatch(/^define internal ptr @sc_jsch_\d+_box\(i32 %tag, i64 %bits\)/m); + // A pure dictionary has no declared fields: its index value schema and + // overflow map offset describe every member. + expect(llvm).toMatch( + /= internal constant \{ i64, i64, ptr, ptr, ptr, ptr, ptr, ptr, i64 \} \{ i64 3, i64 0, ptr null, ptr @sc_jsch_\d+, ptr @\S+, ptr null, ptr @\S+, ptr null, i64 ptrtoint/, + ); + // Tuples, two record arms, and unions outside a record member keep the + // checked-dynamic route alone. + expect(llvm.match(/call ptr @scr_json_parse\(ptr /g)!.length).toBeGreaterThanOrEqual(7); const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true }); - expect(wasm).toContain("= internal constant { i32, i32, ptr, ptr, ptr, ptr, ptr }"); + expect(wasm).toContain("= internal constant { i32, i32, ptr, ptr, ptr, ptr, ptr, ptr, i32 }"); + expect(wasm).toContain("= internal constant { i32, [7 x i32], [7 x ptr], ptr, ptr, ptr }"); expect(wasm).toMatch(/\{ ptr, i32, i32, ptr \} \{ ptr @sc_cs_\d+, i32 3, i32 ptrtoint/); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/packages/compiler/test/llvm-remainder.test.ts b/packages/compiler/test/llvm-remainder.test.ts index e86ed4097..9e6cb02e7 100644 --- a/packages/compiler/test/llvm-remainder.test.ts +++ b/packages/compiler/test/llvm-remainder.test.ts @@ -22,11 +22,12 @@ function constants(x: number): number { function powers(x: number): number { return (x % 8) + (x % -(2 ** 32)) + (x % 1) + (x % 0.5); } +function hash(s: number, x: number): number { return (s * 31 + x) % 65536; } function power(x: number, y: number): number { return x ** y; } function signed(x: number): number { return (x | 0) % -7; } function maybeZero(x: number, y: number): number { return (x | 0) % (y | 0); } console.log(remainder(-9.5, 8), constants(17.25), power(2, 3), signed(-7), maybeZero(7, 0)); -console.log(powers(-9.75)); +console.log(powers(-9.75), hash(-3.5, 7)); // Fractional arguments keep the parameters arbitrary (no call-site proof). console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); `, @@ -35,11 +36,13 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const mod = deserializeModule(await readFile(outPath, "utf8")); - const ll = emitLlvmModule(mod); - const fn = (name: string): string => + // A target with a rounding instruction; x86-64 is checked below. + const ll = emitLlvmModule(mod, { targetTriple: "aarch64-unknown-linux-gnu" }); + const fnIn = (module: string, name: string): string => new RegExp(`^define internal [^\\n]*@sc_[bf]+_${name}(?:\\.i32)?\\([^]*?^}`, "m").exec( - ll, + module, )![0]; + const fn = (name: string): string => fnIn(ll, name); // Five source remainders keep a floating fallback: four behind a // runtime integer check and the one by 0.5. ToUint32 coercion // fallbacks use integer arithmetic. @@ -74,6 +77,8 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); powers.match(/ = call double @llvm.copysign.f64\(double 0x0000000000000000, /g), ).toHaveLength(3); expect(powers).toMatch(/ = frem double [^\n]+, 0x3FE0000000000000$/m); + expect(fn("hash")).toMatch(/ = fdiv double [^\n]+, 0x40F0000000000000$/m); + expect(ll).not.toContain("rem.pow2"); // Proven integers with a nonzero divisor need no check or fallback. const signed = fn("signed"); expect(signed).not.toContain("freeze"); @@ -86,6 +91,32 @@ console.log(remainder(5, 2.5), signed(-7.5), maybeZero(7.5, 2.5)); expect(ll).not.toContain("@llvm.fptosi.sat"); expect(ll).not.toContain("@fmod"); expect(ll).toContain("call double @pow("); + + // Where truncation is an integer round trip, a dividend below 2^62 takes + // the i64 remainder by the power of two and adds back its fraction; a + // computed dividend first tries the plain integer remainder, then one + // conditional subtraction below twice the divisor. Larger, infinite and + // NaN dividends keep the double sequence. + const x86 = emitLlvmModule(mod, { targetTriple: "x86_64-unknown-linux-gnu" }); + const x86Powers = fnIn(x86, "powers"); + expect(x86Powers.match(/ = srem i64 [^\n]+, (?:8|4294967296|1)$/gm)).toHaveLength(3); + expect(x86Powers.match(/^rem\.pow2\.frac\d*:/gm)).toHaveLength(3); + expect(x86Powers).not.toContain("rem.pow2.int"); + expect(x86Powers).not.toContain("rem.int"); + expect(x86Powers.match(/ = fdiv double [^\n]+, 0x/g)).toHaveLength(3); + expect(x86Powers).toMatch(/ = fcmp olt double [^\n]+, 0x43D0000000000000$/m); + expect(x86Powers).toMatch(/ = frem double [^\n]+, 0x3FE0000000000000$/m); + const x86Hash = fnIn(x86, "hash"); + expect(x86Hash.match(/^rem\.pow2\.int\d*:/gm)).toHaveLength(1); + expect(x86Hash.match(/ = srem i64 [^\n]+, 65536$/gm)).toHaveLength(2); + // |x| < 2 * 65536 subtracts copysign(65536, x) at most once. + expect(x86Hash.match(/^rem\.pow2\.small\d*:/gm)).toHaveLength(1); + expect(x86Hash.match(/^rem\.pow2\.keep\d*:/gm)).toHaveLength(1); + expect(x86Hash).toMatch(/ = fcmp olt double [^\n]+, 0x4100000000000000$/m); + expect(x86Hash).toMatch(/ = fcmp olt double [^\n]+, 0x40F0000000000000$/m); + expect(x86Hash).toMatch(/ = call double @llvm.copysign.f64\(double 0x40F0000000000000, /); + expect(x86Hash).not.toContain("frem"); + expect(x86Hash).not.toContain("rem.int"); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/compiler/test/native-codegen-integration.test.ts b/packages/compiler/test/native-codegen-integration.test.ts index fb00116cc..1c5f9e3e7 100644 --- a/packages/compiler/test/native-codegen-integration.test.ts +++ b/packages/compiler/test/native-codegen-integration.test.ts @@ -205,6 +205,72 @@ ${[0, 1, 2, 3, 4, 5].map((index) => ` call void @add${index}(i64 ${index + 2})` }); }); + test("WebAssembly emission gives irreducible loops a single entry", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-helper-irreducible-")); + dirs.push(dir); + const input = join(dir, "loops.ll"); + // @irreducible enters its loop at %a or %b; @reducible has one loop entry. + await writeFile( + input, + `declare void @step(i32) + +define i32 @irreducible(i32 %n, i1 %c) { +entry: + br i1 %c, label %a, label %b +a: + %ia = phi i32 [ 0, %entry ], [ %ib1, %b ] + call void @step(i32 %ia) + %ia1 = add i32 %ia, 1 + %da = icmp slt i32 %ia1, %n + br i1 %da, label %b, label %done +b: + %ib = phi i32 [ 0, %entry ], [ %ia1, %a ] + call void @step(i32 %ib) + %ib1 = add i32 %ib, 2 + %db = icmp slt i32 %ib1, %n + br i1 %db, label %a, label %done +done: + %r = phi i32 [ %ia1, %a ], [ %ib1, %b ] + ret i32 %r +} + +define i32 @reducible(i32 %n) { +entry: + br label %loop +loop: + %i = phi i32 [ 0, %entry ], [ %i1, %loop ] + call void @step(i32 %i) + %i1 = add i32 %i, 1 + %d = icmp slt i32 %i1, %n + br i1 %d, label %loop, label %done +done: + ret i32 %i1 +} +`, + ); + const wasm = "wasm32-unknown-wasi"; + const assemblyPath = join(dir, "loops.s"); + const assemblyArgs = helperArgs(input, assemblyPath, wasm); + assemblyArgs[assemblyArgs.indexOf("--filetype") + 1] = "asm"; + expect((await run(helper, assemblyArgs)).exitCode).toBe(0); + const assembly = await readFile(assemblyPath, "utf8"); + const body = (name: string) => + assembly.slice(assembly.indexOf(`\n${name}:\n`)).split("end_function")[0]!; + // The dispatch block that replaces the second loop entry. + expect(body("irreducible")).toMatch(/\bbr_table\b/); + expect(body("reducible")).not.toMatch(/\bbr_table\b/); + + const unoptimized = helperArgs(input, join(dir, "dev.o"), wasm); + unoptimized[unoptimized.indexOf("--opt-level") + 1] = "0"; + expect((await run(helper, unoptimized)).exitCode).toBe(0); + const partitioned = [ + ...helperArgs(input, join(dir, "a.o"), wasm), + "--output", + join(dir, "b.o"), + ]; + expect((await run(helper, partitioned)).exitCode).toBe(0); + }); + test("LLVM fatal diagnostics remain process-isolated JSON", async () => { const result = await execFileAsync(helper, ["version", "--format=json"], { encoding: "utf8", diff --git a/packages/compiler/test/runtime-optional-precision.test.ts b/packages/compiler/test/runtime-optional-precision.test.ts index bf41309c2..7840b0c62 100644 --- a/packages/compiler/test/runtime-optional-precision.test.ts +++ b/packages/compiler/test/runtime-optional-precision.test.ts @@ -221,3 +221,29 @@ console.log(describe(items[4]!)); `); expect(signature(unproven, "describe")).toBe("describe(Item|undefined): string"); }); + +test("a type assertion keeps an unchecked read from widening its callee", async () => { + const module = await lower(` +function hot(n: number): number { + return (n * 2) | 0; +} +function cold(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s + hot(xs[i] as number)) | 0; + return s; +} +function drain(stack: number[]): number { + let s = 0; + while (stack.length > 0) s = (s + hot(stack.pop() as number)) | 0; + return s; +} +function lookup(m: Map): number { + return hot(m.get("a")); +} +console.log(cold([1, 2, 3]), drain([4, 5]), lookup(new Map([["a", 6]]))); +`); + expect(signature(module, "hot")).toBe("hot(f64): f64"); + const llvm = emitLlvmModule(module); + for (const caller of ["cold", "drain", "lookup"]) + expect(body(llvm, caller)).not.toContain("@scr_union_new"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5659-net-blocklist-socketaddress.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5659-net-blocklist-socketaddress.ts.json new file mode 100644 index 000000000..4226140f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5659-net-blocklist-socketaddress.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5659-net-blocklist-socketaddress.ts" + ], + "diags": [] +} 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/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5776-power-of-two-remainder-integer-path.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5776-power-of-two-remainder-integer-path.ts.json new file mode 100644 index 000000000..7c983ed3c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5776-power-of-two-remainder-integer-path.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5776-power-of-two-remainder-integer-path.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5800-broadcast-channel.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5800-broadcast-channel.ts.json new file mode 100644 index 000000000..9858f853a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5800-broadcast-channel.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5800-broadcast-channel.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5801-broadcast-channel.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5801-broadcast-channel.cjs.json new file mode 100644 index 000000000..56a80c6ea --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5801-broadcast-channel.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5801-broadcast-channel.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5802-broadcast-channel-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5802-broadcast-channel-workers.ts.json new file mode 100644 index 000000000..5e3522da6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5802-broadcast-channel-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5802-broadcast-channel-workers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5803-worker-environment-data.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5803-worker-environment-data.ts.json new file mode 100644 index 000000000..397c5afe2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5803-worker-environment-data.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5803-worker-environment-data.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5804-worker-environment-data.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5804-worker-environment-data.cjs.json new file mode 100644 index 000000000..da385edab --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5804-worker-environment-data.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5804-worker-environment-data.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5805-worker-receive-message-parent-port.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5805-worker-receive-message-parent-port.ts.json new file mode 100644 index 000000000..0976b291e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5805-worker-receive-message-parent-port.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5805-worker-receive-message-parent-port.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5806-worker-resource-limits.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5806-worker-resource-limits.ts.json new file mode 100644 index 000000000..2340ef776 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5806-worker-resource-limits.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5806-worker-resource-limits.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5807-worker-error-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5807-worker-error-values.ts.json new file mode 100644 index 000000000..a116df7f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5807-worker-error-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5807-worker-error-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5808-worker-unhandled-rejections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5808-worker-unhandled-rejections.ts.json new file mode 100644 index 000000000..ea84f1308 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5808-worker-unhandled-rejections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5808-worker-unhandled-rejections.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5809-process-exit-and-rejections.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5809-process-exit-and-rejections.cjs.json new file mode 100644 index 000000000..21816332e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5809-process-exit-and-rejections.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5809-process-exit-and-rejections.cjs" + ], + "diags": [] +} 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/corpus/5830-timers-promises-signal.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5830-timers-promises-signal.ts.json new file mode 100644 index 000000000..6122c27d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5830-timers-promises-signal.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5830-timers-promises-signal.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5831-util-types-brands.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5831-util-types-brands.ts.json new file mode 100644 index 000000000..64ba3cdd4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5831-util-types-brands.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5831-util-types-brands.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5832-util-types-brands-dynamic.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5832-util-types-brands-dynamic.js.json new file mode 100644 index 000000000..9ed42a8ce --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5832-util-types-brands-dynamic.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5832-util-types-brands-dynamic.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5833-url-search-params-iterators.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5833-url-search-params-iterators.ts.json new file mode 100644 index 000000000..b1626d547 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5833-url-search-params-iterators.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5833-url-search-params-iterators.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5834-url-search-params-iterators-dynamic.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5834-url-search-params-iterators-dynamic.mjs.json new file mode 100644 index 000000000..3a60dda60 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5834-url-search-params-iterators-dynamic.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5834-url-search-params-iterators-dynamic.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5835-util-format-integer-specifiers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5835-util-format-integer-specifiers.ts.json new file mode 100644 index 000000000..07c8e6c79 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5835-util-format-integer-specifiers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5835-util-format-integer-specifiers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5836-url-search-params-tuple-pairs.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5836-url-search-params-tuple-pairs.ts.json new file mode 100644 index 000000000..20a6b62bf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5836-url-search-params-tuple-pairs.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5836-url-search-params-tuple-pairs.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5837-util-format-json-circular.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5837-util-format-json-circular.ts.json new file mode 100644 index 000000000..55daf6bd8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5837-util-format-json-circular.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5837-util-format-json-circular.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5838-qs-stringify-bigint.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5838-qs-stringify-bigint.ts.json new file mode 100644 index 000000000..0c1b09788 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5838-qs-stringify-bigint.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5838-qs-stringify-bigint.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5839-timers-promises-scheduler.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5839-timers-promises-scheduler.ts.json new file mode 100644 index 000000000..390364540 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5839-timers-promises-scheduler.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5839-timers-promises-scheduler.ts" + ], + "diags": [] +} 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/compiler/test/ts7/baselines/order-parity/tests/corpus/5842-qs-parse-null-prototype.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5842-qs-parse-null-prototype.ts.json new file mode 100644 index 000000000..a5b4a3e21 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5842-qs-parse-null-prototype.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5842-qs-parse-null-prototype.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5843-qs-parse-null-prototype-js.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5843-qs-parse-null-prototype-js.mjs.json new file mode 100644 index 000000000..228f0a4c0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5843-qs-parse-null-prototype-js.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5843-qs-parse-null-prototype-js.mjs" + ], + "diags": [] +} 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/compiler/test/ts7/baselines/order-parity/tests/corpus/5856-error-is-error.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5856-error-is-error.ts.json new file mode 100644 index 000000000..1fc797e25 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5856-error-is-error.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5856-error-is-error.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5857-regexp-unicode-sets.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5857-regexp-unicode-sets.ts.json new file mode 100644 index 000000000..20742aa5a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5857-regexp-unicode-sets.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5857-regexp-unicode-sets.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5858-disposable-stack.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5858-disposable-stack.ts.json new file mode 100644 index 000000000..316261387 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5858-disposable-stack.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5858-disposable-stack.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5859-disposable-stack-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5859-disposable-stack-js.js.json new file mode 100644 index 000000000..f35375f63 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5859-disposable-stack-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5859-disposable-stack-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5870-worker-thread-names.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5870-worker-thread-names.ts.json new file mode 100644 index 000000000..2c16d66db --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5870-worker-thread-names.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5870-worker-thread-names.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5871-worker-untransferable.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5871-worker-untransferable.cjs.json new file mode 100644 index 000000000..df294877c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5871-worker-untransferable.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5871-worker-untransferable.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5874-worker-stdio-streams.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5874-worker-stdio-streams.ts.json new file mode 100644 index 000000000..12434a757 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5874-worker-stdio-streams.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5874-worker-stdio-streams.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5875-worker-stdout-pipe.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5875-worker-stdout-pipe.cjs.json new file mode 100644 index 000000000..64115b746 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5875-worker-stdout-pipe.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5875-worker-stdout-pipe.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5879-worker-stdin.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5879-worker-stdin.ts.json new file mode 100644 index 000000000..9b7dbdcd7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5879-worker-stdin.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5879-worker-stdin.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5880-emitter-leak-warning-delivery.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5880-emitter-leak-warning-delivery.ts.json new file mode 100644 index 000000000..597be34db --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5880-emitter-leak-warning-delivery.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5880-emitter-leak-warning-delivery.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5881-inspect-weak-collections.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5881-inspect-weak-collections.ts.json new file mode 100644 index 000000000..342179cde --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5881-inspect-weak-collections.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5881-inspect-weak-collections.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5882-string-decoder-runtime-encoding.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5882-string-decoder-runtime-encoding.ts.json new file mode 100644 index 000000000..29b8b2759 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5882-string-decoder-runtime-encoding.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5882-string-decoder-runtime-encoding.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5883-os-set-priority.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5883-os-set-priority.ts.json new file mode 100644 index 000000000..5be1d2b3c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5883-os-set-priority.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5883-os-set-priority.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5884-inspect-layout-options.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5884-inspect-layout-options.ts.json new file mode 100644 index 000000000..17935d5a4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5884-inspect-layout-options.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5884-inspect-layout-options.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5885-events-max-listener-helpers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5885-events-max-listener-helpers.ts.json new file mode 100644 index 000000000..b453b4ae1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5885-events-max-listener-helpers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5885-events-max-listener-helpers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5886-inspect-dates.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5886-inspect-dates.ts.json new file mode 100644 index 000000000..65525fb9b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5886-inspect-dates.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5886-inspect-dates.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5887-inspect-typed-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5887-inspect-typed-arrays.ts.json new file mode 100644 index 000000000..7c49ebfaa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5887-inspect-typed-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5887-inspect-typed-arrays.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5888-inspect-sorted.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5888-inspect-sorted.ts.json new file mode 100644 index 000000000..5e0eedde2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5888-inspect-sorted.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5888-inspect-sorted.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5889-timers-scheduler-signal.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5889-timers-scheduler-signal.ts.json new file mode 100644 index 000000000..305cbf655 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5889-timers-scheduler-signal.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5889-timers-scheduler-signal.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5892-buffer-write-utf16le-runs.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5892-buffer-write-utf16le-runs.ts.json new file mode 100644 index 000000000..012edf8c1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5892-buffer-write-utf16le-runs.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5892-buffer-write-utf16le-runs.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5900-float16-array.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5900-float16-array.ts.json new file mode 100644 index 000000000..47cf8723f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5900-float16-array.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5900-float16-array.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5901-float16-array-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5901-float16-array-js.js.json new file mode 100644 index 000000000..148849dca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5901-float16-array-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5901-float16-array-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json new file mode 100644 index 000000000..dd2338a5b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5904-async-generator-completion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5904-async-generator-completion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json new file mode 100644 index 000000000..dd9276926 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5905-array-from-async-generator.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5905-array-from-async-generator.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json new file mode 100644 index 000000000..fc7977463 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5906-array-from-async-generator-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5906-array-from-async-generator-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts.json new file mode 100644 index 000000000..34c0d8c67 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5911-dyn-class-to-primitive.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5911-dyn-class-to-primitive.ts.json new file mode 100644 index 000000000..0f22fd406 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5911-dyn-class-to-primitive.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5911-dyn-class-to-primitive.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5912-dyn-class-conversion-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5912-dyn-class-conversion-methods.ts.json new file mode 100644 index 000000000..60cf8d9d5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5912-dyn-class-conversion-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5912-dyn-class-conversion-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5913-prototype-type-error-messages.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5913-prototype-type-error-messages.ts.json new file mode 100644 index 000000000..f7e3d10b9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5913-prototype-type-error-messages.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5913-prototype-type-error-messages.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5914-dyn-in-class-prototype-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5914-dyn-in-class-prototype-members.ts.json new file mode 100644 index 000000000..4df0c23de --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5914-dyn-in-class-prototype-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5914-dyn-in-class-prototype-members.ts" + ], + "diags": [] +} 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/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json new file mode 100644 index 000000000..b74ce6d45 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5920-promise-try-args.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5920-promise-try-args.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json new file mode 100644 index 000000000..085d6415c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5921-promise-try-args-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5921-promise-try-args-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5922-matchall-groups-callbacks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5922-matchall-groups-callbacks.ts.json new file mode 100644 index 000000000..2dfdd41eb --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5922-matchall-groups-callbacks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5922-matchall-groups-callbacks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5923-matchall-groups-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5923-matchall-groups-js.js.json new file mode 100644 index 000000000..591c14d12 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5923-matchall-groups-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5923-matchall-groups-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5924-js-literal-identity.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5924-js-literal-identity.js.json new file mode 100644 index 000000000..476981e3e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5924-js-literal-identity.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5924-js-literal-identity.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json new file mode 100644 index 000000000..5ea05b587 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5927-switch-object-identity.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5927-switch-object-identity.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5928-groupby-iterables.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5928-groupby-iterables.ts.json new file mode 100644 index 000000000..9f9cbf02c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5928-groupby-iterables.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5928-groupby-iterables.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5929-groupby-js.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5929-groupby-js.js.json new file mode 100644 index 000000000..770b2a8f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5929-groupby-js.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5929-groupby-js.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-contract-int32-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-contract-int32-arrays.ts.json new file mode 100644 index 000000000..d1d2cb7ea --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-contract-int32-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5930-contract-int32-arrays.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-method-census-asserted-number-keys.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-method-census-asserted-number-keys.ts.json new file mode 100644 index 000000000..6e5a5461b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5930-method-census-asserted-number-keys.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5930-method-census-asserted-number-keys.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json new file mode 100644 index 000000000..f2cfa7309 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5931-asserted-reads-keep-number-parameters.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5931-asserted-reads-keep-number-parameters.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-generic-class-observed-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-generic-class-observed-methods.ts.json new file mode 100644 index 000000000..e3a78d9d8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5932-generic-class-observed-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5932-generic-class-observed-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json new file mode 100644 index 000000000..5cb517618 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5933-class-fields-unknown-object.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5933-class-fields-unknown-object.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-js-class-fields-property-order.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-js-class-fields-property-order.js.json new file mode 100644 index 000000000..b35536392 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5934-js-class-fields-property-order.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5934-js-class-fields-property-order.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5935-js-subclass-fields-and-element-calls.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5935-js-subclass-fields-and-element-calls.js.json new file mode 100644 index 000000000..85aee8915 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5935-js-subclass-fields-and-element-calls.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5935-js-subclass-fields-and-element-calls.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5936-checked-js-class-types.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5936-checked-js-class-types.js.json new file mode 100644 index 000000000..e0bd1c465 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5936-checked-js-class-types.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5936-checked-js-class-types.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5937-override-narrowed-union-returns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5937-override-narrowed-union-returns.ts.json new file mode 100644 index 000000000..c03446be3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5937-override-narrowed-union-returns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5937-override-narrowed-union-returns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5939-js-let-object-reassigned-wider.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5939-js-let-object-reassigned-wider.js.json new file mode 100644 index 000000000..b783afe3f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5939-js-let-object-reassigned-wider.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5939-js-let-object-reassigned-wider.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5940-callback-helper-specialization.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5940-callback-helper-specialization.ts.json new file mode 100644 index 000000000..ab70e2183 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5940-callback-helper-specialization.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5940-callback-helper-specialization.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5941-callback-specialization-recursion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5941-callback-specialization-recursion.ts.json new file mode 100644 index 000000000..c0f902430 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5941-callback-specialization-recursion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5941-callback-specialization-recursion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-callback-specialization-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-callback-specialization-throws.ts.json new file mode 100644 index 000000000..210ebe8ca --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-callback-specialization-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5942-callback-specialization-throws.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-local-class-observed-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-local-class-observed-methods.ts.json new file mode 100644 index 000000000..89502dd27 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5942-local-class-observed-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5942-local-class-observed-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json new file mode 100644 index 000000000..52c08dd09 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5950-tuple-predicate-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5950-tuple-predicate-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json new file mode 100644 index 000000000..2c81316cf --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5951-result-never-widening.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5951-result-never-widening.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5955-any-promise-methods.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5955-any-promise-methods.js.json new file mode 100644 index 000000000..cae7d2625 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5955-any-promise-methods.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5955-any-promise-methods.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5956-erased-generic-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5956-erased-generic-members.ts.json new file mode 100644 index 000000000..dac71caf5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5956-erased-generic-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5956-erased-generic-members.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json new file mode 100644 index 000000000..d294caa65 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5958-asserted-union-receiver.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5958-asserted-union-receiver.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5959-date-to-json.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5959-date-to-json.ts.json new file mode 100644 index 000000000..935958e1d --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5959-date-to-json.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5959-date-to-json.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-generic-mapped-types.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-generic-mapped-types.ts.json new file mode 100644 index 000000000..a95837f80 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5960-generic-mapped-types.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5960-generic-mapped-types.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5970-net-connect-host-name.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5970-net-connect-host-name.ts.json new file mode 100644 index 000000000..8e6e036ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5970-net-connect-host-name.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5970-net-connect-host-name.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5971-buffer-chunks-to-string.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5971-buffer-chunks-to-string.ts.json new file mode 100644 index 000000000..e769f41d9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5971-buffer-chunks-to-string.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5971-buffer-chunks-to-string.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json new file mode 100644 index 000000000..efab76852 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5972-net-set-encoding-listeners.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5972-net-set-encoding-listeners.ts" + ], + "diags": [] +} 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/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/compiler/test/ts7/baselines/order-parity/tests/corpus/5992-worker-thread-name-lifetime.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5992-worker-thread-name-lifetime.mjs.json new file mode 100644 index 000000000..20ef55142 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/5992-worker-thread-name-lifetime.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/5992-worker-thread-name-lifetime.mjs" + ], + "diags": [] +} 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/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-deque-unshift.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-deque-unshift.ts.json new file mode 100644 index 000000000..11873f605 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-deque-unshift.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/array-deque-unshift.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-queue-head-room.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-queue-head-room.ts.json new file mode 100644 index 000000000..d1ad5a71b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-queue-head-room.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/array-queue-head-room.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-index-signatures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-index-signatures.ts.json new file mode 100644 index 000000000..000355ad4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-index-signatures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-typed-parse-index-signatures.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-optional-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-optional-members.ts.json new file mode 100644 index 000000000..6e710af51 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-optional-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-typed-parse-optional-members.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-union-members.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-union-members.ts.json new file mode 100644 index 000000000..c159872f3 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/json-typed-parse-union-members.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/json-typed-parse-union-members.ts" + ], + "diags": [] +} 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/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-last-use-moves.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-last-use-moves.ts.json new file mode 100644 index 000000000..695f90cae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-last-use-moves.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/rc-last-use-moves.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-private-array-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-private-array-writes.ts.json new file mode 100644 index 000000000..3fde008f8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/rc-private-array-writes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/rc-private-array-writes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-concurrency.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-concurrency.ts.json new file mode 100644 index 000000000..bf220882b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-concurrency.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stackless-async-concurrency.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-context.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-context.ts.json new file mode 100644 index 000000000..781445748 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-context.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stackless-async-context.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-exceptions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-exceptions.ts.json new file mode 100644 index 000000000..751ebd1da --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-exceptions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stackless-async-exceptions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-ordering.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-ordering.ts.json new file mode 100644 index 000000000..378f9031f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-ordering.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stackless-async-ordering.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-unhandled.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-unhandled.ts.json new file mode 100644 index 000000000..c6b2325d4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stackless-async-unhandled.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/stackless-async-unhandled.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/checked-js-constructor-arguments.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/checked-js-constructor-arguments.js.json new file mode 100644 index 000000000..97cff5810 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/checked-js-constructor-arguments.js.json @@ -0,0 +1,16 @@ +{ + "order": [ + "/tests/diagnostics/checked-js-constructor-arguments.js" + ], + "diags": [ + { + "code": "SC0001", + "message": "Argument of type 'string' is not assignable to parameter of type 'number'.", + "loc": { + "file": "/tests/diagnostics/checked-js-constructor-arguments.js", + "start": 272, + "end": 276 + } + } + ] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-array-aliases.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-array-aliases.ts.json new file mode 100644 index 000000000..350df38ff --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-array-aliases.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/contract-int32-array-aliases.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-arrays.ts.json new file mode 100644 index 000000000..df19994cc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/contract-int32-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/contract-int32-arrays.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/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json new file mode 100644 index 000000000..52af990e9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/npm-program-type-error/main.ts.json @@ -0,0 +1,25 @@ +{ + "order": [ + "/tests/diagnostics/npm-program-type-error/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Module '\"sqlish\"' has no exported member 'open'.", + "loc": { + "file": "/tests/diagnostics/npm-program-type-error/main.ts", + "start": 138, + "end": 142 + } + }, + { + "code": "SC0001", + "message": "Expected 2 arguments, but got 1.", + "loc": { + "file": "/tests/diagnostics/npm-program-type-error/main.ts", + "start": 239, + "end": 244 + } + } + ] +} 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/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/net-blocklist.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/net-blocklist.ts.json new file mode 100644 index 000000000..d85503861 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/net-blocklist.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/net-blocklist.ts" + ], + "diags": [] +} diff --git a/packages/experimental/package.json b/packages/experimental/package.json index 0922fe551..114650325 100644 --- a/packages/experimental/package.json +++ b/packages/experimental/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/experimental", - "version": "0.3.5", + "version": "0.3.6", "description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects and intern tables shared between threads", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index 46b691fd6..28865dc35 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index 279fcc339..8d03e25cb 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.3.5", + "version": "0.3.6", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 8d642cd7b..a6782dce1 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index 61cd8d5d9..c4ac49e9b 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index 359bd4193..6136a8e07 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index 3857d6950..02d994cdd 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index 65a25e617..fe1329515 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.3.5", + "version": "0.3.6", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index 0d9069ad5..155c6ce87 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index a14a02e6d..a36cd273b 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index b0078d837..f81f21139 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index 7511b7cdd..c76226c6f 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 0e9d10738..ec916922e 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 77e39e31e..bb972ebbe 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 7b1fd1efe..69110991a 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 593aac626..ceb7b975f 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index e991783a1..21f83ccee 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index ce50b8e5e..fa073ecf2 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 30c5ac857..4f6017b29 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.3.5", + "version": "0.3.6", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 50d81790a..0b9559931 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.3.5", + "version": "0.3.6", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index eaf391a9e..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 @@ -1152,6 +1611,92 @@ void scr_mem_prefault(void *lo, void *hi) { #endif } +/* Free order. Free lists are LIFO, so blocks freed in descending address + * order come back in ascending order, the order the bump pointers carve + * fresh memory in, and the order the program then touches them in. The + * order within a page matters as much as across pages: blocks of a page + * handed out in no particular order defeat the hardware prefetcher. + * + * The pointers are marked in a bitmap of block indices per class, over the + * range of blocks they span, and read back from the highest block down. + * That takes one bit per block in that range, at most one per block in + * use, and reading the bitmaps back costs a step per word; a set spread so + * thinly that they would take more than SCR_SA_ORDER_SPARSE words per + * pointer is left as it is. Pointers outside the reservation, not at + * `offset` in a block, or repeated, come first, in the order they had. */ +#define SCR_SA_ORDER_SPARSE 8 + +#if SCR_SMALL_ALLOC +/* The class and block index of a pointer `off` bytes past the reservation + * base plus the block offset, if it is at that offset in a block. Slices + * span at most 1 << 30 bytes, so an offset within one fits in 32 bits. */ +static inline bool scr_sa_order_block(uintptr_t off, uintptr_t span, unsigned shift, + const uint32_t *size, unsigned *c, uint32_t *idx) { + if (off >= span) return false; + *c = (unsigned)(off >> shift); + uint32_t in = (uint32_t)(off & (((uintptr_t)1 << shift) - 1)); + *idx = in / size[*c]; + return in == *idx * size[*c]; +} +#endif + +void scr_mem_order_for_free(void **ptrs, size_t n, size_t offset) { +#if SCR_SMALL_ALLOC + if (n < 2 || !scr_sa.span) return; + uintptr_t base = scr_sa.base + offset, span = scr_sa.span; + unsigned shift = scr_sa.shift, c; + uint32_t idx; + uint32_t size[SCR_SA_NCLASS], lo[SCR_SA_NCLASS], hi[SCR_SA_NCLASS]; + size_t at[SCR_SA_NCLASS], words = 0; + for (c = 0; c < SCR_SA_NCLASS; c++) { + size[c] = (uint32_t)scr_sa_class_size(c); + lo[c] = UINT32_MAX; + hi[c] = 0; + } + for (size_t i = 0; i < n; i++) { + if (!scr_sa_order_block((uintptr_t)ptrs[i] - base, span, shift, size, &c, &idx)) continue; + if (idx < lo[c]) lo[c] = idx; + if (idx > hi[c]) hi[c] = idx; + } + for (c = 0; c < SCR_SA_NCLASS; c++) { + at[c] = words; + if (lo[c] <= hi[c]) words += hi[c] / 64 - lo[c] / 64 + 1; + } + if (words == 0 || words / SCR_SA_ORDER_SPARSE > n) return; + uint64_t *bits = calloc(words, sizeof *bits); + if (!bits) return; /* the order only affects performance */ + size_t k = 0; + for (size_t i = 0; i < n; i++) { + void *p = ptrs[i]; + if (scr_sa_order_block((uintptr_t)p - base, span, shift, size, &c, &idx)) { + uint64_t *word = &bits[at[c] + idx / 64 - lo[c] / 64], bit = (uint64_t)1 << (idx % 64); + if (!(*word & bit)) { + *word |= bit; + continue; + } + } + ptrs[k++] = p; + } + for (c = 0; c < SCR_SA_NCLASS; c++) { + if (lo[c] > hi[c]) continue; + char *first = (char *)base + ((uintptr_t)c << shift) + (size_t)(lo[c] / 64) * 64 * size[c]; + for (size_t w = hi[c] / 64 - lo[c] / 64 + 1; w-- > 0;) { + uint64_t word = bits[at[c] + w]; + while (word) { + unsigned b = 63 - (unsigned)__builtin_clzll(word); + word &= ~((uint64_t)1 << b); + ptrs[k++] = first + (w * 64 + b) * size[c]; + } + } + } + free(bits); +#else + (void)ptrs; + (void)n; + (void)offset; +#endif +} + #if defined(__SANITIZE_ADDRESS__) #define SCR_HEAP_SANITIZER 1 #elif defined(__has_feature) diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index 26300296e..f0efdc935 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -119,6 +119,62 @@ static void check_churn(void) { } } +/* scr_mem_order_for_free keeps every pointer, puts system blocks first and + * each class's blocks in descending address order, and freeing in that + * order hands each class's blocks back in ascending address order. Builds + * without the size-class allocator leave the array as it is. */ +static int compare_pointers(const void *a, const void *b) { + uintptr_t x = (uintptr_t)*(void *const *)a, y = (uintptr_t)*(void *const *)b; + return x < y ? -1 : x > y; +} + +static void check_free_order(void) { + enum { BLOCKS = 12000, SYSTEM = 64, N = BLOCKS + SYSTEM }; + static void *ptrs[N], *before[N], *a[N], *b[N]; + const size_t sizes[2] = {32, 96}; + for (size_t i = 0; i < BLOCKS; i++) ptrs[i] = scr_mem_alloc(sizes[i % 2]); + for (size_t i = BLOCKS; i < N; i++) ptrs[i] = malloc(1024); + uint32_t x = 0x2545f491u; + for (size_t i = N - 1; i > 0; i--) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + size_t j = x % (i + 1); + void *t = ptrs[i]; + ptrs[i] = ptrs[j]; + ptrs[j] = t; + } + memcpy(before, ptrs, sizeof ptrs); + scr_mem_order_for_free(ptrs, N, 0); + memcpy(a, ptrs, sizeof ptrs); + memcpy(b, before, sizeof before); + qsort(a, N, sizeof *a, compare_pointers); + qsort(b, N, sizeof *b, compare_pointers); + assert(memcmp(a, b, sizeof a) == 0); +#if SCR_SMALL_ALLOC + size_t i = 0; + uintptr_t last[SCR_SA_NCLASS] = {0}; + for (; i < SYSTEM; i++) assert(!owned(ptrs[i])); + for (; i < N; i++) { + assert(owned(ptrs[i])); + unsigned c = (unsigned)(((uintptr_t)ptrs[i] - scr_sa.base) >> scr_sa.shift); + assert(!last[c] || (uintptr_t)ptrs[i] < last[c]); + last[c] = (uintptr_t)ptrs[i]; + } + for (i = 0; i < N; i++) scr_mem_free(ptrs[i]); + for (size_t c = 0; c < 2; c++) { + for (size_t k = 0; k < BLOCKS / 2; k++) { + a[k] = scr_mem_alloc(sizes[c]); + if (k) assert(a[k] > a[k - 1]); + } + for (size_t k = 0; k < BLOCKS / 2; k++) scr_mem_free(a[k]); + } +#else + assert(memcmp(ptrs, before, sizeof ptrs) == 0); + for (size_t i = 0; i < N; i++) scr_mem_free(ptrs[i]); +#endif +} + #if SCR_SMALL_ALLOC #include #ifdef __linux__ @@ -373,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 */ @@ -382,6 +764,7 @@ int main(void) { check_realloc(); check_system_pointers(); check_churn(); + check_free_order(); #if SCR_SMALL_ALLOC && defined(__linux__) && defined(__GLIBC__) check_heap_huge_pages(); #endif @@ -389,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_array.c b/packages/runtime/src/scr_array.c index d88ffbf91..ef64be56d 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -166,7 +166,9 @@ typedef struct { size_t sparse_len; size_t sparse_cap; uint8_t elem; /* the owning array's element kind: the dense slot width */ - bool inline_slots; /* the slots live in the array's own block: never freed alone */ + /* The dense allocation that owns the slots, or NULL when there is none to + * free (no slots, or inline slots in the array's own block). */ + void *block; } ScrArrStorage; static uint64_t scr_arr_storage_get(const ScrArrStorage *s, size_t index) { @@ -313,6 +315,81 @@ static void scr_arr_sparse_drop_prefix(ScrArr *a, size_t count) { static bool scr_arr_inline(const ScrArr *a) { return (a->spare & SCR_ARR_INLINE_SLOTS) != 0; } +/* Head room: removing elements from the front of a dense array of 8-byte + * slots with its own allocation (shift, splice(0, n)) advances `data` and + * `present` past them and lowers `cap`, instead of moving the tail down, so + * draining a queue costs O(1) per element. The vacated slots stay in the + * allocation in front of `data` (values) and of `present` (states, all + * holes) until an unshift reuses them, the array empties, or growth moves + * the elements back to the start of the allocation (scr_arr_drop_head). + * `spare` records that an array may have head room; its size is implied, + * because the states still follow the whole value area: + * present == (uint8_t *)(data + cap) + head. + * Every reader indexes from `data`, `present` and `cap` as before (emitted + * code included); only the code that frees, reallocates or swaps the + * allocation needs its start. Static and inline storage never has head room. */ +#define SCR_ARR_HEAD_ROOM 2u +/* An unshift had to move every element: the next one that would have to + * move them again opens head room instead (scr_arr_open_head). */ +#define SCR_ARR_UNSHIFTED 4u +/* Arrays shorter than this keep moving their elements on unshift. */ +#define SCR_ARR_HEAD_MIN ((size_t)16) + +static size_t scr_arr_head(const ScrArr *a) { + if (!(a->spare & SCR_ARR_HEAD_ROOM)) return 0; + return (size_t)(a->present - (uint8_t *)(a->data + a->cap)); +} + +/* The start of the dense allocation. */ +static void *scr_arr_block(const ScrArr *a) { return a->data - scr_arr_head(a); } + +/* Give the head room of an array back to its capacity: the populated dense + * prefix and its states move to the start of the allocation. */ +static void scr_arr_drop_head(ScrArr *a) { + size_t head = scr_arr_head(a); + a->spare &= (uint16_t)~SCR_ARR_HEAD_ROOM; + if (head == 0) return; + size_t cap = a->cap, total = cap + head, live = a->len < cap ? a->len : cap; + uint64_t *data = a->data - head; + uint8_t *present = (uint8_t *)(data + total); + if (live) { + memmove(data, a->data, live * sizeof(uint64_t)); + memmove(present, a->present, live); + } + /* States past `live` were holes already, except the `head` that now sit + * where the moved states were. */ + memset(present + live, SCR_ARR_HOLE, head); + a->data = data; + a->present = present; + a->cap = total; +} + +/* Remove the first `count` (<= len) dense slots, whose values the caller has + * moved out or released, by advancing the storage pointers. Returns false + * for storage without head room (booleans, inline slots); the caller then + * moves the tail down itself. */ +static bool scr_arr_drop_front(ScrArr *a, size_t count) { + if (a->elem == SCR_ELEM_BOOL || scr_arr_inline(a)) return false; + if (count == 0) return true; + memset(a->present, SCR_ARR_HOLE, count); + memset(a->data, 0, count * sizeof(uint64_t)); + a->data += count; + a->present += count; + a->cap -= count; + a->len -= count; + a->filled = a->filled > count ? (uint32_t)(a->filled - count) : 0; + a->spare |= SCR_ARR_HEAD_ROOM; + if (a->len == 0) { + /* Every state in the allocation is a hole: start over at its front. */ + size_t head = scr_arr_head(a); + a->data -= head; + a->present -= head; + a->cap += head; + a->spare &= (uint16_t)~SCR_ARR_HEAD_ROOM; + } + return true; +} + /* Slots requested by the small constructions, rounded up to fill a block * on the 16-byte grid: 5 eight-byte slots (or 8 booleans) after a plain * header. */ @@ -399,8 +476,8 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode); * paths pick the same capacity. */ static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode) { if (need <= a->cap) return; - if (a->elem == SCR_ELEM_BOOL || scr_arr_inline(a) || (a->ext && a->ext->sparse_len) || - need > SCR_ARR_DENSE_LIMIT) { + if (a->elem == SCR_ELEM_BOOL || (a->spare & (SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM)) || + (a->ext && a->ext->sparse_len) || need > SCR_ARR_DENSE_LIMIT) { scr_arr_grow_dense_slow(a, need, mode); return; } @@ -425,8 +502,31 @@ static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode a->cap = cap; } +/* Sparse entries below a raised capacity move into the packed slots. */ +static void scr_arr_absorb_sparse(ScrArr *a) { + size_t moved = scr_arr_sparse_lower_bound(SCR_AX(a)->sparse, SCR_AX(a)->sparse_len, a->cap); + for (size_t i = 0; i < moved; i++) { + scr_arr_dense_put(a, SCR_AX(a)->sparse[i].index, SCR_AX(a)->sparse[i].slot); + a->present[SCR_AX(a)->sparse[i].index] = SCR_AX(a)->sparse[i].state; + } + scr_arr_sparse_drop_prefix(a, moved); +} + static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode) { if (need > SCR_ARR_MAX_LENGTH) scr_arr_oom(); + if (a->spare & SCR_ARR_HEAD_ROOM) { + /* Reuse the head room when it leaves at least half the moved elements' + * count free after this growth, so the move costs amortized O(1) per + * element; otherwise grow past it like any full array. */ + size_t live = a->len < a->cap ? a->len : a->cap; + scr_arr_drop_head(a); + scr_arr_absorb_sparse(a); + if (need <= a->cap) { + if ((a->cap - need) * 2 >= live) return; + need = a->cap + 1; + mode = SCR_ARR_GROW_APPEND; + } + } bool bools = a->elem == SCR_ELEM_BOOL; size_t width = scr_arr_slot_width(a); size_t unit = bools ? SCR_ARR_BOOL_SLOT_BYTES : SCR_ARR_SLOT_BYTES; @@ -450,12 +550,7 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode) { if (cap > SIZE_MAX / SCR_ARR_SLOT_BYTES) scr_arr_oom(); /* bounds both slot sizes */ if (scr_arr_inline(a)) { scr_arr_move_inline(a, cap, unit); - size_t moved = scr_arr_sparse_lower_bound(SCR_AX(a)->sparse, SCR_AX(a)->sparse_len, cap); - for (size_t i = 0; i < moved; i++) { - scr_arr_dense_put(a, SCR_AX(a)->sparse[i].index, SCR_AX(a)->sparse[i].slot); - a->present[SCR_AX(a)->sparse[i].index] = SCR_AX(a)->sparse[i].state; - } - scr_arr_sparse_drop_prefix(a, moved); + scr_arr_absorb_sparse(a); return; } if (!a->data && SCR_AX(a)->sparse_len == 0) { @@ -479,18 +574,53 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode) { a->data = data; a->present = present; a->cap = cap; - size_t moved = scr_arr_sparse_lower_bound(SCR_AX(a)->sparse, SCR_AX(a)->sparse_len, cap); - for (size_t i = 0; i < moved; i++) { - scr_arr_dense_put(a, SCR_AX(a)->sparse[i].index, SCR_AX(a)->sparse[i].slot); - a->present[SCR_AX(a)->sparse[i].index] = SCR_AX(a)->sparse[i].state; - } - scr_arr_sparse_drop_prefix(a, moved); + scr_arr_absorb_sparse(a); } static void scr_arr_grow_dense(ScrArr *a, size_t need) { scr_arr_grow_dense_to(a, need, SCR_ARR_GROW_APPEND); } +/* Room for `count` values in front of the first element of a dense array: + * moves `data` and `present` back into head room, raising `cap`; the caller + * stores the new first `count` slots and states. A shifted array reuses the + * room its shifts left. An array that unshift already had to move once moves + * into a new allocation with head room for half its length again, so a run + * of unshifts costs amortized O(1) per element; a one-off unshift keeps the + * array's allocation. Returns false when the caller must move the elements + * itself. */ +static bool scr_arr_open_head(ScrArr *a, size_t count) { + if (a->elem == SCR_ELEM_BOOL || scr_arr_inline(a)) return false; + size_t head = scr_arr_head(a); + if (head < count) { + size_t len = a->len; + if (!(a->spare & SCR_ARR_UNSHIFTED) || len < SCR_ARR_HEAD_MIN) { + a->spare |= SCR_ARR_UNSHIFTED; + return false; + } + /* The new allocation: head room for the new values plus half the new + * length, then the current dense window (its tail capacity included). */ + size_t room = count + (len + count) / 2; + if (room > SCR_ARR_MAX_LENGTH || a->cap > SIZE_MAX / SCR_ARR_SLOT_BYTES - room) return false; + size_t total = room + a->cap; + uint64_t *block = scr_mem_alloc(total * SCR_ARR_SLOT_BYTES); + if (!block) scr_arr_oom(); + uint8_t *states = (uint8_t *)(block + total); + memcpy(block + room, a->data, len * sizeof(uint64_t)); + memset(states, SCR_ARR_HOLE, room); + memcpy(states + room, a->present, len); + memset(states + room + len, SCR_ARR_HOLE, a->cap - len); + scr_mem_free(scr_arr_block(a)); + a->data = block + room; + a->present = states + room; + a->spare |= SCR_ARR_HEAD_ROOM; + } + a->data -= count; + a->present -= count; + a->cap += count; + return true; +} + /* Is the top of the dense prefix populated like an append or forward fill? * Samples at most SCR_ARR_DENSE_GAP states, and only when a write lands * within that gap past capacity. */ @@ -557,13 +687,13 @@ static bool scr_arr_slot_at_storage(const ScrArrStorage *s, size_t index, static bool scr_arr_slot_at(const ScrArr *a, size_t index, uint64_t *out) { ScrArrStorage s = {a->data, a->present, a->cap, SCR_AX(a)->sparse, - SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, false}; + SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, NULL}; return scr_arr_slot_at_storage(&s, index, out); } static uint8_t scr_arr_state_at(const ScrArr *a, size_t index, uint64_t *out) { ScrArrStorage s = {a->data, a->present, a->cap, SCR_AX(a)->sparse, - SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, false}; + SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, NULL}; return scr_arr_state_at_storage(&s, index, out); } @@ -737,8 +867,8 @@ static bool scr_arr_prop_delete(ScrArr *a, double key) { static ScrArrStorage scr_arr_take_storage(ScrArr *a) { ScrArrStorage old = {a->data, a->present, a->cap, SCR_AX(a)->sparse, SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, - scr_arr_inline(a)}; - a->spare &= (uint16_t)~SCR_ARR_INLINE_SLOTS; + scr_arr_inline(a) ? NULL : scr_arr_block(a)}; + a->spare &= (uint16_t)~(SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM); a->data = NULL; a->present = NULL; a->cap = 0; @@ -752,7 +882,7 @@ static ScrArrStorage scr_arr_take_storage(ScrArr *a) { } static void scr_arr_free_storage(ScrArrStorage *s) { - if (!s->inline_slots) scr_mem_free(s->data); + scr_mem_free(s->block); scr_arr_sparse_free(s->sparse); memset(s, 0, sizeof(*s)); } @@ -885,6 +1015,11 @@ void scr_arr_outline(ScrArr *a) { if (scr_arr_inline(a)) scr_arr_move_inline(a, a->cap, a->elem == SCR_ELEM_BOOL ? SCR_ARR_BOOL_SLOT_BYTES : SCR_ARR_SLOT_BYTES); + /* The swapped fields do not include `spare`: no head room either. */ + if (a->spare & SCR_ARR_HEAD_ROOM) { + scr_arr_drop_head(a); + scr_arr_absorb_sparse(a); + } } void scr_arr_swap_indexed_ext(ScrArr *a, ScrArr *b) { @@ -942,7 +1077,7 @@ static void scr_arr_ext_free(ScrArr *a) { static void scr_arr_gc_free(void *a0) { ScrArr *a = (ScrArr *)a0; - if (!scr_arr_inline(a)) scr_mem_free(a->data); + if (!scr_arr_inline(a)) scr_mem_free(scr_arr_block(a)); if (a->ext) scr_arr_ext_free(a); #ifdef SCR_RC_AUDIT scr_live_arrays--; @@ -1039,7 +1174,7 @@ static void scr_arr_destroy(void *object) { if (a->cyc) { scr_arr_gc_free(a); } else { - if (!scr_arr_inline(a)) scr_mem_free(a->data); + if (!scr_arr_inline(a)) scr_mem_free(scr_arr_block(a)); if (a->ext) scr_arr_ext_free(a); #ifdef SCR_RC_AUDIT scr_live_arrays--; @@ -1672,6 +1807,15 @@ static double scr_arr_unshift_slot(ScrArr *a, uint64_t slot) { scr_arr_trap_oob(SCR_ARR_LEN_F64(a->len), a->len); } size_t old_len = a->len; + if (scr_arr_is_dense(a) && scr_arr_open_head(a, 1)) { + /* The prefix stays put in front of the new first value. */ + size_t f = a->filled; + a->data[0] = slot; + a->present[0] = SCR_ARR_VALUE; + a->len++; + a->filled = (uint32_t)(f + 1); + return SCR_ARR_LEN_F64(a->len); + } if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + 1, 1)) { scr_arr_grow_dense(a, old_len + 1); /* The prefix moves up behind the new first value. */ @@ -1716,6 +1860,14 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots) { size_t old_len = a->len; if (count == 0) return (double)old_len; if (count > SCR_ARR_MAX_LENGTH - old_len) scr_arr_oom(); + if (scr_arr_is_dense(a) && scr_arr_open_head(a, count)) { + size_t f = a->filled; + scr_arr_put_slots(a, 0, count, slots); + memset(a->present, SCR_ARR_VALUE, count); + a->len = old_len + count; + a->filled = (uint32_t)(f + count); + return SCR_ARR_LEN_F64(a->len); + } if (scr_arr_is_dense(a) && scr_arr_dense_ok(a, old_len + count, count)) { scr_arr_grow_dense(a, old_len + count); size_t f = a->filled; @@ -1748,6 +1900,13 @@ double scr_arr_unshift_spread(ScrArr *a, const ScrArr *src) { size_t add = src->len; if (add == 0) return (double)old_len; if (add > SCR_ARR_MAX_LENGTH - old_len) scr_arr_oom(); + if (scr_arr_is_dense(a) && scr_arr_is_dense(src) && scr_arr_open_head(a, add)) { + /* A self-spread reads the original values behind the opened room. */ + scr_arr_copy_dense(a, 0, src, src == a ? add : 0, add, false, true); + a->len += add; + scr_arr_refill(a); + return SCR_ARR_LEN_F64(a->len); + } if (scr_arr_is_dense(a) && scr_arr_is_dense(src) && scr_arr_dense_ok(a, old_len + add, add)) { scr_arr_grow_dense(a, old_len + add); @@ -1846,10 +2005,12 @@ bool scr_arr_pop_bool(ScrArr *a) { return scr_arr_pop_slot(a) != 0; } void *scr_arr_pop_ref(ScrArr *a) { return scr_slot_to_ptr(scr_arr_pop_slot(a)); } /* ── shift ───────────────────────────────────────────────────────────── - * The first element out, tail sliding down. The EMITTER guards the empty - * array (JS answers undefined there — the `elem | undefined` union), so - * an empty receiver here is an internal error, pop's discipline. Ref - * ownership moves out to the caller (no retain). */ + * The first element out: dense storage advances past it into head room; + * booleans, inline slots and sparse arrays slide the tail down. The + * EMITTER guards the empty array (JS answers undefined there — the + * `elem | undefined` union), so an empty receiver here is an internal + * error, pop's discipline. Ref ownership moves out to the caller (no + * retain). */ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { if (slot_out) *slot_out = 0; if (a->len == 0) return SCR_ARR_HOLE; @@ -1857,6 +2018,8 @@ uint8_t scr_arr_shift_state(ScrArr *a, uint64_t *slot_out) { if (scr_arr_is_dense(a)) { uint8_t state = a->present[0]; if (slot_out && state == SCR_ARR_VALUE) *slot_out = scr_arr_dense_get(a, 0); + /* The first slot becomes head room; the tail stays where it is. */ + if (scr_arr_drop_front(a, 1)) return state; /* The prefix slides down with the tail. */ size_t f = a->filled; scr_arr_move_dense(a, 0, 1, old_len - 1); @@ -1932,6 +2095,8 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { memcpy(out->present, a->present + from, n); } scr_arr_refill(out); + /* A removed prefix becomes head room. */ + if (from == 0 && scr_arr_drop_front(a, n)) return out; /* The tail moves down over the removed range; values below the prefix * bound that stay values keep it. */ size_t f = a->filled; @@ -2067,6 +2232,12 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou } } size_t old_len = a->len; + if (from == 0 && count == 0 && scr_arr_drop_front(a, n)) { + /* A removed prefix becomes head room. */ + for (size_t i = 0; i < kept; i++) scr_elem_release(a, removed[i]); + if (removed != inline_removed) scr_mem_free(removed); + return; + } scr_arr_grow_dense(a, next_len); bool packed = a->filled == old_len; scr_arr_move_dense(a, from + count, from + n, old_len - from - n); @@ -2940,6 +3111,11 @@ static double scr_bytes_sort_load(const ScrBytes *b, size_t i) { memcpy(&v, at, sizeof v); return v; } + case SCR_BYTES_F16: { + uint16_t v; + memcpy(&v, at, sizeof v); + return scr_f16_to_f64(v); + } case SCR_BYTES_F32: { float v; memcpy(&v, at, sizeof v); @@ -2980,6 +3156,11 @@ static void scr_bytes_sort_store(ScrBytes *b, size_t i, double value) { memcpy(at, &v, sizeof v); break; } + case SCR_BYTES_F16: { + uint16_t v = scr_f16_from_f64(value); + memcpy(at, &v, sizeof v); + break; + } case SCR_BYTES_F32: { float v = (float)value; memcpy(at, &v, sizeof v); @@ -3092,6 +3273,18 @@ static int scr_bytes_f32_key(const uint8_t *at, int32_t order, uint32_t *key) { return 0; } +/* Float16 keys use the low 16 bits of a 32-bit key; a descending order + * complements all 32, which keeps the order of keys. */ +static int scr_bytes_f16_key(const uint8_t *at, int32_t order, uint32_t *key) { + uint16_t bits; + memcpy(&bits, at, sizeof bits); + if ((bits & 0x7fff) > 0x7c00) return order ? 2 : 1; + if (order && bits == 0x8000) return 2; + uint32_t k = (bits & 0x8000) ? (uint16_t)~bits : (uint32_t)bits | 0x8000; + *key = k ^ (order < 0 ? ~UINT32_C(0) : 0); + return 0; +} + static int scr_bytes_f64_key(const uint8_t *at, int32_t order, uint64_t *key) { double v; uint64_t bits; @@ -3152,6 +3345,9 @@ static bool scr_bytes_sort_refused(const ScrBytes *b, int32_t order) { if (b->elem == SCR_BYTES_F32) { uint32_t key; if (scr_bytes_f32_key(data + i * 4, order, &key)) return true; + } else if (b->elem == SCR_BYTES_F16) { + uint32_t key; + if (scr_bytes_f16_key(data + i * 2, order, &key)) return true; } else if (b->elem == SCR_BYTES_F64) { uint64_t key; if (scr_bytes_f64_key(data + i * 8, order, &key)) return true; @@ -3182,7 +3378,7 @@ bool scr_bytes_sort_numeric(ScrBytes *b, int32_t order) { size_t width = scr_bytes_elem_size(b->elem); uint8_t *data = b->data; /* Integer elements have no NaN or -0: every order is a value order. */ - if (width == 1 || (width == 2 && n >= SCR_BYTES_COUNT_SORT)) { + if (width == 1 || (width == 2 && b->elem != SCR_BYTES_F16 && n >= SCR_BYTES_COUNT_SORT)) { scr_bytes_count_sort(b, order < 0); return true; } @@ -3221,13 +3417,23 @@ bool scr_bytes_sort_numeric(ScrBytes *b, int32_t order) { uint32_t bits_ = (k_ & SCR_SORT_SIGN32) ? k_ & ~SCR_SORT_SIGN32 : ~k_; \ memcpy(data + (i) * 4, &bits_, 4); \ } while (0) +#define SCR_BYTES_READ32_F16(i, key) scr_bytes_f16_key(data + (i) * 2, order, (key)) +#define SCR_BYTES_WRITE32_F16(i, key) \ + do { \ + uint16_t k_ = (uint16_t)((key) ^ flip32); \ + uint16_t bits_ = (k_ & 0x8000) ? (uint16_t)(k_ & 0x7fff) : (uint16_t)~k_; \ + memcpy(data + (i) * 2, &bits_, 2); \ + } while (0) if (b->elem == SCR_BYTES_F32) SCR_BYTES_KEYS32(F32); + else if (b->elem == SCR_BYTES_F16) SCR_BYTES_KEYS32(F16); else SCR_BYTES_KEYS32(INT); #undef SCR_BYTES_KEYS32 #undef SCR_BYTES_READ32_INT #undef SCR_BYTES_WRITE32_INT #undef SCR_BYTES_READ32_F32 #undef SCR_BYTES_WRITE32_F32 +#undef SCR_BYTES_READ32_F16 +#undef SCR_BYTES_WRITE32_F16 free(keys); return true; } diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 829492fa5..d02c2097e 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -76,6 +76,7 @@ typedef struct ScrArr { * ref elements and the historic element kinds keep the lean 1-word * header. Kept beside `elem` so the RC fast paths test it with one load. */ uint8_t cyc; + /* Storage flags private to scr_array.c (inline slots, head room). */ uint16_t spare; /* A lower bound on the hole-free prefix: filled <= len, filled <= cap, * and every dense state below it is SCR_ARR_VALUE. Element reads below @@ -209,8 +210,9 @@ void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target); /* The array's out-of-line storage, allocated (zeroed) on first use. */ ScrArrExt *scr_arr_ext(ScrArr *a); /* Moves inline dense slots (kept in the header block of a small array) to - * their own allocation, so the storage pointers may be swapped with another - * array's. No-op for other arrays. */ + * their own allocation, and the elements of an array with head room (left + * by shift) to the start of theirs, so the storage pointers may be swapped + * with another array's. No-op for other arrays. */ void scr_arr_outline(ScrArr *a); /* Swaps the sparse slots and numeric properties of two arrays; each keeps * its own match metadata (refresh of a cached typed view, llvm/dyn.ts). */ @@ -376,9 +378,10 @@ bool scr_arr_pop_bool(ScrArr *a); void *scr_arr_pop_ref(ScrArr *a); uint8_t scr_arr_pop_state(ScrArr *a, uint64_t *slot_out); -/* shift: the first element out, tail sliding down. The emitter guards the - * empty array (JS's undefined — the `elem | undefined` union), so empty - * here is an internal error; _ref transfers ownership out like pop. */ +/* shift: the first element out; the rest stay in place behind head room + * (amortized O(1)). The emitter guards the empty array (JS's undefined — + * the `elem | undefined` union), so empty here is an internal error; _ref + * transfers ownership out like pop. */ double scr_arr_shift_f64(ScrArr *a); bool scr_arr_shift_bool(ScrArr *a); void *scr_arr_shift_ref(ScrArr *a); diff --git a/packages/runtime/src/scr_assert.c b/packages/runtime/src/scr_assert.c index 9e2e79418..5a4769038 100644 --- a/packages/runtime/src/scr_assert.c +++ b/packages/runtime/src/scr_assert.c @@ -791,7 +791,7 @@ static bool scr_assert_loose_error(const ScrDyn *a, const ScrDyn *b) { } static bool scr_assert_loose_float_elem(ScrBytesElem elem) { - return elem == SCR_BYTES_F32 || elem == SCR_BYTES_F64; + return elem == SCR_BYTES_F16 || elem == SCR_BYTES_F32 || elem == SCR_BYTES_F64; } /* Typed arrays and Buffers: the same tag (a Buffer is a Uint8Array here), @@ -1305,6 +1305,7 @@ static const char *scr_assert_cf_bytes_class(ScrBytesElem elem) { switch (elem) { case SCR_BYTES_U8: return "Uint8Array"; case SCR_BYTES_U32: return "Uint32Array"; + case SCR_BYTES_F16: return "Float16Array"; case SCR_BYTES_F32: return "Float32Array"; case SCR_BYTES_I32: return "Int32Array"; case SCR_BYTES_F64: return "Float64Array"; diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 9bf42bb07..259f2fa7a 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -638,6 +638,12 @@ struct ScrFiber { bool await_parked; #ifdef SCR_ASAN_FIBERS void *fake_stack; + /* Live stackless async fibers (scr_async_spawn_stackless) form a list + * rooted in a global, the stack root regions' counterpart: a suspended + * coroutine frame that never resumes stays reachable for the leak check + * as an abandoned fiber stack does. */ + struct ScrFiber *stackless_next; + struct ScrFiber **stackless_previous; #endif }; @@ -713,6 +719,8 @@ static void scr_fiber_keep_abandoned(ScrFiber *fiber) { } scr_abandoned_fibers[scr_abandoned_len++] = fiber; } + +static SCR_TL ScrFiber *scr_stackless_fibers; #endif void *scr_fiber_self(void) { return scr_current; } @@ -986,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++; @@ -1816,7 +1827,7 @@ static void scr_trampoline(void) { /* Seeds a fresh fiber's register file so its first switch-in enters the * trampoline with an ABI-conforming stack. The stack itself is only * written at its top word, so the mapping stays lazily committed. */ -static void scr_fiber_context_init(ScrFiber *f) { +static void scr_fiber_context_start(ScrFiber *f, void (*start)(void)) { f->stack = scr_fiber_stack_new(); uintptr_t top = ((uintptr_t)f->stack + SCR_FIBER_STACK) & ~(uintptr_t)15; memset(&f->ctx, 0, sizeof f->ctx); @@ -1826,13 +1837,13 @@ static void scr_fiber_context_init(ScrFiber *f) { void **sp = (void **)top; *--sp = NULL; f->ctx.gpr[0] = sp; - f->ctx.gpr[7] = (void *)scr_trampoline; + f->ctx.gpr[7] = (void *)start; f->ctx.mxcsr = __builtin_ia32_stmxcsr(); __asm__ volatile("fnstcw %0" : "=m"(f->ctx.fpcw)); #else - f->ctx.reg[0] = (uint64_t)(uintptr_t)scr_trampoline; /* x19, for scr_ctx_boot */ - f->ctx.reg[11] = (uint64_t)(uintptr_t)scr_ctx_boot; /* x30: resume address */ - f->ctx.reg[12] = (uint64_t)top; /* sp */ + f->ctx.reg[0] = (uint64_t)(uintptr_t)start; /* x19, for scr_ctx_boot */ + f->ctx.reg[11] = (uint64_t)(uintptr_t)scr_ctx_boot; /* x30: resume address */ + f->ctx.reg[12] = (uint64_t)top; /* sp */ #endif } #elif !defined(_WIN32) && !defined(__wasi__) @@ -1845,7 +1856,7 @@ static void scr_fiber_context_init(ScrFiber *f) { * same as when initializing the full mapping; ordinary calls can still * use all of SCR_FIBER_STACK. * See libplatform/src/ucontext/generic/makecontext.c's arm64 implementation. */ -static void scr_fiber_context_init(ScrFiber *f) { +static void scr_fiber_context_start(ScrFiber *f, void (*start)(void)) { f->stack = scr_fiber_stack_new(); if (getcontext(&f->ctx) != 0) scr_trap("scriptc: getcontext failed\n"); f->ctx.uc_stack.ss_sp = f->stack; @@ -1857,11 +1868,14 @@ static void scr_fiber_context_init(ScrFiber *f) { f->ctx.uc_stack.ss_sp = f->stack + SCR_FIBER_STACK - bootstrap; f->ctx.uc_stack.ss_size = bootstrap; #endif - makecontext(&f->ctx, scr_trampoline, 0); + makecontext(&f->ctx, start, 0); f->ctx.uc_stack.ss_sp = f->stack; f->ctx.uc_stack.ss_size = SCR_FIBER_STACK; } #endif +#if !defined(_WIN32) && !defined(__wasi__) +static void scr_fiber_context_init(ScrFiber *f) { scr_fiber_context_start(f, scr_trampoline); } +#endif /* Frees a finished fiber's execution resources (the promise release and * bookkeeping stay at the call sites). Windows: DeleteFiber tears down the @@ -1874,6 +1888,12 @@ static void scr_fiber_destroy(ScrFiber *f) { if (f->context_next) f->context_next->context_previous = f->context_previous; } #endif +#ifdef SCR_ASAN_FIBERS + if (f->stackless_previous) { + *f->stackless_previous = f->stackless_next; + if (f->stackless_next) f->stackless_next->stackless_previous = f->stackless_previous; + } +#endif #ifdef __wasi__ if (f->coro != NULL) scr_wasi_coro_destroy(f->coro); #else @@ -1989,6 +2009,105 @@ ScrPromise *scr_async_spawn(void (*entry)(ScrFiber *, void *), void *argpack) { return scr_async_spawn_impl(entry, argpack, NULL); } +#ifndef __wasi__ +/* ── stackless async functions ──────────────────────────────────────── + * The compiler lowers an async body that suspends only at its own awaits + * to an LLVM switched coroutine (scr_async.h). Its fiber is a scheduler + * envelope without a stack or saved context: the eager prefix runs on the + * spawner's stack (the WASI spawn's shape), and every later step runs on + * the loop's (scr_resume_fiber). The exception cell, AsyncLocalStorage + * context, and Error.stack chain swap exactly as a fiber switch swaps them, + * and the promise settles and wakes its waiters through the same paths. */ +#ifndef _WIN32 +/* The stack a nest of eager prefixes leaves above the running stack's + * guard before the next prefix moves to a fresh stack. Each fiber of the + * stackful form brought its own stack, so an async recursion as deep as + * memory allows keeps working; between moves the stack guard (with its + * reserve below the guard) still covers synchronous recursion. */ +#define SCR_STACKLESS_PREFIX_ROOM ((uintptr_t)64 << 10) + +static SCR_TL struct { + ScrFiber *body; + void (*entry)(ScrFiber *, void *); + void *argpack; +} scr_prefix_call; + +/* Runs on the carrier's fresh stack: the body's prefix as the body's own + * fiber, then back to the spawner, which frees the stack. */ +static void scr_prefix_trampoline(void) { +#ifdef SCR_ASAN_FIBERS + const void *ob; + size_t os; + __sanitizer_finish_switch_fiber(scr_current->fake_stack, &ob, &os); +#endif + ScrFiber *carrier = scr_current; + ScrFiber *f = scr_prefix_call.body; + void (*entry)(ScrFiber *, void *) = scr_prefix_call.entry; + void *argpack = scr_prefix_call.argpack; + scr_current = f; + scr_exc_swap_cell(&f->exc); + scr_als_active = &f->als; + entry(f, argpack); + scr_current = carrier; + scr_switch(&carrier->ctx, carrier->return_to, NULL); + /* unreachable */ +} + +static void scr_prefix_on_fresh_stack(ScrFiber *f, void (*entry)(ScrFiber *, void *), + void *argpack) { + ScrFiber carrier; + memset(&carrier, 0, sizeof carrier); + scr_fiber_context_start(&carrier, scr_prefix_trampoline); + ScrCtx here; + carrier.return_to = &here; + scr_prefix_call.body = f; + scr_prefix_call.entry = entry; + scr_prefix_call.argpack = argpack; + scr_switch(&here, &carrier.ctx, &carrier); + scr_fiber_stack_free(carrier.stack); +} +#endif + +ScrPromise *scr_async_spawn_stackless(void (*entry)(ScrFiber *, void *), void *argpack) { + ScrFiber *f = scr_mem_calloc(sizeof *f); + if (!f) scr_oom(); + scr_fiber_track(f); + f->promise = scr_promise_new(); + f->stackless = true; + f->als = scr_als_ctx_retain(*SCR_ALS_SLOT()); + scr_fibers_live++; +#ifdef SCR_ASAN_FIBERS + f->stackless_next = scr_stackless_fibers; + f->stackless_previous = &scr_stackless_fibers; + if (scr_stackless_fibers) scr_stackless_fibers->stackless_previous = &f->stackless_next; + scr_stackless_fibers = f; +#endif + ScrFiber *spawner = scr_current; +#ifndef _WIN32 + uintptr_t guard = scr_stack_guard; + if (guard != 0 && (uintptr_t)__builtin_frame_address(0) < guard + SCR_STACKLESS_PREFIX_ROOM) { + scr_prefix_on_fresh_stack(f, entry, argpack); + } else +#endif + { + scr_current = f; + scr_exc_swap_cell(&f->exc); + scr_als_active = &f->als; + entry(f, argpack); /* the eager prefix: returns at a suspension or the end */ + } + scr_current = spawner; + scr_exc_swap_cell(spawner ? &spawner->exc : NULL); + scr_als_active = spawner ? &spawner->als : &scr_als_main_slot; + ScrPromise *result = scr_promise_retain(f->promise); + if (f->done) { + scr_promise_release(f->promise); + scr_fiber_destroy(f); + scr_fibers_live--; + } + return result; +} +#endif + static void scr_await_park(ScrPromise *p); static void scr_promise_rethrow(ScrPromise *p); @@ -2004,7 +2123,7 @@ static ScrPromise *scr_async_spawn_await(void (*entry)(ScrFiber *, void *), void bool *ok) { #ifdef SCR_SPAWN_AWAIT ScrFiber *self = scr_current; - if (self != NULL && self->gen == NULL && self->inline_depth == 0) { + if (self != NULL && self->gen == NULL && self->inline_depth == 0 && !self->stackless) { ScrPromise *p = scr_async_spawn_impl(entry, argpack, self); if (self->await_parked) { self->await_parked = false; @@ -2079,6 +2198,12 @@ static void scr_inline_park_trap(void) { scr_trap("scriptc: internal error: fiberless async body suspended\n"); } +/* A stackless body has no stack to park: only its own emitted awaits may + * suspend it (scr_coro_await_prepare). */ +static void scr_stackless_park_trap(void) { + scr_trap("scriptc: internal error: stackless async body parked its stack\n"); +} + void scr_async_inline_enter(ScrAsyncInline *frame) { ScrExcCell *cell = scr_exc_current_cell(); frame->stack = cell->stack; @@ -2165,6 +2290,7 @@ static void scr_await_park(ScrPromise *p) { abort(); } if (self->inline_depth != 0) scr_inline_park_trap(); + if (self->stackless) scr_stackless_park_trap(); scr_promise_add_waiter(p, self); #ifdef SCR_WORKERS self->waiting = p; @@ -2225,6 +2351,51 @@ void scr_gen_coro_finish(ScrFiber *self) { } scr_fiber_finish(self); } + +/* A stackless async body's await. A pending promise owns the continuation + * through its waiter list. A settled one (or a non-promise operand) takes + * one microtask turn on the READY queue, except where scr_await_yield + * continues a fiber in place: resumed by the loop's drain with nothing + * else queued, this body is the very next job either way. */ +static bool scr_coro_hop_needed(ScrFiber *self) { + if (self->return_to == &scr_loop_ctx && scr_ready_len == 0) return false; + scr_ready_push(self); + return true; +} + +bool scr_coro_await_prepare(ScrFiber *self, ScrPromise *p) { + if (p->state == SCR_PROM_PENDING) { + scr_promise_add_waiter(p, self); + return true; + } + return scr_coro_hop_needed(self); +} + +bool scr_coro_hop_prepare(ScrFiber *self) { return scr_coro_hop_needed(self); } + +void scr_coro_async_finish(ScrFiber *self) { scr_fiber_finish(self); } + +/* Extraction after the await's suspension: the promise has settled. */ +static bool scr_coro_await_settled(ScrPromise *p) { + if (p->state == SCR_PROM_PENDING) + scr_trap("scriptc: internal error: resumed before awaited promise settled\n"); + scr_prom_observe(p); + if (p->state == SCR_PROM_REJECTED) { + scr_promise_rethrow(p); + return false; + } + return true; +} + +void scr_coro_await_void(ScrPromise *p) { scr_coro_await_settled(p); } +double scr_coro_await_f64(ScrPromise *p) { return scr_coro_await_settled(p) ? p->f64 : 0; } +bool scr_coro_await_bool(ScrPromise *p) { return scr_coro_await_settled(p) ? p->b : false; } +ScrStr *scr_coro_await_str(ScrPromise *p) { + return scr_coro_await_settled(p) && p->payload ? scr_str_retain((ScrStr *)p->payload) : NULL; +} +void *scr_coro_await_ref(ScrPromise *p) { + return scr_coro_await_settled(p) && p->payload ? p->retain_fn(p->payload) : NULL; +} #endif /* One microtask hop: park the current fiber on the READY queue itself and @@ -2239,6 +2410,7 @@ static void scr_await_yield(void) { abort(); } if (self->inline_depth != 0) scr_inline_park_trap(); + if (self->stackless) scr_stackless_park_trap(); if (self->gen != NULL) { scr_ready_push(self); scr_switch(&self->ctx, self->return_to, NULL); @@ -3641,10 +3813,21 @@ static void scr_resume_fiber(ScrFiber *f) { scr_exc_swap_cell(NULL); scr_als_active = &scr_als_main_slot; #else - scr_switch(&scr_loop_ctx, &f->ctx, f); - f = scr_loop_reap; - if (f == NULL) return; - scr_loop_reap = NULL; + if (f->stackless) { + /* A stackless async body continues on the loop's stack. */ + scr_current = f; + scr_exc_swap_cell(&f->exc); + scr_als_active = &f->als; + scr_coro_resume(f->coro); + scr_current = NULL; + scr_exc_swap_cell(NULL); + scr_als_active = &scr_als_main_slot; + } else { + scr_switch(&scr_loop_ctx, &f->ctx, f); + f = scr_loop_reap; + if (f == NULL) return; + scr_loop_reap = NULL; + } #endif if (f->done) { scr_promise_release(f->promise); @@ -4400,7 +4583,9 @@ bool scr_report_unhandled_rejections(void) { #ifdef SCR_WORKERS if (scr_context_report_error) { scr_promise_rethrow(p); + scr_context_report_rejection = true; scr_exc_print_uncaught(); + scr_context_report_rejection = false; scr_promise_release(p); continue; } @@ -4680,6 +4865,13 @@ struct ScrGen { ScrExcCell async_error; bool settle_queued; bool success_hop_done; + /* A return() request at a yield: the queued job resumes the body after + * the return value's await (return_queued), and the body's completion, + * on that request or a later one, settles without another turn + * (return_awaited). An explicit `return x` during the unwind clears it, + * since that return takes its own turn to await x. */ + bool return_queued; + bool return_awaited; #ifdef SCR_WORKERS bool context_abandoned; #endif @@ -5081,6 +5273,8 @@ void scr_coro_yield_ref(ScrFiber *self, void *v, void (*release)(void *)) { scr_gen_slot_ref(&self->gen->out, v, release); } +void scr_async_gen_return_awaits(void) { scr_gen_self()->return_awaited = false; } + void scr_async_gen_hop_done(void) { ScrGen *g = scr_gen_self(); if (!g->is_async) { @@ -5360,6 +5554,21 @@ static void scr_async_gen_capture_caller_error(ScrGen *g) { if (cell->kind != SCR_EXC_NONE) scr_gen_exc_move(&g->async_error, cell); } +/* Whether the body's latest stop settles its request without a further + * turn: a yield whose await already supplied the value, or a completion + * after a return() request's await. */ +static bool scr_async_gen_settles_now(ScrGen *g) { + if (g->success_hop_done) { + g->success_hop_done = false; + return true; + } + if (g->return_awaited && g->state == SCR_GEN_DONE) { + g->return_awaited = false; + return true; + } + return false; +} + static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { const bool was_done = g->state == SCR_GEN_DONE; if (req->mode == SCR_AG_NEXT) { @@ -5371,6 +5580,13 @@ static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { scr_gen_slot_reset(&g->ret); g->ret = req->arg; memset(&req->arg, 0, sizeof req->arg); + if (g->state == SCR_GEN_SUSPENDED) { + /* At a yield, AsyncGeneratorYield awaits the return value before + * the body resumes, so finally blocks run one turn later. */ + g->return_queued = true; + scr_async_gen_schedule_settle(g); + return; + } scr_gen_resume_return(g); } else { scr_gen_exc_move(scr_exc_current_cell(), &req->thrown); @@ -5385,18 +5601,39 @@ static void scr_async_gen_run_active(ScrGen *g, ScrAsyncGenRequest *req) { if (g->state == SCR_GEN_AWAITING) return; /* next() on an already-completed generator produces its undefined done - * result immediately. Yield, body return, and return(value) all pass - * through AsyncGeneratorYield's successful Await step. */ + * result immediately. A yield and return(value) on an unstarted or + * completed generator pass through an Await step; a body that falls off + * its end, or whose `return x` already awaited x, marks its completion + * as settled. */ if (was_done && req->mode == SCR_AG_NEXT) { scr_async_gen_finish_active(g, false); - } else if (g->success_hop_done) { - g->success_hop_done = false; + } else if (scr_async_gen_settles_now(g)) { scr_async_gen_finish_active(g, false); } else { scr_async_gen_schedule_settle(g); } } +/* The return() request's await has taken its turn: resume the body with + * the return completion. */ +static void scr_async_gen_resume_returning(ScrGen *g) { + g->return_queued = false; + g->return_awaited = true; + scr_gen_resume_return(g); + scr_async_gen_capture_caller_error(g); + if (g->async_error.kind != SCR_EXC_NONE) { + scr_async_gen_finish_active(g, true); + } else if (g->state == SCR_GEN_AWAITING) { + return; + } else if (scr_async_gen_settles_now(g)) { + scr_async_gen_finish_active(g, false); + } else { + scr_async_gen_schedule_settle(g); + return; + } + scr_async_gen_start_queued(g); +} + static void scr_async_gen_start_queued(ScrGen *g) { /* Keep the object alive while an immediately-settled request releases * its own retain and the loop inspects the next queue node. */ @@ -5507,6 +5744,12 @@ static void scr_async_gen_job(ScrGen *g) { fputs("scriptc: internal error: invalid async generator settlement job\n", stderr); abort(); } + if (g->return_queued) { + scr_gen_retain(g); + scr_async_gen_resume_returning(g); + scr_gen_release(g); + return; + } scr_async_gen_finish_active(g, false); scr_async_gen_start_queued(g); } @@ -5524,8 +5767,7 @@ static void scr_async_gen_resume_ready(ScrGen *g) { scr_async_gen_finish_active(g, true); scr_async_gen_start_queued(g); } else if (g->state != SCR_GEN_AWAITING) { - if (g->success_hop_done) { - g->success_hop_done = false; + if (scr_async_gen_settles_now(g)) { scr_async_gen_finish_active(g, false); scr_async_gen_start_queued(g); } else { diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index b671e5c84..7f294ca29 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -77,6 +77,25 @@ bool scr_wasi_module_await_prepare(ScrFiber *self, ScrPromise *p); void scr_wasi_async_finish(ScrFiber *self); void scr_wasi_gen_finish(ScrFiber *self); +/* Native stackless async functions: an async body the compiler lowered to + * an LLVM switched coroutine because it suspends only at its own awaits. + * It runs on whichever stack resumes it (the spawner's for the eager + * prefix, the loop's afterwards) with a heap frame holding the values live + * across its awaits, instead of a dedicated fiber stack. Each await first + * prepares the continuation (true: the coroutine must suspend; false: it + * continues in place, exactly where a fiber's hop would continue without + * switching), then extracts the settled result after resuming without any + * further hop or park (a rejection re-throws). */ +ScrPromise *scr_async_spawn_stackless(void (*entry)(ScrFiber *, void *), void *argpack); +bool scr_coro_await_prepare(ScrFiber *self, ScrPromise *p); +bool scr_coro_hop_prepare(ScrFiber *self); +void scr_coro_async_finish(ScrFiber *self); +void scr_coro_await_void(ScrPromise *p); +double scr_coro_await_f64(ScrPromise *p); +bool scr_coro_await_bool(ScrPromise *p); +ScrStr *scr_coro_await_str(ScrPromise *p); /* +1 */ +void *scr_coro_await_ref(ScrPromise *p); /* +1 via the stored retain */ + double scr_await_f64(ScrPromise *p); /* rejected promises re-throw */ bool scr_await_bool(ScrPromise *p); ScrStr *scr_await_str(ScrPromise *p); /* +1 */ @@ -197,6 +216,10 @@ void scr_emit_warning(const char *name, const char *code, ScrStr *message); * listener registration): units that cannot link the dyn-async unit route * their warnings through it, and print the default report otherwise. */ extern SCR_TL void (*scr_warning_emit_hook)(const char *name, const char *code, ScrStr *message); +/* Node's traceWarningHelperShown: the "(Use `node --trace-warnings ...`" + * hint follows only the first warning report of the process, whichever + * unit prints it (scr_lib.c). */ +extern SCR_TL bool scr_warning_trace_hint_shown; /* process.noDeprecation (which 0) and process.throwDeprecation (which 1): * the stored value (+1; undefined until assigned), the assignment * (borrowed), and the truthiness the warning paths test. */ @@ -344,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_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index deac50626..b7387c13e 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -341,9 +341,100 @@ void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor) { scr_uncaught_sync_hook(); } +/* V8's side-effect-free string of a value, which Node's + * UnhandledPromiseRejection message quotes: primitives as String() gives + * them, a symbol as Symbol(description), an array as [object Array], an + * object with its own toString as [object Object], and other objects as + * #. */ +static bool scr_rejection_custom_to_string(const ScrDyn *value) { + if (value->kind == SCR_DYN_TYPED_REF) { + ScrStr *key = scr_str_new("toString", 8); + bool found = scr_dyn_typed_ref_class_has(value, key); + scr_str_release(key); + return found; + } + for (const ScrDyn *p = value; p; p = p->prototype) + if (p->kind == SCR_DYN_OBJ && scr_dyn_obj_get(p, "toString", 8)) return true; + return false; +} + +static ScrStr *scr_rejection_reason_text(const ScrDyn *reason, const ScrDyn *view) { + switch (view->kind) { + case SCR_DYN_UNDEF: return scr_str_new("undefined", 9); + case SCR_DYN_NULL: return scr_str_new("null", 4); + case SCR_DYN_SYMBOL: return reason->v.symbol.render(reason->v.symbol.value); + case SCR_DYN_ARR: return scr_str_new("[object Array]", 14); + case SCR_DYN_PROMISE: return scr_str_new("#", 10); + case SCR_DYN_OBJ: { + if (scr_rejection_custom_to_string(reason) || scr_rejection_custom_to_string(view)) + return scr_str_new("[object Object]", 15); + const char *name = reason->kind == SCR_DYN_OBJ ? scr_dyn_class_name(reason) : NULL; + const ScrStr *typed = reason->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_class_name(reason) : NULL; + ScrStr *parts[3] = { + scr_str_new("#<", 2), + typed ? scr_str_new(typed->data, typed->len) : scr_str_new(name ? name : "Object", name ? strlen(name) : 6), + scr_str_new(">", 1), + }; + ScrStr *text = scr_str_concat_parts(parts, 3); + for (size_t i = 0; i < 3; i++) scr_str_release(parts[i]); + return text; + } + default: return scr_dyn_to_string(view, NULL); + } +} + +ScrDyn *scr_unhandled_rejection_error(ScrDyn *reason) { + ScrError *error = scr_errdyn_err_of(reason); + ScrDyn *view = reason->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(reason) : scr_dyn_retain(reason); + if (!view || scr_exc_pending()) { + scr_error_release(error); + scr_dyn_release(view); + return NULL; + } + ScrStr *stack = scr_str_new("stack", 5); + bool error_like = error || (view->kind == SCR_DYN_OBJ && scr_dyn_has_own(view, stack)); + scr_str_release(stack); + if (error_like) { + scr_error_release(error); + scr_dyn_release(view); + return scr_dyn_retain(reason); + } + ScrStr *text = scr_rejection_reason_text(reason, view); + scr_dyn_release(view); + if (!text || scr_exc_pending()) { + scr_str_release(text); + return NULL; + } + static const char head[] = "This error originated either by throwing inside of an async function without a " + "catch block, or by rejecting a promise which was not handled with .catch(). The " + "promise rejected with the reason \""; + ScrStr *parts[3] = {scr_str_new(head, sizeof head - 1), text, scr_str_new("\".", 2)}; + ScrStr *message = scr_str_concat_parts(parts, 3); + for (size_t i = 0; i < 3; i++) scr_str_release(parts[i]); + error = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_str_release(error->name); + error->name = scr_str_new("UnhandledPromiseRejection", 25); + error->code = scr_str_new("ERR_UNHANDLED_REJECTION", 23); + ScrDyn *value = scr_dyn_from_error(error); + /* Node's class fields: code, then name, both own and enumerable */ + scr_dyn_obj_set(value, "name", 4, scr_dyn_new_str(error->name)); + scr_error_release(error); + return value; +} + static int scr_uncaught_dispatch(bool from_promise) { ScrCaught *caught = scr_exc_take(); ScrDyn *error = scr_caught_to_dyn(caught); + if (from_promise) { + ScrDyn *wrapped = scr_unhandled_rejection_error(error); + scr_dyn_release(error); + if (!wrapped) { + scr_caught_release(caught); + return -1; + } + error = wrapped; + } const char *origin = from_promise ? "unhandledRejection" : "uncaughtException"; ScrStr *text = scr_str_new(origin, strlen(origin)); ScrDyn *args[2] = {error, scr_dyn_new_str(text)}; @@ -738,9 +829,8 @@ static void scr_warning_dispatch(ScrDyn *w) { } /* Node's one-time trace hint, after the first report; deprecations * name their own trace flag. */ - static SCR_TL bool hinted = false; - if (!hinted) { - hinted = true; + if (!scr_warning_trace_hint_shown) { + scr_warning_trace_hint_shown = true; scr_jb_puts(&report, deprecation ? "\n(Use `node --trace-deprecation ...` to show where the warning was created)" : "\n(Use `node --trace-warnings ...` to show where the warning was created)"); @@ -1024,8 +1114,82 @@ ScrDyn *scr_promise_reason_dyn(const ScrPromise *p) { SCR_TL extern bool (*scr_dyn_promise_identity_fn)(ScrPromise *, ScrPromise *); +/* Promise.prototype.then, catch and finally as function values: one FUNC + * box per method and thread. A call reads its receiver from the ambient + * binding (`p.then.call(p, f)`, or the method read off the promise and + * called on it); a receiver that is not a promise throws Node's + * incompatible-receiver TypeError. */ +static const char *const scr_dyn_promise_method_names[3] = {"then", "catch", "finally"}; +static SCR_TL ScrDyn *scr_dyn_promise_methods[3]; + +static void scr_dyn_promise_methods_cleanup(void) { + for (size_t i = 0; i < 3; i++) { + if (scr_dyn_promise_methods[i]) scr_dyn_release(scr_dyn_promise_methods[i]); + scr_dyn_promise_methods[i] = NULL; + } +} + +static void scr_dyn_throw_incompatible_receiver(const char *method, const ScrDyn *self) { + char shown[64]; + const char *text = "#"; + if (scr_dyn_is_nullish(self)) { + text = self->kind == SCR_DYN_NULL ? "null" : "undefined"; + } else if (self->kind == SCR_DYN_BOOL) { + text = self->v.b ? "true" : "false"; + } else if (self->kind == SCR_DYN_STR) { + snprintf(shown, sizeof shown, "%.*s", (int)(self->v.str->len < 48 ? self->v.str->len : 48), self->v.str->data); + text = shown; + } else if (self->kind == SCR_DYN_NUM) { + ScrStr *s = scr_f64_to_scrstr(self->v.num); + snprintf(shown, sizeof shown, "%.*s", (int)s->len, s->data); + scr_str_release(s); + text = shown; + } + char message[160]; + int len = snprintf(message, sizeof message, + "Method Promise.prototype.%s called on incompatible receiver %s", method, text); + scr_throw_error_msg(SCR_ERR_TYPE, message, (size_t)len); +} + +static ScrDyn *scr_dyn_promise_method_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + size_t which = (size_t)scr_box_get_f64(closure->caps[0]); + ScrDyn *self = scr_dyn_this_get(); + ScrPromise *promise = scr_dyn_promise_of(self); + if (!promise) { + scr_dyn_throw_incompatible_receiver(scr_dyn_promise_method_names[which], self); + scr_dyn_release(self); + return NULL; + } + ScrDyn *first = argc >= 1 ? args[0] : NULL; + ScrDyn *result = which == 0 ? scr_dyn_promise_then(promise, first, argc >= 2 ? args[1] : NULL, NULL) + : which == 1 ? scr_dyn_promise_then(promise, NULL, first, NULL) + : scr_dyn_promise_then(promise, NULL, NULL, first); + scr_dyn_release(self); + return result; +} + +static ScrDyn *scr_dyn_promise_member(const char *key, size_t len) { + for (size_t i = 0; i < 3; i++) { + const char *name = scr_dyn_promise_method_names[i]; + if (strlen(name) != len || memcmp(name, key, len) != 0) continue; + if (!scr_dyn_promise_methods[i]) { + if (!scr_dyn_promise_methods[0] && !scr_dyn_promise_methods[1] && + !scr_dyn_promise_methods[2]) + scr_atexit(scr_dyn_promise_methods_cleanup); + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[0], (double)i); + scr_dyn_promise_methods[i] = + scr_dyn_new_func(closure, scr_dyn_promise_method_call, i == 0 ? 2 : 1, "", name); + } + return scr_dyn_retain(scr_dyn_promise_methods[i]); + } + return NULL; +} + ScrDyn *scr_dyn_new_promise(ScrPromise *p) { scr_dyn_promise_identity_fn = scr_promise_identity_equal; + scr_dyn_promise_member_fn = scr_dyn_promise_member; ScrDyn *d = scr_dyn_alloc_promise(scr_promise_release); d->v.promise = scr_promise_retain(p); return d; @@ -1042,6 +1206,7 @@ ScrDyn *scr_dyn_new_promise_adapting(ScrPromise *src, ScrPromise *dst = scr_promise_new(); scr_promise_share_identity(dst, src); scr_dyn_promise_identity_fn = scr_promise_identity_equal; + scr_dyn_promise_member_fn = scr_dyn_promise_member; scr_promise_race_add(dst, src, adapt); ScrDyn *d = scr_dyn_alloc_promise(scr_promise_release); d->v.promise = dst; /* the constructor's +1 moves in */ diff --git a/packages/runtime/src/scr_bytes.c b/packages/runtime/src/scr_bytes.c index e0fb55464..28258a29c 100644 --- a/packages/runtime/src/scr_bytes.c +++ b/packages/runtime/src/scr_bytes.c @@ -17,6 +17,11 @@ #include #include #include +#if defined(__SSE2__) +#include +#elif defined(__ARM_NEON) && defined(__aarch64__) +#include +#endif static bool scr_td_label_space(unsigned char c) { return c == 9 || c == 10 || c == 12 || c == 13 || c == 32; @@ -82,10 +87,60 @@ static void scr_bytes_oom(void) { scr_trap("scriptc: out of memory\n"); } +uint16_t scr_f16_from_f64(double value) { + uint64_t bits; + memcpy(&bits, &value, sizeof bits); + uint16_t sign = (uint16_t)((bits >> 48) & 0x8000); + uint64_t magnitude = bits & UINT64_C(0x7fffffffffffffff); + if (magnitude >= UINT64_C(0x7ff0000000000000)) { + if (magnitude == UINT64_C(0x7ff0000000000000)) return sign | 0x7c00; + return sign | 0x7e00 | (uint16_t)((magnitude >> 42) & 0x3ff); + } + int exponent = (int)(magnitude >> 52) - 1023; + if (exponent > 15) return sign | 0x7c00; + uint64_t fraction = magnitude & ((UINT64_C(1) << 52) - 1); + uint64_t significand = exponent == -1023 ? fraction : fraction | (UINT64_C(1) << 52); + if (exponent == -1023) exponent = -1022; + int biased = exponent + 15; + /* Normal results keep 10 of the 52 fraction bits; subnormal results + * shift further, by how far the exponent is below the normal range. */ + int shift = biased >= 1 ? 42 : 42 + (1 - biased); + if (shift >= 54) return sign; + uint64_t q = significand >> shift; + uint64_t rest = significand & ((UINT64_C(1) << shift) - 1); + uint64_t half = UINT64_C(1) << (shift - 1); + if (rest > half || (rest == half && (q & 1))) q++; + if (biased < 1) return sign | (uint16_t)q; /* a carry into 0x400 is the smallest normal */ + if (q == 2048) { + q = 1024; + biased++; + } + if (biased >= 31) return sign | 0x7c00; + return sign | (uint16_t)(biased << 10) | (uint16_t)(q & 0x3ff); +} + +double scr_f16_to_f64(uint16_t h) { + uint64_t sign = (uint64_t)(h >> 15) << 63; + unsigned exponent = (h >> 10) & 0x1f, fraction = h & 0x3ff; + uint64_t bits; + if (exponent == 0) { + double v = (double)fraction * 5.9604644775390625e-8; /* fraction * 2^-24, exact */ + memcpy(&bits, &v, sizeof bits); + bits |= sign; + } else if (exponent == 31) { + bits = sign | UINT64_C(0x7ff0000000000000) | ((uint64_t)fraction << 42); + } else { + bits = sign | ((uint64_t)(exponent - 15 + 1023) << 52) | ((uint64_t)fraction << 42); + } + double out; + memcpy(&out, &bits, sizeof out); + return out; +} + size_t scr_bytes_elem_size(ScrBytesElem elem) { switch (elem) { case SCR_BYTES_U8: case SCR_BYTES_U8C: case SCR_BYTES_I8: return 1; - case SCR_BYTES_U16: case SCR_BYTES_I16: return 2; + case SCR_BYTES_U16: case SCR_BYTES_I16: case SCR_BYTES_F16: return 2; case SCR_BYTES_U32: case SCR_BYTES_I32: case SCR_BYTES_F32: return 4; case SCR_BYTES_F64: return 8; } @@ -101,6 +156,7 @@ const char *scr_bytes_elem_name(ScrBytesElem elem) { case SCR_BYTES_I16: return "Int16Array"; case SCR_BYTES_U32: return "Uint32Array"; case SCR_BYTES_I32: return "Int32Array"; + case SCR_BYTES_F16: return "Float16Array"; case SCR_BYTES_F32: return "Float32Array"; case SCR_BYTES_F64: return "Float64Array"; } @@ -324,6 +380,11 @@ static double scr_bytes_decode(ScrBytesElem elem, const uint8_t *p) { memcpy(&v, p, 4); return (double)v; } + case SCR_BYTES_F16: { + uint16_t v; + memcpy(&v, p, 2); + return scr_f16_to_f64(v); + } case SCR_BYTES_F32: { float v; memcpy(&v, p, 4); @@ -363,6 +424,11 @@ static void scr_bytes_encode(ScrBytesElem elem, uint8_t *p, double v) { memcpy(p, &u, 4); break; } + case SCR_BYTES_F16: { + uint16_t h = scr_f16_from_f64(v); + memcpy(p, &h, 2); + break; + } case SCR_BYTES_F32: { float f = (float)v; /* round-to-nearest-even, exactly Float32Array */ memcpy(p, &f, 4); @@ -506,6 +572,13 @@ static void scr_bytes_read_numbers(double *out, const uint8_t *src, ScrBytesElem SCR_READ_NUMBERS(SCR_BYTES_I32, int32_t); SCR_READ_NUMBERS(SCR_BYTES_F32, float); SCR_READ_NUMBERS(SCR_BYTES_F64, double); + case SCR_BYTES_F16: + for (size_t i = 0; i < count; i++) { + uint16_t value; + memcpy(&value, src + i * sizeof value, sizeof value); + out[i] = scr_f16_to_f64(value); + } + return; } #undef SCR_READ_NUMBERS } @@ -530,6 +603,9 @@ static void scr_bytes_write_numbers(uint8_t *dst, ScrBytesElem elem, const doubl case SCR_BYTES_U32: case SCR_BYTES_I32: { SCR_WRITE_NUMBERS(uint32_t, scr_numeric_to_u32(src[i])); } + case SCR_BYTES_F16: { + SCR_WRITE_NUMBERS(uint16_t, scr_f16_from_f64(src[i])); + } case SCR_BYTES_F32: { SCR_WRITE_NUMBERS(float, (float)src[i]); } @@ -931,6 +1007,7 @@ static size_t scr_dataview_get_size(ScrDataViewGet kind) { return 1; case SCR_DV_U16: case SCR_DV_I16: + case SCR_DV_F16: return 2; case SCR_DV_U32: case SCR_DV_I32: @@ -969,6 +1046,8 @@ double scr_dataview_get(const ScrBytes *b, double byte_off, ScrDataViewGet kind, return (double)(int16_t)u; case SCR_DV_I32: return (double)(int32_t)u; + case SCR_DV_F16: + return scr_f16_to_f64((uint16_t)u); case SCR_DV_F32: { uint32_t bits = (uint32_t)u; float f; @@ -1013,6 +1092,9 @@ void scr_dataview_set(ScrBytes *b, double byte_off, double value, ScrDataViewGet case SCR_DV_I32: u = (uint64_t)scr_bytes_to_u32(value); break; + case SCR_DV_F16: + u = scr_f16_from_f64(value); + break; case SCR_DV_F32: { float f = (float)value; uint32_t bits; @@ -2457,6 +2539,22 @@ static ScrStr *scr_bytes_normalize_encoding(const ScrStr *raw) { return NULL; } +/* new StringDecoder(encoding) with an encoding known only at run time: + * Node's normalizeEncoding. undefined, null and "" are utf8; a Buffer + * encoding name in any ASCII case answers its canonical name (+1); any + * other string throws ERR_UNKNOWN_ENCODING (NULL). Encodings that are not + * strings have no lowering. */ +ScrStr *scr_strdec_encoding(const ScrDyn *enc) { + if (enc->kind == SCR_DYN_UNDEF || enc->kind == SCR_DYN_NULL) return scr_str_new("utf8", 4); + if (enc->kind != SCR_DYN_STR) { + static const char message[] = "new StringDecoder with an encoding that is not a string has no lowering"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return NULL; + } + if (enc->v.str->len == 0) return scr_str_new("utf8", 4); + return scr_bytes_normalize_encoding(enc->v.str); +} + ScrStr *scr_bytes_to_str_checked(const ScrBytes *b, const ScrStr *enc) { /* Node returns before resolving the encoding when there are no bytes * to decode, so even an unknown runtime name answers the empty string. */ @@ -3302,13 +3400,30 @@ double scr_bytes_write_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc, while (i < s->len && budget - written >= width) { #if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ /* utf16le of an ASCII run: zero-extend each byte to a little-endian - * unit, eight per step with two word stores (the generic loop below - * stores byte by byte, because the source and destination may alias - * as far as the compiler knows). */ + * unit, sixteen per step with vector unpacks where the target has them + * and eight per step with two word stores otherwise (the generic loop + * below stores byte by byte, because the source and destination may + * alias as far as the compiler knows). */ if (utf16) { uint8_t *d = b->data + o + written; size_t n = 0, room = (budget - written) / 16, left = (s->len - i) / 8; size_t steps = room < left ? room : left; +#if defined(__SSE2__) + const __m128i zero = _mm_setzero_si128(); + for (; n + 2 <= steps; n += 2) { + __m128i v = _mm_loadu_si128((const __m128i *)(s->data + i + 8 * n)); + if (_mm_movemask_epi8(v)) break; + _mm_storeu_si128((__m128i *)(d + 16 * n), _mm_unpacklo_epi8(v, zero)); + _mm_storeu_si128((__m128i *)(d + 16 * n + 16), _mm_unpackhi_epi8(v, zero)); + } +#elif defined(__ARM_NEON) && defined(__aarch64__) + for (; n + 2 <= steps; n += 2) { + uint8x16_t v = vld1q_u8((const uint8_t *)s->data + i + 8 * n); + if (vmaxvq_u8(v) & 0x80) break; + vst1q_u8(d + 16 * n, vreinterpretq_u8_u16(vmovl_u8(vget_low_u8(v)))); + vst1q_u8(d + 16 * n + 16, vreinterpretq_u8_u16(vmovl_high_u8(v))); + } +#endif for (; n < steps; n++) { uint64_t w; memcpy(&w, s->data + i + 8 * n, 8); diff --git a/packages/runtime/src/scr_bytes.h b/packages/runtime/src/scr_bytes.h index b56f9477d..4309154c9 100644 --- a/packages/runtime/src/scr_bytes.h +++ b/packages/runtime/src/scr_bytes.h @@ -20,7 +20,8 @@ * Elements are scalars only and the backing edge is acyclic by * construction (owners point at nothing): never part of a cycle, no * trace. Element reads widen to double; writes coerce JS-exactly (ToUint8 - * / ToUint32 modular truncation, double→float rounding for f32). + * / ToUint32 modular truncation, double→float rounding for f32, one + * double→binary16 rounding for f16). * Out-of-bounds typed ELEMENT reads trap like arrays; invalid writes are * ignored as in JavaScript. The Node-shaped operations * (construction lengths, set(), the read/write numeric families, the @@ -37,7 +38,16 @@ typedef enum ScrBytesElem { SCR_BYTES_U16, /* Uint16Array */ SCR_BYTES_I16, /* Int16Array */ SCR_BYTES_U8C, /* Uint8ClampedArray */ + SCR_BYTES_F16, /* Float16Array (IEEE 754 binary16 storage) */ } ScrBytesElem; +#define SCR_BYTES_ELEM_COUNT 10 + +/* Float16Array's element conversions: one round-to-nearest-even step from + * the double (Float16Array stores, Math.f16round and DataView.setFloat16 + * all round this way), and the exact widening back. NaN keeps its sign and + * top payload bits with the quiet bit set. */ +uint16_t scr_f16_from_f64(double value); +double scr_f16_to_f64(uint16_t bits); typedef struct ScrSharedBytes ScrSharedBytes; @@ -94,6 +104,26 @@ ScrDyn *scr_worker_parent_port(void); * (scr_worker_events.c). */ ScrDyn *scr_worker_message_channel(void); ScrDyn *scr_worker_receive_message_on_port(ScrDyn *port); +/* new BroadcastChannel(...args) (scr_worker_events.c): args is the argument + * vector as one array (borrowed); may throw. */ +ScrDyn *scr_worker_broadcast_channel(ScrDyn *args); +/* setEnvironmentData(key, value) and getEnvironmentData(key) (+1); both + * borrow their arguments. */ +void scr_worker_set_environment_data(ScrDyn *key, ScrDyn *value); +ScrDyn *scr_worker_get_environment_data(ScrDyn *key); +ScrDyn *scr_worker_resource_limits(void); +ScrDyn *scr_worker_thread_name_current(void); +/* markAsUntransferable(value) and isMarkedAsUntransferable(value) + * (scr_worker_events.c); both borrow the value. */ +void scr_worker_mark_untransferable(ScrDyn *value); +bool scr_worker_is_marked_untransferable(ScrDyn *value); +/* worker.stdout, worker.stderr and worker.stdin streams: main installs the + * stream unit's operations and the Readable and Writable boxes (the + * Writable one may be NULL) when the program reads them. */ +struct ScrChildStreamOps; +void scr_worker_stdio_install(const struct ScrChildStreamOps *ops, ScrDyn *(*readable_box)(void *), + ScrDyn *(*writable_box)(void *)); +void scr_worker_stdio_default(const struct ScrChildStreamOps *ops, ScrDyn *(*view)(void *)); ScrDyn *scr_worker_new(double root, ScrStr *filename, ScrDyn *options); bool scr_shared_array_buffer_is(const ScrDyn *); bool scr_buffer_storage_is(const ScrDyn *); @@ -119,6 +149,7 @@ double scr_bytes_to_u8_clamp(double value); ScrStr *scr_strdec_write(const ScrStr *enc, double pending, const ScrBytes *chunk); double scr_strdec_next(const ScrStr *enc, double pending, const ScrBytes *chunk); ScrStr *scr_strdec_end(const ScrStr *enc, double pending); +ScrStr *scr_strdec_encoding(const ScrDyn *enc); /* +1, or NULL with ERR_UNKNOWN_ENCODING pending */ /* `new Uint8Array(n)` / Buffer.alloc(n): zero-filled. n goes through * ToIndex like JS (NaN → 0, truncate toward zero; negative or > 2^53-1 @@ -148,6 +179,7 @@ ScrBytes *scr_array_buffer_view_i16(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_u8(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_u32(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_i32(ScrDyn *, ScrDyn *, ScrDyn *); +ScrBytes *scr_array_buffer_view_f16(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_f32(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_f64(ScrDyn *, ScrDyn *, ScrDyn *); ScrBytes *scr_array_buffer_view_dv(ScrDyn *, ScrDyn *, ScrDyn *); @@ -205,7 +237,7 @@ ScrBytes *scr_dataview_new(ScrBytes *src, double byte_off, bool has_len, double * with the exception pending). */ typedef enum ScrDataViewGet { SCR_DV_U8, SCR_DV_I8, SCR_DV_U16, SCR_DV_I16, SCR_DV_U32, SCR_DV_I32, - SCR_DV_F32, SCR_DV_F64, SCR_DV_BIGU64, SCR_DV_BIGI64, + SCR_DV_F32, SCR_DV_F64, SCR_DV_BIGU64, SCR_DV_BIGI64, SCR_DV_F16, } ScrDataViewGet; double scr_dataview_get(const ScrBytes *b, double byte_off, ScrDataViewGet kind, bool le); diff --git a/packages/runtime/src/scr_child.c b/packages/runtime/src/scr_child.c index 96a900d1a..2d72c21d2 100644 --- a/packages/runtime/src/scr_child.c +++ b/packages/runtime/src/scr_child.c @@ -1552,6 +1552,7 @@ static bool scr_child_stream_pump(ScrChildStream *s) { scr_child_stream_capture_append(s, buf, (size_t)got); if (s->n_data == 0) continue; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)got); + chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, buf, (size_t)got); ScrStr *text = NULL; if (s->encoding != NULL) { @@ -4068,6 +4069,7 @@ static bool scr_child_stream_pump(ScrChildStream *s) { scr_child_stream_capture_append(s, buf, (size_t)n); if (s->n_data == 0) continue; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, buf, (size_t)n); ScrStr *text = NULL; if (s->encoding != NULL) { diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index d6e4e93fb..1e031a2d0 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -204,6 +204,61 @@ void scr_init(void) { /* A real definition lets relocatable library links localize this hook; * a tentative common symbol stays externally visible on Mach-O. */ SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide) = NULL; +SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len) = NULL; + +SCR_TL bool scr_stdin_fed = false; +SCR_TL void (*scr_stdin_fed_attach)(void) = NULL; +SCR_TL bool (*scr_stdin_fed_waiting)(void) = NULL; +static SCR_TL ScrBytes **scr_stdin_fed_q; +static SCR_TL size_t scr_stdin_fed_head, scr_stdin_fed_len, scr_stdin_fed_cap; +static SCR_TL bool scr_stdin_fed_end; + +void scr_stdin_fed_push(ScrBytes *chunk) { + if (scr_stdin_fed_end) { + if (chunk) scr_bytes_release(chunk); + return; + } + if (!chunk) { + scr_stdin_fed_end = true; + return; + } + if (scr_stdin_fed_head + scr_stdin_fed_len == scr_stdin_fed_cap) { + if (scr_stdin_fed_head > 0) { + memmove(scr_stdin_fed_q, scr_stdin_fed_q + scr_stdin_fed_head, + scr_stdin_fed_len * sizeof *scr_stdin_fed_q); + scr_stdin_fed_head = 0; + } + if (scr_stdin_fed_len == scr_stdin_fed_cap) { + scr_stdin_fed_cap = scr_stdin_fed_cap ? scr_stdin_fed_cap * 2 : 8; + scr_stdin_fed_q = realloc(scr_stdin_fed_q, scr_stdin_fed_cap * sizeof *scr_stdin_fed_q); + if (!scr_stdin_fed_q) scr_trap("scriptc: out of memory\n"); + } + } + scr_stdin_fed_q[scr_stdin_fed_head + scr_stdin_fed_len++] = chunk; +} + +ScrBytes *scr_stdin_fed_take(bool *ended) { + if (scr_stdin_fed_len == 0) { + *ended = scr_stdin_fed_end; + return NULL; + } + *ended = false; + ScrBytes *chunk = scr_stdin_fed_q[scr_stdin_fed_head++]; + if (--scr_stdin_fed_len == 0) scr_stdin_fed_head = 0; + return chunk; +} + +void scr_stdin_fed_clear(void) { + for (size_t i = 0; i < scr_stdin_fed_len; i++) + scr_bytes_release(scr_stdin_fed_q[scr_stdin_fed_head + i]); + free(scr_stdin_fed_q); + scr_stdin_fed_q = NULL; + scr_stdin_fed_head = scr_stdin_fed_len = scr_stdin_fed_cap = 0; + scr_stdin_fed_end = false; + scr_stdin_fed = false; + scr_stdin_fed_attach = NULL; + scr_stdin_fed_waiting = NULL; +} /* Lines up to this size render on the C stack (fiber stacks are 256 KiB). */ #define SCR_CONSOLE_STACK_LINE 2048 @@ -338,7 +393,7 @@ static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool glob #if SCR_STDIO_DIRECT /* A large single string goes out without a copy: string + newline in one * vectored write. */ - if (!scr_stdio_write_hook && !(global && scr_console_indenter) && n == 1 && + if (!scr_stdio_write_hook && !scr_stdio_capture && !(global && scr_console_indenter) && n == 1 && (args[0].tag & SCR_ARG_KIND) == SCR_ARG_STR && args[0].v.s->len >= SCR_CONSOLE_STACK_LINE) { char *owned; @@ -409,6 +464,7 @@ void scr_console_write_fd(int fd, size_t n, const ScrLogArg *args) { * Buffered runtime-internal stdout bytes go out first (for both streams) to * preserve stdout order and the merged-fd ordering convention. */ int scr_stdio_write_raw(int fd, const void *data, size_t len) { + if (__builtin_expect(scr_stdio_capture != NULL, 0) && scr_stdio_capture(fd == 2 ? 2 : 1, data, len)) return 0; #if SCR_STDIO_DIRECT FILE *locked = scr_fd_stream_lock(fd == 2 ? 2 : 1); if (scr_stdout_pending()) fflush(stdout); diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index fc2686374..d5bb78957 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -23,6 +23,7 @@ static const atomic_bool scr_context_raised = true; static SCR_TL const atomic_bool *scr_context_signal = &scr_context_unsignaled; static SCR_TL bool scr_context_stopped; SCR_TL void (*scr_context_report_error)(void); +SCR_TL bool scr_context_report_rejection; typedef struct { char *name; char *value; } ScrContextEnvEntry; struct ScrContextEnv { size_t count; ScrContextEnvEntry *entries; }; @@ -196,6 +197,13 @@ double scr_context_thread_number(void) { return 0; } double scr_worker_root(void) { return -1; } ScrDyn *scr_worker_data(void) { return scr_dyn_new_null(); } ScrDyn *scr_worker_parent_port(void) { return scr_dyn_new_null(); } +ScrDyn *scr_worker_resource_limits(void) { return scr_dyn_new_obj(); } +ScrDyn *scr_worker_thread_name_current(void) { + ScrStr *empty = scr_str_new("", 0); + ScrDyn *name = scr_dyn_new_str(empty); + scr_str_release(empty); + return name; +} #endif /* ── native stack guard ───────────────────────────────────────────────── diff --git a/packages/runtime/src/scr_core.h b/packages/runtime/src/scr_core.h index a640169fa..6d251e8ce 100644 --- a/packages/runtime/src/scr_core.h +++ b/packages/runtime/src/scr_core.h @@ -294,6 +294,15 @@ double scr_library_i64_in(int64_t v, const char *trap_msg); double scr_library_u64_in(uint64_t v, const char *trap_msg); void scr_library_str_out(ScrStr *s, const uint8_t **out, size_t *out_len); void scr_library_bytes_out(ScrBytes *b, const uint8_t **out, size_t *out_len); +/* The cstring class (a NUL-terminated `const char *`). Inbound: a copy + * decoded as UTF-8 with U+FFFD replacement; NULL delivers trap_msg (the + * assembled SC4012 message). Outbound results move into the arena and + * return their data; callback arguments return the string's own data, + * borrowed for the call. A string containing U+0000 cannot be a C string: + * both outbound forms trap instead of truncating. */ +ScrStr *scr_library_cstr_in(const char *p, const char *trap_msg); /* +1 */ +const char *scr_library_cstr_out(ScrStr *s); +const char *scr_library_cstr_arg(const ScrStr *s); #define scr_atexit(fn) scr_library_register_reset(fn) #elif defined(SCR_WORKERS) @@ -332,6 +341,8 @@ void scr_exc_alert_add(int delta); /* worker.terminate() references */ void scr_exc_alert_thread_exit(void); void scr_loop_context_shutdown(void); extern SCR_TL void (*scr_context_report_error)(void); +/* Set while scr_context_report_error reports an unhandled rejection. */ +extern SCR_TL bool scr_context_report_rejection; typedef struct ScrContextEnv ScrContextEnv; ScrContextEnv *scr_context_env_capture(void); void scr_context_env_free(ScrContextEnv *environment); diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 229122e71..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(); @@ -926,6 +1003,9 @@ static void scr_mark_gray(void *obj, uintptr_t depth) { ScrCycHdr *h = scr_cyc_hdr(obj); if (scr_cyc_color(h) == SCR_CYC_GRAY) return; scr_cyc_set_color(h, SCR_CYC_GRAY); + /* A full pass takes every buffer, so each buffered object it reaches + * leaves its buffer here, in the header write the walk makes anyway. */ + if (scr_gen_limit == SCR_CYC_OLD && !scr_cyc_take) scr_cyc_clear_buffered(h); if (scr_white.n < SCR_CYC_GRAY_LIST_MAX) scr_cyc_push_work(&scr_white, obj); if (SCR_RC(obj) > 0) scr_gray_referenced++; if (depth == SCR_CYC_WALK_DEPTH) { @@ -1047,6 +1127,10 @@ static SCR_CYC_COMPACT void scr_xg_release(void *obj) { scr_rc_destroy(obj, scr_xg_destroy); } +/* Dead sets at least this large are torn down in address order. Smaller + * ones mostly stay in cache, where ordering them costs more than it saves. */ +#define SCR_CYC_ORDER_MIN 16384 + /* One pass over every candidate at or below `gen_limit`; returns how many * objects it freed. */ /* The second half of a pass: settle the white set, promote, re-buffer, @@ -1125,8 +1209,18 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit) { /* Teardowns run after the full walk. They may release untraced children * (plain RC) — which can re-buffer survivors for the NEXT pass — but - * never touch traced (white, already-accounted) edges. */ + * never touch traced (white, already-accounted) edges. + * + * A large dead set is torn down in address order, highest address first + * (scr_mem_order_for_free). The walk leaves it in graph order, in which + * the free lists would hand its blocks to the next allocations scattered + * over every page the dead structure used, and the structures built from + * them, and every later walk over those, would miss the cache on most + * objects. In address order the blocks come back in the order the bump + * pointers hand out fresh memory. Teardown order is not observable. */ size_t freed = scr_white.n; + if (scr_white.n >= SCR_CYC_ORDER_MIN) + scr_mem_order_for_free(scr_white.v, scr_white.n, sizeof(ScrCycHdr)); for (size_t i = 0; i < scr_white.n; i++) { void *obj = scr_white.v[i]; scr_cyc_type(scr_cyc_hdr(obj))->free_fn(obj); @@ -1150,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; @@ -1242,18 +1345,23 @@ static size_t scr_cyc_pass(unsigned gen_limit) { scr_cands = scr_roots[largest]; scr_roots[largest] = previous; scr_roots[largest].n = 0; - for (size_t i = 0; i < scr_cands.n; i++) { - ScrCycHdr *h = scr_cyc_hdr(scr_cands.v[i]); - scr_cyc_clear_buffered(h); - if (gen_limit < SCR_CYC_OLD) scr_cyc_walked(scr_cyc_type(h), gen_limit); - } + /* A full pass clears the buffered marks as markGray reaches the + * candidates, so only a limited level's candidates are touched here. */ + if (gen_limit < SCR_CYC_OLD) + for (size_t i = 0; i < scr_cands.n; i++) { + ScrCycHdr *h = scr_cyc_hdr(scr_cands.v[i]); + scr_cyc_clear_buffered(h); + scr_cyc_walked(scr_cyc_type(h), gen_limit); + } } for (unsigned g = 0; g <= gen_limit && !scr_cyc_take; g++) { for (size_t i = 0; i < scr_roots[g].n; i++) { void *obj = scr_roots[g].v[i]; - ScrCycHdr *h = scr_cyc_hdr(obj); - scr_cyc_clear_buffered(h); - if (gen_limit < SCR_CYC_OLD) scr_cyc_walked(scr_cyc_type(h), gen_limit); + if (gen_limit < SCR_CYC_OLD) { + ScrCycHdr *h = scr_cyc_hdr(obj); + scr_cyc_clear_buffered(h); + scr_cyc_walked(scr_cyc_type(h), gen_limit); + } scr_cyc_push(&scr_cands, obj); } scr_roots[g].n = 0; diff --git a/packages/runtime/src/scr_dgram.c b/packages/runtime/src/scr_dgram.c index f03c6876b..13650a35d 100644 --- a/packages/runtime/src/scr_dgram.c +++ b/packages/runtime/src/scr_dgram.c @@ -1122,6 +1122,7 @@ static void scr_dgram_read(ScrDgramSocket *s) { rport = ntohs(((struct sockaddr_in *)&from)->sin_port); } ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; /* the message is a Buffer */ memcpy(chunk->data, buf, (size_t)n); ScrStr *addr = scr_str_new(ip, strlen(ip)); ScrStr *family = scr_str_new("IPv4", 4); @@ -1335,7 +1336,10 @@ static void scr_dns_warn_tick(ScrClosure *self) { #endif fprintf(stderr, "(node:%ld) [DEP0118] DeprecationWarning: %.*s\n", pid, (int)message->len, message->data); - fputs("(Use `node --trace-deprecation ...` to show where the warning was created)\n", stderr); + if (!scr_warning_trace_hint_shown) { + scr_warning_trace_hint_shown = true; + fputs("(Use `node --trace-deprecation ...` to show where the warning was created)\n", stderr); + } } scr_str_release(message); } diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 2895487d9..667ceced0 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -166,6 +166,11 @@ typedef enum { SCR_DYNH_DIR, /* fs.Dir (scr_lib.c) */ SCR_DYNH_CIPHER, /* node:crypto Cipheriv (scr_cipher.c) */ SCR_DYNH_DECIPHER, /* node:crypto Decipheriv (scr_cipher.c) */ + SCR_DYNH_NET_SOCKET_ADDRESS, /* net.SocketAddress (scr_net.c) */ + SCR_DYNH_NET_BLOCK_LIST, /* net.BlockList (scr_net.c) */ + SCR_DYNH_DISPOSABLE_STACK, /* DisposableStack (scr_symbol.c) */ + SCR_DYNH_MESSAGE_EVENT, /* ScrEvent with data — BroadcastChannel's 'message' */ + SCR_DYNH_BROADCAST_CHANNEL, /* worker_threads BroadcastChannel (scr_worker_events.c) */ SCR_DYNH_COUNT, } ScrDynHandleTag; @@ -595,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); @@ -617,6 +623,19 @@ ScrDyn *scr_dyn_symbol_key_get(const ScrDyn *value, const ScrDyn *key, bool opti * and return NULL with the exception pending. */ ScrDyn *scr_dyn_using_method(const ScrDyn *value, bool async); ScrDyn *scr_dyn_using_dispose(const ScrDyn *method, const ScrDyn *value); +/* DisposableStack (scr_symbol.c). scr_disposable_stack_new answers a new + * stack (+1). The use, adopt and defer calls borrow their operands, throw + * Node's ReferenceError on a disposed stack and Node's TypeError on an + * invalid resource or callback, and return false with the exception + * pending. scr_disposable_stack_use_callback registers `dispose(value)` + * with the error texts of use(); the compiler passes it the disposal of a + * class instance, whose dyn view has no methods. The stack's other members + * are function values and getters of its dyn handle. */ +ScrDyn *scr_disposable_stack_new(void); +bool scr_disposable_stack_is(const ScrDyn *value); +bool scr_disposable_stack_use(const ScrDyn *stack, const ScrDyn *value); +bool scr_disposable_stack_use_callback(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *dispose); +bool scr_disposable_stack_adopt(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *on_dispose); void scr_dyn_symbol_key_set(ScrDyn *value, ScrDyn *key, ScrDyn *stored); void scr_dyn_symbol_key_placeholder(ScrDyn *value, ScrDyn *key); bool scr_dyn_has_key_computed(const ScrDyn *value, const ScrDyn *key); @@ -651,6 +670,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); @@ -871,6 +894,8 @@ ScrDyn *scr_dyn_apply_array_like(const ScrDyn *target, const ScrDyn *receiver, c 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. */ @@ -937,6 +962,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); @@ -1126,6 +1158,10 @@ typedef struct ScrDynHandleOps { /* The view has no properties, so util.inspect prints it in full at any * depth, as Node prints `{}`. */ bool inspect_view_empty; + /* A symbol-keyed READ (`stack[Symbol.dispose]`, the `using` method + * lookup): +1 value, or NULL when the class has no member for `key` + * (the read answers undefined). NULL hook = no symbol-keyed members. */ + ScrDyn *(*symbol_get)(void *h, const ScrDyn *key); } ScrDynHandleOps; ScrDyn *scr_dyn_native_handle_iterator(ScrDyn *source, unsigned selection); @@ -1147,6 +1183,13 @@ ScrDyn *scr_dyn_new_promise_adapting(ScrPromise *src, void (*adapt)(ScrPromise *dst, ScrPromise *src)); /* BORROWED peek at the boxed promise; NULL when d is not a promise box. */ ScrPromise *scr_dyn_promise_of(const ScrDyn *d); +/* A property read of a promise box (+1): Promise.prototype.then, catch + * and finally as function values, and undefined for every other key + * (promises have no own properties). The method values come from the + * gated async unit, which installs scr_dyn_promise_member_fn when it boxes + * a promise. */ +ScrDyn *scr_dyn_promise_key_get(const ScrDyn *d, const ScrStr *k); +extern SCR_TL ScrDyn *(*scr_dyn_promise_member_fn)(const char *key, size_t len); /* Promise.all / allSettled / any over a spread dyn array (borrowed); * `reader` (borrowed) reads a program class instance's `then`. +1. */ ScrPromise *scr_dyn_promise_all(const ScrDyn *entries, const ScrDyn *reader); @@ -1208,11 +1251,18 @@ ScrDyn *scr_gen_receiver(void); void scr_dyn_chunk_enc(bool utf8); ScrDyn *scr_dyn_new_chunk(const ScrBytes *b); -/* util.format's %j over a dyn value: the JSON.stringify text (+1), +/* The JSON text of a dyn value: the JSON.stringify text (+1), * "undefined" when the root drops (undefined/function), or NULL with a * pending exception (a runtime handle inside the tree throws the "not * supported yet" Error). */ ScrStr *scr_dyn_format_j(const ScrDyn *d); +/* util.format's %j (Node's tryStringify), +1: over a dyn value, the + * JSON.stringify(d) text, or "undefined" when it returns undefined; over + * the text of a typed serializer that may have thrown, the text itself. + * A circular-structure TypeError becomes "[Circular]"; any other + * exception stays pending and the result is NULL. */ +ScrStr *scr_util_format_j(const ScrDyn *d); +ScrStr *scr_util_format_j_text(ScrStr *text); /* borrows text */ /* Node's ERR_INVALID_ARG_TYPE listener gate over a dyn value — shared by * the emitter unit's checked-dynamic registrations and the handle * dispatchers' .on(...) paths (both render errors.js's diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 72d277271..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]"); @@ -1520,7 +1517,7 @@ static ScrDyn *scr_dyn_invoke_impl( if (recv->kind == SCR_DYN_BYTES) { ScrBytes *bytes = recv->v.bytes; if (bytes->is_data_view) { - static const char *const kinds[] = { "Uint8", "Int8", "Uint16", "Int16", "Uint32", "Int32", "Float32", "Float64", "BigUint64", "BigInt64" }; + static const char *const kinds[] = { "Uint8", "Int8", "Uint16", "Int16", "Uint32", "Int32", "Float32", "Float64", "BigUint64", "BigInt64", "Float16" }; const bool get = !strncmp(method, "get", 3); const bool set = !strncmp(method, "set", 3); for (size_t kind = 0; (get || set) && kind < sizeof kinds / sizeof kinds[0]; kind++) { @@ -1537,7 +1534,7 @@ static ScrDyn *scr_dyn_invoke_impl( } const bool le = argc > (get ? 1 : 2) && scr_dyn_truthy(args[get ? 1 : 2]); if (get) { - if (kind >= SCR_DV_BIGU64) { + if (kind == SCR_DV_BIGU64 || kind == SCR_DV_BIGI64) { ScrBigInt *value = scr_bigint_dataview_get(bytes, offset, kind == SCR_DV_BIGI64, le); if (scr_exc_pending()) return NULL; ScrDyn *result = scr_dyn_new_bigint(value); @@ -1547,7 +1544,7 @@ static ScrDyn *scr_dyn_invoke_impl( double value = scr_dataview_get(bytes, offset, (ScrDataViewGet)kind, le); return scr_exc_pending() ? NULL : scr_dyn_new_num(value); } - if (kind >= SCR_DV_BIGU64) { + if (kind == SCR_DV_BIGU64 || kind == SCR_DV_BIGI64) { ScrBigInt *value = scr_dyn_bigint_coerce(argc > 1 ? args[1] : scr_dyn_undefined()); if (scr_exc_pending()) return NULL; scr_bigint_dataview_set(bytes, offset, value, le); diff --git a/packages/runtime/src/scr_event_target.c b/packages/runtime/src/scr_event_target.c index 722d7e5bf..7c8a465a6 100644 --- a/packages/runtime/src/scr_event_target.c +++ b/packages/runtime/src/scr_event_target.c @@ -292,8 +292,6 @@ static bool scr_et_listener_same(const ScrDyn *a, const ScrDyn *b) { /* ── the warnings Node prints through process.emitWarning ───────────── */ -static SCR_TL bool scr_et_trace_hint_shown; - typedef struct { char name[48]; ScrStr *message; @@ -315,8 +313,8 @@ static void scr_et_warn_tick(ScrClosure *cb) { fflush(stdout); fprintf(stderr, "(node:%ld) %s: %.*s\n", SCR_ET_PID(), w->name, (int)w->message->len, w->message->data); - if (!scr_et_trace_hint_shown) { - scr_et_trace_hint_shown = true; + if (!scr_warning_trace_hint_shown) { + scr_warning_trace_hint_shown = true; fputs("(Use `node --trace-warnings ...` to show where the warning was created)\n", stderr); } } @@ -785,7 +783,8 @@ static ScrEventObj *scr_event_obj_of(const ScrDyn *d); static ScrEvent *scr_event_of(const ScrDyn *d) { if (d && d->kind == SCR_DYN_HANDLE && - (d->v.handle.tag == SCR_DYNH_EVENT || d->v.handle.tag == SCR_DYNH_CUSTOM_EVENT)) { + (d->v.handle.tag == SCR_DYNH_EVENT || d->v.handle.tag == SCR_DYNH_CUSTOM_EVENT || + d->v.handle.tag == SCR_DYNH_MESSAGE_EVENT)) { return (ScrEvent *)d->v.handle.ptr; } ScrEventObj *obj = scr_event_obj_of(d); @@ -1079,6 +1078,17 @@ static ScrDyn *scr_event_get(void *ptr, const char *key, size_t len) { if (scr_et_key(key, len, "isTrusted")) return scr_dyn_new_bool(ev->trusted); if (ev->custom && scr_et_key(key, len, "detail")) return ev->detail ? scr_dyn_retain(ev->detail) : scr_dyn_new_null(); + if (ev->message) { + if (scr_et_key(key, len, "data")) return ev->detail ? scr_dyn_retain(ev->detail) : scr_dyn_new_null(); + if (scr_et_key(key, len, "origin") || scr_et_key(key, len, "lastEventId")) { + ScrStr *empty = scr_str_new("", 0); + ScrDyn *value = scr_dyn_new_str(empty); + scr_str_release(empty); + return value; + } + if (scr_et_key(key, len, "source")) return scr_dyn_new_null(); + if (scr_et_key(key, len, "ports")) return scr_dyn_new_arr(); + } return scr_et_method_value(key, len, false); } @@ -1172,6 +1182,16 @@ static const ScrDynHandleOps scr_event_ops = { .inspect_view = scr_event_inspect_view, }; +static const ScrDynHandleOps scr_message_event_ops = { + .cls = "MessageEvent", + .retain = scr_event_retain_v, + .release = scr_event_release_v, + .invoke = scr_event_invoke, + .get = scr_event_get, + .set = scr_event_set, + .inspect_view = scr_event_inspect_view, +}; + static const ScrDynHandleOps scr_custom_event_ops = { .cls = "CustomEvent", .retain = scr_event_retain_v, @@ -1493,6 +1513,7 @@ static void scr_et_install(void) { installed = true; scr_dyn_handle_install(SCR_DYNH_EVENT, &scr_event_ops); scr_dyn_handle_install(SCR_DYNH_CUSTOM_EVENT, &scr_custom_event_ops); + scr_dyn_handle_install(SCR_DYNH_MESSAGE_EVENT, &scr_message_event_ops); scr_dyn_handle_install(SCR_DYNH_EVENT_TARGET, &scr_event_target_ops); scr_event_ref_hooks = &scr_event_obj_ref_hooks; scr_et_register_teardown(); @@ -1592,6 +1613,46 @@ ScrDyn *scr_custom_event_new(ScrDyn *args) { return out; } +/* ── BroadcastChannel's EventTarget side ───────────────────────────── */ + +static ScrEventTargetCore *scr_et_broadcast_core_new(const char *cls) { + ScrEventTargetCore *core = scr_et_calloc(sizeof *core); + scr_et_core_init(core, 10, cls); + return core; +} + +static void scr_et_broadcast_core_free(ScrEventTargetCore *core) { + scr_et_core_drop(core); + free(core); +} + +/* the 'message' MessageEvent (not trusted, as in Node) with the data */ +static void scr_et_broadcast_dispatch(ScrEventTargetCore *core, ScrDyn *self, ScrDyn *data) { + ScrEvent *ev = scr_event_alloc(scr_str_new("message", 7), false); + ev->message = true; + ev->detail = scr_dyn_retain(data); + scr_event_track(ev); + ScrDyn *event = scr_dyn_new_handle(ev, SCR_DYNH_MESSAGE_EVENT); + scr_event_release_v(ev); + scr_et_dispatch(core, self, event); + scr_dyn_release(event); +} + +static ScrDyn *scr_et_broadcast_method_value(const char *name, size_t length) { + return scr_et_method_value(name, length, true); +} + +static const ScrEtBroadcastOps scr_et_broadcast_table = { + &scr_et_broadcast_core_new, &scr_et_broadcast_core_free, &scr_et_broadcast_dispatch, + &scr_et_target_method, &scr_et_broadcast_method_value, + &scr_et_handler_get, &scr_et_handler_set, +}; + +void scr_et_broadcast_install(void) { + scr_et_install(); + scr_et_broadcast_ops = &scr_et_broadcast_table; +} + bool scr_event_is(const ScrDyn *v, double kind) { if (v->kind == SCR_DYN_TYPED_REF) { /* Compiled subclasses extend Event or EventTarget (never CustomEvent). */ @@ -1600,7 +1661,8 @@ bool scr_event_is(const ScrDyn *v, double kind) { } if (v->kind != SCR_DYN_HANDLE) return false; ScrDynHandleTag tag = v->v.handle.tag; - if (kind == 0) return tag == SCR_DYNH_EVENT || tag == SCR_DYNH_CUSTOM_EVENT; + if (kind == 0) + return tag == SCR_DYNH_EVENT || tag == SCR_DYNH_CUSTOM_EVENT || tag == SCR_DYNH_MESSAGE_EVENT; if (kind == 1) return tag == SCR_DYNH_CUSTOM_EVENT; return tag == SCR_DYNH_EVENT_TARGET || (scr_et_signal_core_fn != NULL && tag == SCR_DYNH_ABORT_SIGNAL); @@ -1683,3 +1745,213 @@ ScrDyn *scr_events_add_abort_listener(ScrDyn *signal, ScrDyn *listener) { } return scr_dyn_new_func(dispose, scr_et_abort_listener_dispose, 0, "()", "[Symbol.dispose]"); } + +/* ── node:timers/promises with { signal } ───────────────────────────── + * setTimeout(delay, value, { signal, ref }) and setImmediate(value, + * { signal, ref }), as lib/timers/promises.js runs them. An undefined + * signal takes the plain form. A value that is not an AbortSignal rejects + * with ERR_INVALID_ARG_TYPE, and an aborted signal rejects with an + * AbortError whose cause is signal.reason, both without arming the timer. + * Otherwise an 'abort' listener clears the timer (or immediate) and + * rejects with that AbortError. The result settles like the timer's + * promise once the listener is removed (scr_tp_abort_entry). */ +typedef struct { + size_t rc; + ScrPromise *inner; /* settled by the timer or the abort; owned */ + ScrPromise *result; /* the returned promise; owned */ + ScrDyn *signal; /* owned */ + ScrDyn *listener; /* owned, NULL until registered */ + double handle; + bool immediate; + int step; /* the next step of scr_tp_abort_entry */ + bool settled; /* the timer fired or the signal aborted */ +} ScrTpAbort; + +static void *scr_tp_abort_retain_v(void *p) { + ((ScrTpAbort *)p)->rc++; + return p; +} + +static void scr_tp_abort_release_v(void *p) { + ScrTpAbort *t = p; + if (--t->rc != 0) return; + scr_promise_release(t->inner); + scr_promise_release(t->result); + scr_dyn_release(t->signal); + if (t->listener) scr_dyn_release(t->listener); + free(t); +} + +static ScrBox *scr_tp_abort_box(ScrTpAbort *t) { + ScrBox *box = scr_box_new_obj(&scr_tp_abort_retain_v, &scr_tp_abort_release_v, NULL); + scr_box_set_ref(box, scr_tp_abort_retain_v(t)); + return box; +} + +/* new AbortError(undefined, { cause: signal.reason }), thrown. */ +static void scr_tp_throw_abort(ScrDyn *signal) { + ScrStr *message = scr_str_new("The operation was aborted", 25); + ScrError *e = scr_error_new(SCR_ERR_ERROR, message); + scr_str_release(message); + scr_str_release(e->name); + e->name = scr_str_new("AbortError", 10); + /* AbortError assigns code, then name: own enumerable properties */ + scr_error_set_code(e, "ABORT_ERR"); + e->name_present = true; + e->name_enumerable = true; + ScrStr *k = scr_str_new("reason", 6); + ScrDyn *reason = scr_dyn_handle_key_get(signal, k); + scr_str_release(k); + scr_error_define_cause(e, reason ? reason : scr_dyn_undefined()); + if (reason) scr_dyn_release(reason); + scr_throw_obj(e, &scr_error_retain_v, &scr_error_release_v, scr_error_trace_arg()); +} + +/* The timer or immediate fired: the timer's promise takes the value. */ +static void scr_tp_abort_fire(ScrClosure *self) { + ScrTpAbort *t = scr_box_get_ref(self->caps[0]); + ScrPromise *value = scr_box_get_ref(self->caps[1]); + if (!t->settled) { + t->settled = true; + scr_promise_adapt_copy(t->inner, value); + } + scr_promise_release(value); + scr_tp_abort_release_v(t); +} + +/* The 'abort' listener: clear the timer, reject the timer's promise. */ +static ScrDyn *scr_tp_abort_thunk(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + (void)args; + (void)argc; + ScrTpAbort *t = scr_box_get_ref(clo->caps[0]); + if (!t->settled) { + t->settled = true; + if (t->immediate) scr_clear_immediate(t->handle); + else scr_clear_interval(t->handle); + scr_tp_throw_abort(t->signal); + scr_promise_reject_pending(t->inner); + } + scr_tp_abort_release_v(t); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* The steps after the timer's promise settles, one promise job each: + * Node's SafePromisePrototypeFinally wraps the promise in + * `new Promise((a, b) => new SafePromise((a, b) => p.then(a, b)) + * .finally(remove).then(a, b))`, so the listener comes off in the second + * job and the result settles in the fifth. Native fibers take each job as + * an await hop; on wasm32 a runtime waiter cannot suspend, so each job is + * a waiter spawned after a settled promise. */ +static void scr_tp_abort_entry(ScrFiber *self, void *opaque) { + (void)self; + ScrTpAbort *t = opaque; + for (;;) { + if (t->step == 0) { + scr_await_void(t->inner); + if (scr_exc_pending()) scr_exc_clear(); + } else if (t->step == 1) { + bool aborted; + ScrEventTargetCore *core = + scr_et_signal_core_fn ? scr_et_signal_core_fn(t->signal, &aborted) : NULL; + if (core && t->listener) { + ScrStr *abort = scr_str_new("abort", 5); + ScrDyn *type = scr_dyn_new_str(abort); + scr_str_release(abort); + ScrDyn *remove_args[2] = {type, t->listener}; + ScrDyn *r = scr_et_remove_listener(core, remove_args, 2); + if (r) scr_dyn_release(r); + scr_dyn_release(type); + } + /* The listener's closure holds this state: dropping it here ends + * the cycle once the signal no longer holds the listener. */ + if (t->listener) { + scr_dyn_release(t->listener); + t->listener = NULL; + } + } else if (t->step == 4) { + scr_promise_adapt_copy(t->result, t->inner); + scr_tp_abort_release_v(t); + return; + } + t->step++; +#ifdef __wasi__ + ScrPromise *settled = scr_promise_settled_void(); + scr_promise_release(scr_async_spawn_after(settled, &scr_tp_abort_entry, t)); + scr_promise_release(settled); + return; +#else + scr_await_hop(); +#endif + } +} + +static ScrPromise *scr_tp_signal(double ms, ScrPromise *value, bool ref, ScrDyn *signal, + bool immediate) { + if (signal->kind == SCR_DYN_UNDEF) { + return immediate ? scr_tp_set_immediate_value(value, ref) + : scr_tp_set_timeout_value(ms, value, ref); + } + bool aborted = false; + ScrEventTargetCore *core = scr_et_signal_core_fn ? scr_et_signal_core_fn(signal, &aborted) : NULL; + ScrPromise *result = scr_promise_new(); + if (!core) { + scr_dyn_prop_type_fail("options.signal", "an instance of AbortSignal", signal); + scr_promise_reject_pending(result); + return result; + } + if (aborted) { + scr_tp_throw_abort(signal); + scr_promise_reject_pending(result); + return result; + } + ScrTpAbort *t = scr_et_calloc(sizeof *t); + t->rc = 1; + t->inner = scr_promise_new(); + t->result = scr_promise_retain(result); + t->signal = scr_dyn_retain(signal); + t->immediate = immediate; + ScrClosure *fire = scr_closure_new((void *)&scr_tp_abort_fire, 2); + fire->caps[0] = scr_tp_abort_box(t); + fire->caps[1] = scr_box_new_obj(scr_promise_retain_v, scr_promise_release_v, scr_promise_trace_v); + scr_box_set_ref(fire->caps[1], scr_promise_retain(value)); + if (immediate) { + t->handle = scr_set_immediate(fire); + if (!ref) scr_immediate_unref(t->handle); + } else { + t->handle = scr_set_timeout_handle(fire, ms); + if (!ref) scr_timer_unref(t->handle); + } + ScrClosure *clo = scr_closure_new((void *)&scr_tp_abort_thunk, 1); + clo->caps[0] = scr_tp_abort_box(t); + t->listener = scr_dyn_new_func(clo, &scr_tp_abort_thunk, 0, "()", "cancelListenerHandler"); + ScrStr *abort = scr_str_new("abort", 5); + bool duplicate; + (void)scr_et_append(core, abort, t->listener, SCR_ET_RESIST, &duplicate); + scr_str_release(abort); + /* The creation reference moves to the finally waiter. */ + scr_promise_release(scr_async_spawn_after(t->inner, &scr_tp_abort_entry, t)); + return result; +} + +ScrPromise *scr_tp_set_timeout_signal(double ms, ScrPromise *value, bool ref, ScrDyn *signal) { + 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 f218bbc9f..e787cdbdb 100644 --- a/packages/runtime/src/scr_event_target.h +++ b/packages/runtime/src/scr_event_target.h @@ -39,9 +39,9 @@ typedef struct ScrEvent { size_t rc; ScrStr *type; ScrDyn *target; /* +1, NULL until dispatched */ - ScrDyn *detail; /* CustomEvent's detail (+1); NULL for Event */ + ScrDyn *detail; /* CustomEvent's detail, MessageEvent's data (+1); NULL for Event */ double time_stamp; - bool custom, bubbles, cancelable, composed, trusted; + bool custom, message, bubbles, cancelable, composed, trusted; bool default_prevented, propagation_stopped, stop, dispatching, in_passive; bool tracked; struct ScrEvent *tracked_prev, *tracked_next; @@ -82,8 +82,38 @@ extern SCR_TL ScrEventTargetCore *(*scr_et_signal_core_fn)(const ScrDyn *d, bool * which reaches the class's compiled methods. Without it a capsule's own * view answers. */ void scr_event_handle_event_reader(ScrDyn *reader); +/* BroadcastChannel's EventTarget side, which the worker events unit + * reaches through this table (that unit links without this one): a core + * of its own, dispatching a 'message' MessageEvent with the data, the + * EventTarget methods (false: not one of them), and the on + * attributes. scr_et_broadcast_install sets the table (defined in + * scr_json.c, which every program links). */ +typedef struct { + ScrEventTargetCore *(*core_new)(const char *cls); + void (*core_free)(ScrEventTargetCore *core); + void (*dispatch_message)(ScrEventTargetCore *core, ScrDyn *self, ScrDyn *data); + bool (*method)(ScrEventTargetCore *core, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, ScrDyn **out); + ScrDyn *(*method_value)(const char *name, size_t length); + ScrDyn *(*handler_get)(ScrEventTargetCore *core, const char *type); + void (*handler_set)(ScrEventTargetCore *core, const char *type, const ScrDyn *value); +} ScrEtBroadcastOps; +extern SCR_TL const ScrEtBroadcastOps *scr_et_broadcast_ops; +void scr_et_broadcast_install(void); + /* events.addAbortListener(signal, listener): +1 disposable, or NULL. */ ScrDyn *scr_events_add_abort_listener(ScrDyn *signal, ScrDyn *listener); +/* timers/promises setTimeout(delay, value, options) and setImmediate(value, + * options) with options.signal (an undefined signal takes the plain form): + * `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 * extends Event`): an instance lays out like a compiler-emitted hierarchy diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 7b9bb27c6..670ea10d6 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -545,6 +545,25 @@ ScrPromise *scr_stdin_next_chunk(void) { * consumer, so the pipe itself is the buffer (backpressure, not loss). */ static void scr_stdin_deliver(ScrBytes *chunk); +/* A chunk that arrived (moves): delivered, or buffered while paused or + * without a consumer. */ +static void scr_stdin_arrived(ScrBytes *chunk) { + if (scr_stdin_paused || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { + if (scr_stdin_buffer) { + size_t before = scr_stdin_buffer->len; + ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + chunk->len)); + memcpy(joined->data, scr_stdin_buffer->data, before); + memcpy(joined->data + before, chunk->data, chunk->len); + scr_bytes_release(scr_stdin_buffer); + scr_bytes_release(chunk); + scr_stdin_buffer = joined; + } else scr_stdin_buffer = chunk; + return; + } + scr_stdin_deliver(chunk); + scr_bytes_release(chunk); +} + static void scr_stdin_service(void) { if (!scr_stdin_watching()) return; if (!scr_stdin_paused && scr_stdin_buffer) { @@ -558,6 +577,18 @@ static void scr_stdin_service(void) { if (!scr_stdin_buffer) scr_stdin_finish(true); return; } + if (scr_stdin_fed) { + /* the chunks the parent wrote to worker.stdin, one per pass like a + * read(2) */ + bool ended = false; + ScrBytes *chunk = scr_stdin_fed_take(&ended); + if (chunk) scr_stdin_arrived(chunk); + else if (ended) { + scr_stdin_read_eof = true; + if (!scr_stdin_buffer) scr_stdin_finish(true); + } + return; + } #ifdef _WIN32 /* The poll(2)-probe's win32 spelling, by handle type: a PIPE (the * harness/ssh shape) answers PeekNamedPipe — bytes available, or a @@ -623,20 +654,7 @@ static void scr_stdin_service(void) { } ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); memcpy(chunk->data, buf, (size_t)n); - if (scr_stdin_paused || (!scr_stdin_ndata && !scr_stdin_resumed && !scr_stdin_waiter)) { - if (scr_stdin_buffer) { - size_t before = scr_stdin_buffer->len; - ScrBytes *joined = scr_bytes_new(SCR_BYTES_U8, (double)(before + (size_t)n)); - memcpy(joined->data, scr_stdin_buffer->data, before); - memcpy(joined->data + before, buf, (size_t)n); - scr_bytes_release(scr_stdin_buffer); - scr_bytes_release(chunk); - scr_stdin_buffer = joined; - } else scr_stdin_buffer = chunk; - return; - } - scr_stdin_deliver(chunk); - scr_bytes_release(chunk); + scr_stdin_arrived(chunk); } static void scr_stdin_deliver(ScrBytes *chunk) { @@ -1201,8 +1219,11 @@ void scr_stdin_data_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { static bool scr_events_pending(void) { return scr_stdin_pending(); } +/* A worker's fed stdin has no descriptor to poll: its chunks arrive in + * the worker's mailbox, which the worker unit waits on while + * scr_stdin_fed_waiting answers true. */ static bool scr_events_watching(void) { - return (scr_context_is_main() && scr_sig_watched > 0) || scr_stdin_pending(); + return (scr_context_is_main() && scr_sig_watched > 0) || (!scr_stdin_fed && scr_stdin_pending()); } /* One dispatch pass at a loop turn: wake-pipe bytes are consumed (safe @@ -1250,7 +1271,12 @@ void scr_events_install(void) { static SCR_TL bool installed = false; if (installed) return; installed = true; - if (!scr_context_is_main()) scr_stdin_read_eof = true; + if (scr_stdin_fed) { + scr_stdin_fed_waiting = scr_stdin_pending; + if (scr_stdin_fed_attach) scr_stdin_fed_attach(); + } else if (!scr_context_is_main()) { + scr_stdin_read_eof = true; + } #ifdef __wasi__ int stdin_flags = fcntl(0, F_GETFL, 0); if (stdin_flags >= 0) (void)fcntl(0, F_SETFL, stdin_flags | O_NONBLOCK); diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index aaca388a1..4f934ce24 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -34,11 +34,11 @@ * 'error' listener the Error payload THROWS through the exception cell * (catchable; uncaught exits 1), Node's unhandled-'error' contract. * - * The maxListeners leak warning prints Node's message shape on stderr, - * once per (emitter, event name), plus the once-per-process - * --trace-warnings hint line. Documented divergences: the pid in - * "(node:pid)" is this process's (it can never equal the oracle's), and - * the warning prints synchronously at the add (Node defers it a tick). */ + * The maxListeners leak warning is emitted once per (emitter, event name) + * on the next tick, as process.emitWarning delivers it: to the process + * 'warning' listeners and Node's stderr report, with the once-per-process + * --trace-warnings hint line. The pid in "(node:pid)" is this process's + * (it can never equal the oracle's). */ #include "scr_runtime.h" #include @@ -112,8 +112,6 @@ SCR_TL ScrVt scr_emitter_vt = {0, 0, &scr_emitter_release_direct}; SCR_TL double scr_emitter_default_max = 10; /* EventEmitter.defaultMaxListeners */ -static SCR_TL bool scr_ee_trace_hint_shown = false; - /* Post-registration hook (scr_runtime.h): the stream unit's flow kick. * NULL in stream-free builds — behavior byte-identical. */ SCR_TL void (*scr_emitter_on_hook)(ScrEmitter *em, ScrStr *name) = NULL; @@ -375,24 +373,52 @@ static void scr_ee_emit_meta(ScrEmitter *em, const char *meta, ScrStr *name) { /* ── add / remove ─────────────────────────────────────────────────────── */ +/* process.emitWarning's next-tick delivery of the leak warning: to the + * 'warning' listeners and the default report when the process-warning + * unit is linked, otherwise the default report alone. */ +static void scr_ee_warn_tick(ScrClosure *self) { + ScrStr *message = (ScrStr *)scr_box_get_ref(self->caps[0]); + if (scr_warning_emit_hook) { + scr_warning_emit_hook("MaxListenersExceededWarning", NULL, message); + } else { + fflush(stdout); + fprintf(stderr, "(node:%ld) MaxListenersExceededWarning: %.*s\n", SCR_EE_PID(), + (int)message->len, message->data); + if (!scr_warning_trace_hint_shown) { + scr_warning_trace_hint_shown = true; + fputs("(Use `node --trace-warnings ...` to show where the warning was created)\n", stderr); + } + } + scr_str_release(message); +} + static void scr_ee_warn_maybe(ScrEmitter *em, ScrEeBucket *b) { ScrEeReg *reg = em->reg; double max = reg->max >= 0 ? reg->max : scr_emitter_default_max; if (b->warned || max <= 0 || (double)b->n <= max) return; b->warned = true; - /* Node's exact message, except the pid, which is OURS (it can - * never match another process's), and the synchronous timing (Node - * defers the print by a tick). SEMANTICS.md documents both. */ - fprintf(stderr, - "(node:%ld) MaxListenersExceededWarning: Possible EventEmitter memory leak " - "detected. %zu %s listeners added to [%s]. MaxListeners is %g. Use " - "emitter.setMaxListeners() to increase limit\n", - SCR_EE_PID(), b->n, b->name->data, em->cls, max); - if (!scr_ee_trace_hint_shown) { - scr_ee_trace_hint_shown = true; - fputs("(Use `node --trace-warnings ...` to show where the warning was created)\n", - stderr); - } + /* Node's message, except the pid, which is this process's. Node emits + * the warning through process.emitWarning, which delivers it on the next + * tick. */ + char count[32], limit[32]; + snprintf(count, sizeof count, "%zu", b->n); + snprintf(limit, sizeof limit, "%g", max); + ScrJsonBuf text; + scr_jb_init(&text); + scr_jb_puts(&text, "Possible EventEmitter memory leak detected. "); + scr_jb_puts(&text, count); + scr_jb_putc(&text, ' '); + scr_jb_write(&text, b->name->data, b->name->len); + scr_jb_puts(&text, " listeners added to ["); + scr_jb_puts(&text, em->cls); + scr_jb_puts(&text, "]. MaxListeners is "); + scr_jb_puts(&text, limit); + scr_jb_puts(&text, ". Use emitter.setMaxListeners() to increase limit"); + ScrClosure *tick = scr_closure_new((void *)&scr_ee_warn_tick, 1); + ScrBox *box = scr_box_new(SCR_BOX_STR); + scr_box_set_ref(box, scr_jb_finish(&text)); + tick->caps[0] = box; + scr_next_tick(tick); } /* Registers a listener. cb and orig MOVE in; name is borrowed. Returns em diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index c9622ceb3..80fb98957 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -213,5 +213,10 @@ extern SCR_TL int (*scr_uncaught_exception_hook)(bool from_promise); bool scr_exc_handle_uncaught(bool from_promise); void scr_process_on_uncaught_exception(ScrDyn *fn, bool once, bool monitor); void scr_process_off_uncaught_exception(ScrDyn *fn, bool monitor); +/* The value Node reports for an unhandled rejection (scr_async_dyn.c): an + * error-like reason itself, otherwise an UnhandledPromiseRejection Error + * (code ERR_UNHANDLED_REJECTION) quoting the reason. Borrows; +1, NULL + * after a throw. */ +ScrDyn *scr_unhandled_rejection_error(ScrDyn *reason); #endif /* SCR_EXCEPTION_H */ diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index fe1b92c0f..f586b96f8 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -10,6 +10,7 @@ 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080 #include "scr_runtime.h" +#include "scr_system_errors.h" #include "scr_url_internal.h" #include @@ -495,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) { @@ -526,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; @@ -2888,14 +2904,21 @@ static int sf_hex_value(char c) { return -1; } +/* EnvHttpProxyAgent sends Basic proxy authentication only when the proxy + * URL has both a username and a password. */ +static bool sf_proxy_has_credentials(const ScrStr *userinfo) { + const char *colon = memchr(userinfo->data, ':', userinfo->len); + return colon != NULL && colon != userinfo->data && + colon + 1 < userinfo->data + userinfo->len; +} + /* EnvHttpProxyAgent derives Basic proxy authentication from the proxy URL's * decoded username/password. ScrUrl keeps the encoded userinfo as one string, - * so decode it here and add the empty-password separator when necessary. */ + * so decode it here. */ static ScrStr *sf_proxy_authorization(const ScrStr *userinfo) { char *plain = malloc(userinfo->len + 1); if (!plain) sf_oom(); size_t plain_len = 0; - bool has_separator = false; for (size_t i = 0; i < userinfo->len;) { char c = userinfo->data[i]; if (c == '%' && i + 2 < userinfo->len) { @@ -2907,11 +2930,9 @@ static ScrStr *sf_proxy_authorization(const ScrStr *userinfo) { continue; } } - if (c == ':') has_separator = true; plain[plain_len++] = c; i++; } - if (!has_separator) plain[plain_len++] = ':'; static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -4058,15 +4079,154 @@ static void sf_on_response(ScrClosure *cb, ScrHttpReq *res) { sf_release(t); } +typedef struct { int value; const char *name; } SfUvError; +#define SF_UV_ERROR(name, message) { SCR_NODE_UV__##name, #name }, +static const SfUvError sf_uv_errors[] = { SCR_NODE_UV_ERRNO_MAP(SF_UV_ERROR) }; +#undef SF_UV_ERROR + +/* libuv's errno value for a code name; getaddrinfo's ENOTFOUND is + * EAI_NONAME. 0 when the name is not a libuv code. */ +static int sf_uv_errno(const char *name) { + if (strcmp(name, "ENOTFOUND") == 0) return SCR_NODE_UV__EAI_NONAME; + for (size_t i = 0; i < sizeof sf_uv_errors / sizeof sf_uv_errors[0]; i++) { + if (strcmp(sf_uv_errors[i].name, name) == 0) return sf_uv_errors[i].value; + } + return 0; +} + +static const char sf_tunnel_prefix[] = "Proxy response ("; +static const char sf_not_http_prefix[] = "Response does not match the HTTP/1.1 protocol"; + +/* The cause Node's fetch gives a transport failure, built from the + * runtime's failure message. " [ ]" messages + * become Node's system error with code, errno and syscall (plus address + * and port for connect, hostname for getaddrinfo). A tunnel the proxy + * refused becomes undici's DOMException "Request was cancelled." whose + * cause is the AbortError carrying the proxy status, and a proxy reply + * that is not HTTP becomes the HTTPParserError. NULL (no cause) for + * every other message. +1. */ +static ScrDyn *sf_failure_cause(const ScrStr *message) { + if (message->len > sizeof sf_tunnel_prefix - 1 && + memcmp(message->data, sf_tunnel_prefix, sizeof sf_tunnel_prefix - 1) == 0) { + ScrError *aborted = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_str_release(aborted->name); + aborted->name = scr_str_new("AbortError", 10); + aborted->name_present = true; + scr_error_set_code(aborted, "UND_ERR_ABORTED"); + ScrDyn *inner = scr_dyn_from_error(aborted); + scr_error_release(aborted); + ScrDyn *options = scr_dyn_new_obj(); + ScrStr *name = scr_str_new("Error", 5); + scr_dyn_obj_set(options, "name", 4, scr_dyn_new_str(name)); + scr_str_release(name); + scr_dyn_obj_set(options, "cause", 5, inner); + ScrStr *text = scr_str_new("Request was cancelled.", 22); + ScrDyn *m = scr_dyn_new_str(text); + scr_str_release(text); + ScrError *dom = scr_domex_new(m, options); + scr_dyn_release(m); + scr_dyn_release(options); + ScrDyn *cause = scr_dyn_from_error(dom); + scr_error_release(dom); + return cause; + } + if (message->len >= sizeof sf_not_http_prefix - 1 && + memcmp(message->data, sf_not_http_prefix, sizeof sf_not_http_prefix - 1) == 0) { + ScrError *parser = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_str_release(parser->name); + parser->name = scr_str_new("HTTPParserError", 15); + parser->name_present = true; + ScrDyn *cause = scr_dyn_from_error(parser); + scr_error_release(parser); + return cause; + } + const char *data = message->data; + const char *space = memchr(data, ' ', message->len); + if (space == NULL || space == data) return NULL; + size_t syscall_len = (size_t)(space - data); + const char *code = space + 1; + const char *end = data + message->len; + const char *code_end = code; + while (code_end < end && *code_end != ' ') code_end++; + char code_buf[32]; + size_t code_len = (size_t)(code_end - code); + if (code_len < 2 || code_len >= sizeof code_buf || code[0] != 'E') return NULL; + for (size_t i = 0; i < code_len; i++) { + char c = code[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) return NULL; + } + memcpy(code_buf, code, code_len); + code_buf[code_len] = '\0'; + int uv = sf_uv_errno(code_buf); + if (uv == 0) return NULL; + ScrError *e = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_error_set_code(e, code_buf); + e->system_errno = uv; + e->system_call = scr_str_new(data, syscall_len); + ScrDyn *cause = scr_dyn_from_error(e); + scr_error_release(e); + const char *detail = code_end < end ? code_end + 1 : end; + size_t detail_len = (size_t)(end - detail); + if (syscall_len == 7 && memcmp(data, "connect", 7) == 0 && detail_len > 0) { + const char *colon = NULL; + for (const char *q = detail; q < end; q++) { + if (*q == ':') colon = q; + } + if (colon != NULL && colon > detail && colon + 1 < end) { + ScrStr *address = scr_str_new(detail, (size_t)(colon - detail)); + scr_dyn_obj_set(cause, "address", 7, scr_dyn_new_str(address)); + scr_str_release(address); + scr_dyn_obj_set(cause, "port", 4, scr_dyn_new_num(strtod(colon + 1, NULL))); + } + } else if (syscall_len == 11 && memcmp(data, "getaddrinfo", 11) == 0 && detail_len > 0) { + ScrStr *hostname = scr_str_new(detail, detail_len); + scr_dyn_obj_set(cause, "hostname", 8, scr_dyn_new_str(hostname)); + scr_str_release(hostname); + } + return cause; +} + +/* A plain Error cause. +1. */ +static ScrDyn *sf_plain_cause(const char *message) { + ScrStr *text = scr_str_new(message, strlen(message)); + ScrError *e = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + ScrDyn *cause = scr_dyn_from_error(e); + scr_error_release(e); + return cause; +} + +/* Rejects with TypeError "fetch failed" whose cause is `cause` (moves; + * NULL rejects without a cause). */ +static void sf_reject_cause(SfTransfer *t, ScrDyn *cause) { + if (t->done || cause == NULL) { + scr_dyn_release(cause); + sf_reject(t, "fetch failed"); + return; + } + ScrError *e = NULL; + ScrDyn *reason = sf_type_reason("fetch failed", &e); + scr_error_set_cause(e, cause); + scr_dyn_release(cause); + scr_error_release(e); + sf_reject_reason(t, reason); + scr_dyn_release(reason); +} + +/* Rejects with TypeError "fetch failed", with Node's cause when the + * failure message has one. */ +static void sf_reject_failure(SfTransfer *t, const ScrStr *message) { + sf_reject_cause(t, message != NULL ? sf_failure_cause(message) : NULL); +} + static void sf_on_client_error(ScrClosure *cb, ScrStr *message) { - (void)message; SfTransfer *t = sf_from(cb); if (!t) return; if (!t->done) { if (t->response_sent && t->response_stream) { sf_response_terminate(t); } else { - sf_reject(t, "fetch failed"); + sf_reject_failure(t, message); } } sf_release(t); @@ -4150,25 +4310,77 @@ static bool sf_redirect(SfTransfer *t, int status, return true; } +/* A CONNECT tunnel for one request: the head sent to the proxy, and the + * target's TLS client context when the target is https. */ +typedef struct { + char *head; + size_t head_len; + void *tls_ctx; +} SfTunnel; + +/* undici's CONNECT head: the target as host:port, its Host value, and + * the proxy credentials from the proxy URL. Returns malloc'd storage. */ +static char *sf_tunnel_head(const ScrUrl *url, int port, const char *authority, + size_t authority_len, const ScrUrl *proxy, size_t *len_out) { + ScrStr *authorization = sf_proxy_has_credentials(proxy->userinfo) + ? sf_proxy_authorization(proxy->userinfo) + : NULL; + size_t cap = url->host->len + authority_len + 160 + (authorization ? authorization->len : 0); + char *head = malloc(cap); + if (!head) sf_oom(); + int n = snprintf(head, cap, "CONNECT %.*s:%d HTTP/1.1\r\nhost: %.*s\r\nconnection: close\r\n", + (int)url->host->len, url->host->data, port, (int)authority_len, authority); + if (authorization) { + n += snprintf(head + n, cap - (size_t)n, "proxy-authorization: %.*s\r\n", + (int)authorization->len, authorization->data); + scr_str_release(authorization); + } + n += snprintf(head + n, cap - (size_t)n, "proxy-connection: keep-alive\r\n\r\n"); + *len_out = (size_t)n; + return head; +} + +static SfTunnel *sf_tunnel_new(const ScrUrl *url, int port, const char *authority, + size_t authority_len, const ScrUrl *proxy, void *tls_ctx) { + SfTunnel *tunnel = malloc(sizeof *tunnel); + if (!tunnel) sf_oom(); + tunnel->head = sf_tunnel_head(url, port, authority, authority_len, proxy, &tunnel->head_len); + tunnel->tls_ctx = tls_ctx; + return tunnel; +} + +/* The client's wrap hook for a tunneled request: the CONNECT exchange + * runs on the proxy socket, then the target's TLS session (if any) runs + * inside the tunnel. */ +static void sf_tunnel_wrap(ScrNetSocket *sock, void *ctx) { + SfTunnel *tunnel = ctx; + scr_net_sock_set_tunnel(sock, tunnel->head, tunnel->head_len); + if (tunnel->tls_ctx) scr_tls_fetch_client_wrap(sock, tunnel->tls_ctx); + free(tunnel->head); + free(tunnel); +} + static bool sf_start_hop(SfTransfer *t) { ScrUrl *url = t->url; bool https = sf_eq_ci(url->scheme, "https"); int default_port = https ? 443 : 80; int port = sf_port(url, default_port); if (sf_bad_port(port)) { - sf_reject(t, "fetch failed"); + sf_reject_cause(t, sf_plain_cause("bad port")); return false; } bool invalid_proxy = false; ScrUrl *proxy = sf_proxy_for(url, https, port, &invalid_proxy); bool proxy_http = proxy && sf_eq_ci(proxy->scheme, "http"); bool proxy_https = proxy && sf_eq_ci(proxy->scheme, "https"); - if (invalid_proxy || (proxy && !proxy_http && !proxy_https) || - (https && proxy)) { + if (invalid_proxy || (proxy && !proxy_http && !proxy_https)) { scr_url_release(proxy); sf_reject(t, "fetch failed"); return false; } + /* A proxy carries every request through a CONNECT tunnel, as undici's + * ProxyAgent does; an https:// proxy runs the tunnel inside a TLS + * session to the proxy. */ size_t user_len = (size_t)scr_arr_len(t->headers); ScrArr *headers = scr_arr_new(SCR_ELEM_STR, user_len + 16); @@ -4214,13 +4426,6 @@ static bool sf_start_hop(SfTransfer *t) { scr_arr_push_ref(headers, name); scr_arr_push_ref(headers, value); } - if (proxy && proxy->userinfo->len > 0 && - !sf_pairs_have(t->headers, "proxy-authorization")) { - ScrStr *authorization = sf_proxy_authorization(proxy->userinfo); - sf_push_header_text(headers, "proxy-authorization", - authorization->data, authorization->len); - scr_str_release(authorization); - } if (!sf_pairs_have(t->headers, "accept")) { sf_push_header_text(headers, "accept", "*/*", 3); } @@ -4240,17 +4445,29 @@ static bool sf_start_hop(SfTransfer *t) { ScrStr *bare = sf_bare_host(proxy ? proxy->host : url->host); ScrStr *dial = scr_net_blocking_lookup(bare); - ScrStr *path = proxy ? sf_url_serialize(url) : sf_path(url); + ScrStr *path = sf_path(url); int dial_port = proxy ? sf_port(proxy, proxy_https ? 443 : 80) : port; - ScrStr *sni = - https ? sf_bare_host(url->host) - : proxy_https ? sf_bare_host(proxy->host) : NULL; - void *tls_ctx = - sni ? scr_tls_fetch_client_ctx(sni, true) : NULL; + ScrStr *sni = https ? sf_bare_host(url->host) : NULL; + void (*wrap)(ScrNetSocket *, void *) = NULL; + void *wrap_ctx = NULL; + if (proxy_https) { + size_t head_len = 0; + char *head = sf_tunnel_head(url, port, authority, (size_t)authority_len, proxy, &head_len); + wrap_ctx = scr_tls_fetch_tunnel_ctx(bare, sni, head, head_len); + wrap = &scr_tls_fetch_tunnel_wrap; + free(head); + } else if (proxy_http) { + void *tls_ctx = sni ? scr_tls_fetch_client_ctx(sni, true) : NULL; + wrap_ctx = sf_tunnel_new(url, port, authority, (size_t)authority_len, proxy, tls_ctx); + wrap = &sf_tunnel_wrap; + } else if (sni) { + wrap_ctx = scr_tls_fetch_client_ctx(sni, true); + wrap = &scr_tls_fetch_client_wrap; + } ScrHttpClientReq *client = scr_http_request_ex( dial, dial_port, path, t->method, 0, headers, false, NULL, NULL, - default_port, sni ? &scr_tls_fetch_client_wrap : NULL, tls_ctx); + default_port, wrap, wrap_ctx); scr_arr_release(headers); scr_str_release(path); scr_str_release(dial); diff --git a/packages/runtime/src/scr_generator.h b/packages/runtime/src/scr_generator.h index f528b8b8f..2c2c0e953 100644 --- a/packages/runtime/src/scr_generator.h +++ b/packages/runtime/src/scr_generator.h @@ -133,6 +133,8 @@ void scr_gen_yield_f64(double v); void scr_gen_yield_bool(bool v); void scr_gen_yield_ref(void *v, void (*release)(void *)); /* moves */ void scr_async_gen_hop_done(void); +/* An async generator's `return x` whose completion turn awaits x. */ +void scr_async_gen_return_awaits(void); void scr_gen_out_f64(ScrGen *g, double v); /* trampoline completion stores */ void scr_gen_out_bool(ScrGen *g, bool v); void scr_gen_out_ref(ScrGen *g, void *v, void (*release)(void *)); /* moves */ diff --git a/packages/runtime/src/scr_http.c b/packages/runtime/src/scr_http.c index 7a8359032..8fb7e3b0f 100644 --- a/packages/runtime/src/scr_http.c +++ b/packages/runtime/src/scr_http.c @@ -2733,6 +2733,7 @@ static void scr_http_req_deliver(ScrHttpReq *r, const char *data, size_t n) { bool piped = r->pipe_res != NULL || r->pipe_client != NULL || r->pipe_sock != NULL; if (r->data_ls.n == 0 && !piped) return; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, data, n); if (r->data_ls.n > 0) { ScrNetL *snap; @@ -2931,6 +2932,7 @@ static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len, bool *o return true; } ScrBytes *head = scr_bytes_new(SCR_BYTES_U8, (double)conn->len); + head->is_buffer = true; /* the upgrade head is a Buffer */ if (conn->len > 0) memcpy(head->data, conn->buf, conn->len); conn->len = 0; scr_net_sock_clear_native_reader(conn->sock); @@ -2966,6 +2968,7 @@ static bool scr_http_conn_parse_head(ScrHttpConn *conn, size_t head_len, bool *o return true; } ScrBytes *head = scr_bytes_new(SCR_BYTES_U8, (double)conn->len); + head->is_buffer = true; /* the upgrade head is a Buffer */ if (conn->len > 0) memcpy(head->data, conn->buf, conn->len); conn->len = 0; scr_net_sock_clear_native_reader(conn->sock); @@ -4659,6 +4662,7 @@ static bool scr_http_client_parse_head(ScrHttpConn *conn, size_t head_len) { return true; } ScrBytes *head = scr_bytes_new(SCR_BYTES_U8, (double)conn->len); + head->is_buffer = true; /* the upgrade head is a Buffer */ if (conn->len > 0) memcpy(head->data, conn->buf, conn->len); conn->len = 0; scr_net_sock_clear_native_reader(conn->sock); diff --git a/packages/runtime/src/scr_http2.c b/packages/runtime/src/scr_http2.c index 6952e1fcd..4c5267e6a 100644 --- a/packages/runtime/src/scr_http2.c +++ b/packages/runtime/src/scr_http2.c @@ -1093,6 +1093,7 @@ static void scr_h2_stream_rq_push(ScrH2Stream *st, const char *data, size_t n) { static void scr_h2_stream_fire_data(ScrH2Stream *st, const char *data, size_t n) { if (st->data_ls.n == 0) return; ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, data, n); ScrNetL *snap; size_t nl = scr_net_ls_snapshot(&st->data_ls, &snap); diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index d5b1cae2d..e37e32c08 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -196,6 +196,13 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port * the fresh socket. The ctx moves into the transport at wrap time. */ void *scr_tls_fetch_client_ctx(ScrStr *host /*borrowed*/, bool reject_unauthorized); void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx); +/* The fetch tunnel through an https:// proxy: TLS to the proxy, the + * CONNECT head (copied) inside it, then TLS to target_host inside the + * tunnel (NULL target_host: an http:// target's plaintext). The ctx moves + * into the socket's transport at wrap time. */ +void *scr_tls_fetch_tunnel_ctx(ScrStr *proxy_host /*borrowed*/, ScrStr *target_host /*borrowed, nullable*/, + const char *request, size_t len); +void scr_tls_fetch_tunnel_wrap(ScrNetSocket *sock, void *ctx); /* The loop-side registration (scr_async.c, always linked): `pending` * joins the exhaustion test (a live handle keeps the process alive, like * Node's active TCP handles), `dispatch` runs at every turn top like the diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index e0615635a..34438353f 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -6,9 +6,10 @@ * escapes, the 10000-char string cap and the per-line ` +` continuation * form), Buffer's hex form, and the LAYOUT engine — a * frame stack reproducing reduceToSingleString, isBelowBreakLength, and - * groupArrayElements from Node v24's lib/internal/util/inspect.js for the - * DEFAULT options (compact: 3, breakLength: 80, maxArrayLength: 100, - * maxStringLength: 10000; non-default options fence in the frontend). + * groupArrayElements from Node v24's lib/internal/util/inspect.js for + * maxArrayLength: 100 and maxStringLength: 10000, a numeric or false + * compact (default 3), any breakLength (default 80) and a boolean sorted; + * other non-default options fence in the frontend. * * Lengths follow Node exactly: the break-length math counts UTF-16 code * units (JS .length); the grid-grouping math counts DISPLAY WIDTH. Node @@ -242,8 +243,20 @@ static SCR_TL size_t g_nframes; static SCR_TL size_t g_frames_cap; static SCR_TL size_t g_indent; /* ctx.indentationLvl */ static SCR_TL bool g_no_break; /* breakLength: Infinity (console.table cells) */ +static SCR_TL double g_break_length = 80; /* ctx.breakLength */ +static SCR_TL double g_compact = 3; /* ctx.compact: a number, 0 for false */ +static SCR_TL bool g_sorted; /* ctx.sorted === true */ static SCR_TL double g_cur_depth; /* ctx.currentDepth (last-entered composite) */ +/* util.inspect's breakLength, compact and sorted for the renders that + * follow (compact false is 0); the call site restores 80, 3 and false + * after its render. */ +void scr_insp_layout(double break_length, double compact, bool sorted) { + g_break_length = break_length; + g_compact = compact; + g_sorted = sorted; +} + static InspFrame *insp_top(void) { return &g_frames[g_nframes - 1]; } static void insp_circ_reset(void); /* circular-reference state, below */ @@ -360,6 +373,13 @@ ScrStr *scr_insp_ref_wrap(const void *v, ScrStr *s) { return out; } +/* formatRaw's Date base: toISOString(), or toString()'s "Invalid Date" + * when the time value is NaN (the only time value outside the ISO range). */ +ScrStr *scr_insp_date(double ms) { + if (isnan(ms)) return scr_str_new("Invalid Date", 12); + return scr_date_to_iso(ms); +} + /* remainingText: the "... N more items" tail entry. */ ScrStr *scr_insp_more_items(double remaining) { char buf[64]; @@ -382,15 +402,15 @@ static void insp_group(InspFrame *f, bool trailing_more) { if ((double)w > max_length) max_length = (double)w; } double actual_max = max_length + 2; - if (actual_max * 3 + (double)g_indent < 80 && + if (actual_max * 3 + (double)g_indent < g_break_length && (total_length / actual_max > 5 || max_length <= 6)) { double approx_char_heights = 2.5; double average_bias = sqrt(actual_max - total_length / (double)f->len); double biased_max = fmax(actual_max - 3 - average_bias, 1); double columns_d = fmin( fmin(round(sqrt(approx_char_heights * biased_max * (double)output_len) / biased_max), - floor((80 - (double)g_indent) / actual_max)), - fmin(3 * 4 /* ctx.compact * 4 */, 15)); + floor((g_break_length - (double)g_indent) / actual_max)), + fmin(g_compact * 4, 15)); if (columns_d <= 1) { free(data_len); return; @@ -458,18 +478,23 @@ static void insp_group(InspFrame *f, bool trailing_more) { free(data_len); } -/* isBelowBreakLength: UTF-16 lengths against breakLength 80. */ +static int insp_item_cmp(const void *a, const void *b) { + return scr_str_cmp_u16(*(ScrStr *const *)a, *(ScrStr *const *)b); +} + +/* isBelowBreakLength: UTF-16 lengths against ctx.breakLength. */ static bool insp_below_break_length(InspFrame *f, size_t start, const ScrStr *base) { size_t total = f->len + start; - if (total + f->len > 80) return false; + if ((double)(total + f->len) > g_break_length) return false; for (size_t i = 0; i < f->len; i++) { total += insp_utf16_len(f->items[i]->data, f->items[i]->len); - if (total > 80) return false; + if ((double)total > g_break_length) return false; } return base->len == 0 || !insp_contains_byte(base->data, base->len, '\n'); } -/* reduceToSingleString for the default options (compact: 3). Pops the +/* reduceToSingleString for a numeric or false compact (only a compact of + * at least 1 groups arrays and tries the single line). Pops the * frame. base/b0/b1 are borrowed; +1 result. `recurse` is the frame's * recurseTimes (the children's depth); array_extras marks kArrayExtrasType * (grid grouping applies); trailing_more marks a "... N more items" tail @@ -478,10 +503,15 @@ ScrStr *scr_insp_end(ScrStr *base, ScrStr *b0, ScrStr *b1, double recurse, bool bool trailing_more) { InspFrame *f = insp_top(); g_indent -= 2; + /* sorted: true orders an object-type composite's entries (properties, + * and Map and Set entries) by their rendered text, in UTF-16 code-unit + * order; arrays keep their element order. */ + if (g_sorted && !array_extras && f->len > 1) qsort(f->items, f->len, sizeof *f->items, insp_item_cmp); size_t entries = f->len; - if (array_extras && entries > 6) insp_group(f, trailing_more); + bool compact = g_compact >= 1; + if (compact && array_extras && entries > 6) insp_group(f, trailing_more); InspBuf out = {0}; - if (g_cur_depth - recurse < 3 && entries == f->len) { + if (compact && g_cur_depth - recurse < g_compact && entries == f->len) { size_t start = f->len + g_indent + insp_utf16_len(b0->data, b0->len) + insp_utf16_len(base->data, base->len) + 10; if (insp_below_break_length(f, start, base)) { @@ -619,7 +649,7 @@ static size_t insp_utf16_offset(const char *data, size_t len, size_t units, bool /* formatPrimitive over a string value under the default options: * maxStringLength 10000, then the per-line ` +` continuation when the - * (truncated) text is longer than 16 chars AND breakLength(80) - + * (truncated) text is longer than 16 chars AND breakLength - * indentationLvl - 4, split after each newline; the trailer counts the * cut characters. Uses the CURRENT indentation. */ ScrStr *scr_insp_str(ScrStr *s) { @@ -652,7 +682,8 @@ ScrStr *scr_insp_str(ScrStr *s) { if (surrogate_tail_len) ib_bytes(&val, surrogate_tail, surrogate_tail_len); size_t vlen = val.len; const char *v = val.data ? val.data : ""; - if (!g_no_break && units > 16 && units > 80 - g_indent - 4 && memchr(v, '\n', vlen) != NULL) { + if (!g_no_break && units > 16 && (double)units > g_break_length - (double)g_indent - 4 && + memchr(v, '\n', vlen) != NULL) { /* split after each newline; quote each chunk; join with ` +\n` */ size_t chunk_start = 0; bool first = true; @@ -946,6 +977,22 @@ static ScrStr *scr_insp_class_tag(const char *cls, const char *tail) { /* A native Set or Map held as a checked-dynamic value: `Set(n) { a, b }`, * `Map(n) { k => v }`, at most 100 entries like Node's maxArrayLength. */ +/* Node's formatWeakCollection: the entries of a WeakMap or WeakSet are not + * listed without showHidden, so the body is the one "" + * entry, and past the depth limit the value prints as [WeakMap]. */ +static ScrStr *scr_insp_weak_collection(const char *label, double recurse, double depth) { + InspBuf out = {0}; + if (recurse > depth) { + ib_char(&out, '['); + ib_cstr(&out, label); + ib_char(&out, ']'); + return ib_take(&out); + } + ib_cstr(&out, label); + ib_cstr(&out, " { }"); + return ib_take(&out); +} + static ScrStr *scr_insp_dyn_collection(ScrDyn *d, double recurse, double depth, bool is_map) { ScrMap *map = d->v.handle.ptr; const char *label = is_map ? "Map" : "Set"; @@ -1077,6 +1124,46 @@ static ScrStr *scr_insp_obj(ScrDyn *d, double recurse, double depth, const char return out; } +/* A numeric typed array (not a Buffer): `Uint8Array(n) [ ... ]` with + * Node's maxArrayLength (100) and array grid, `[Uint8Array]` past the + * depth limit. Borrows b; +1. */ +ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth) { + char prefix[48]; + int pn = snprintf(prefix, sizeof prefix, "%s(%zu) [", scr_bytes_elem_name(b->elem), b->len); + if (b->len == 0) { + InspBuf out = {0}; + ib_bytes(&out, prefix, (size_t)pn); + ib_char(&out, ']'); + return ib_take(&out); + } + if (recurse > depth) { + char tag[32]; + int n = snprintf(tag, sizeof tag, "[%s]", scr_bytes_elem_name(b->elem)); + return scr_str_new(tag, (size_t)n); + } + scr_insp_begin(recurse + 1); + size_t shown = b->len < 100 ? b->len : 100; + for (size_t i = 0; i < shown; i++) { + ScrStr *s = scr_insp_f64(scr_bytes_get(b, (double)i)); + scr_insp_entry(s, true); + scr_str_release(s); + } + bool more = b->len > 100; + if (more) { + ScrStr *m = scr_insp_more_items((double)(b->len - 100)); + scr_insp_entry(m, true); + scr_str_release(m); + } + ScrStr *base = scr_str_new("", 0); + ScrStr *b0 = scr_str_new(prefix, (size_t)pn); + ScrStr *b1 = scr_str_new("]", 1); + ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, more); + scr_str_release(base); + scr_str_release(b0); + scr_str_release(b1); + return out; +} + ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { if (d->kind == SCR_DYN_PROXY) return scr_insp_dyn(d->v.proxy.target, recurse, depth); switch (d->kind) { @@ -1153,6 +1240,10 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { } if (d->v.handle.tag == SCR_DYNH_SET || d->v.handle.tag == SCR_DYNH_MAP) return scr_insp_dyn_collection(d, recurse, depth, d->v.handle.tag == SCR_DYNH_MAP); + 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 @@ -1245,40 +1336,7 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) { /* Buffer-flavored (a stream chunk): Node's form. */ return scr_insp_buffer(b); } - char prefix[48]; - int pn = snprintf(prefix, sizeof prefix, "%s(%zu) [", scr_bytes_elem_name(b->elem), b->len); - if (b->len == 0) { - InspBuf out = {0}; - ib_bytes(&out, prefix, (size_t)pn); - ib_char(&out, ']'); - return ib_take(&out); - } - if (recurse > depth) { - char tag[32]; - int n = snprintf(tag, sizeof tag, "[%s]", scr_bytes_elem_name(b->elem)); - return scr_str_new(tag, (size_t)n); - } - scr_insp_begin(recurse + 1); - size_t shown = b->len < 100 ? b->len : 100; - for (size_t i = 0; i < shown; i++) { - ScrStr *s = scr_insp_f64(scr_bytes_get(b, (double)i)); - scr_insp_entry(s, true); - scr_str_release(s); - } - bool more = b->len > 100; - if (more) { - ScrStr *m = scr_insp_more_items((double)(b->len - 100)); - scr_insp_entry(m, true); - scr_str_release(m); - } - ScrStr *base = scr_str_new("", 0); - ScrStr *b0 = scr_str_new(prefix, (size_t)pn); - ScrStr *b1 = scr_str_new("]", 1); - ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, more); - scr_str_release(base); - scr_str_release(b0); - scr_str_release(b1); - return out; + return scr_insp_typed_array(b, recurse, depth); } } return scr_str_new("undefined", 9); /* unreachable */ diff --git a/packages/runtime/src/scr_inspect.h b/packages/runtime/src/scr_inspect.h index e489b23b7..f8d28b4f0 100644 --- a/packages/runtime/src/scr_inspect.h +++ b/packages/runtime/src/scr_inspect.h @@ -23,6 +23,8 @@ ScrStr *scr_insp_f64(double x); /* JS ToString, except -0 → "-0" */ ScrStr *scr_insp_str(ScrStr *s); /* the quoting ladder + line splitting */ ScrStr *scr_insp_regex(ScrRegex *re); /* /source/flags */ +/* A Date (its time value): toISOString(), or "Invalid Date" for NaN (+1). */ +ScrStr *scr_insp_date(double ms); ScrStr *scr_insp_buffer(ScrBytes *b); /* , 50-byte cap */ /* The stackless error form: [Name: message] / [Name], plus the stamped * code slot as its one extra property ({ code: 'X' }); [Name] beyond @@ -32,9 +34,16 @@ ScrStr *scr_insp_error(ScrError *e, double recurse, double depth); * traversal is here. dyn-boxed bytes render in the checked-dynamic tree's documented * Uint8Array identity. */ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth); +/* A numeric typed array that is not a Buffer: `Uint8Array(n) [ ... ]`, + * `[Uint8Array]` past the depth limit. Borrows b; +1. */ +ScrStr *scr_insp_typed_array(ScrBytes *b, double recurse, double depth); /* format's %s / rest-args twin: dyn strings pass verbatim. */ ScrStr *scr_insp_dyn_s(ScrDyn *d, double depth); void scr_insp_begin(double recurse); +/* util.inspect's breakLength, compact (0 for false) and sorted for the + * renders that follow; the call site restores the defaults (80, 3, false) + * afterwards. */ +void scr_insp_layout(double break_length, double compact, bool sorted); void scr_insp_entry(ScrStr *s, bool is_num); /* Circular references, Node-exact ( / [Circular *N]): the * compiler-emitted helpers over CYCLE-CAPABLE composites call circ_check 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 355165333..b23e78241 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; @@ -482,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; @@ -489,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; @@ -539,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; @@ -551,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; } @@ -1449,6 +1481,8 @@ ScrDyn *scr_dyn_iter_pack(const ScrDyn *src, const ScrStr *msg) { return scr_dyn_not_iterable(src, msg); } +static ScrDyn *scr_native_iterator_drain(const ScrDyn *src); + /* The for-of-over-dyn pack accessors: the emitted index loop drives them * over a scr_dyn_iter_pack result (ARR by construction — the defensive * arms cover nothing reachable from that lowering). Never throw. */ @@ -1463,6 +1497,13 @@ ScrDyn *scr_dyn_map_seed_entries(const ScrDyn *src) { scr_dyn_release(view); if (scr_exc_pending()) return NULL; } + /* A native iterator (sp.entries(), a Map's entries()) or a handle that + * steps through live entries (URLSearchParams): producing its entries + * runs no program code, so draining them first is what the consumer + * observes. */ + if (src->kind == SCR_DYN_HANDLE && + (src->v.handle.tag == SCR_DYNH_ITERATOR || scr_dyn_handle_ops_of(src)->iter_step)) + return scr_native_iterator_drain(src); if (src->kind == SCR_DYN_HANDLE || src->kind == SCR_DYN_TYPED_REF) { static const char message[] = "new Map(entries) over native non-array iterables has no lowering"; scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); @@ -1729,13 +1770,40 @@ static bool scr_dyn_typed_ref_prototype(const ScrDyn *value) { return view && (view->kind == SCR_DYN_OBJ || view->kind == SCR_DYN_ARR); } +/* True when `prototype` is null or an object or function, the values + * Object.create and Object.setPrototypeOf accept. Otherwise throws Node's + * TypeError, whose message ends with the value's string form. */ +static bool scr_dyn_prototype_valid(const ScrDyn *prototype) { + if (scr_dyn_is_object(prototype) || scr_dyn_is_callable(prototype)) return true; + if (scr_exc_pending()) return false; + ScrStr *text = prototype->kind == SCR_DYN_SYMBOL + ? prototype->v.symbol.render(prototype->v.symbol.value) : scr_dyn_string_coerce_js(prototype); + if (!text) return false; + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, "Object prototype may only be an Object or null: "); + scr_jb_put_str(&b, text); + scr_str_release(text); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return false; +} + +/* A valid receiver or prototype whose kind this operation does not handle. */ +static void scr_dyn_prototype_unsupported(const char *operation) { + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_puts(&b, operation); + scr_jb_puts(&b, " with this value is not supported yet"); + scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); +} + ScrDyn *scr_dyn_obj_create(ScrDyn *prototype) { + if (!scr_dyn_prototype_valid(prototype)) return NULL; if (prototype != scr_builtin_object_prototype && scr_dyn_class_reflection_fence(prototype)) return NULL; bool typed = scr_dyn_typed_ref_prototype(prototype); if (scr_exc_pending()) return NULL; if (prototype->kind != SCR_DYN_OBJ && prototype->kind != SCR_DYN_ARR && prototype->kind != SCR_DYN_NULL && !typed) { - static const char message[] = "Object prototype may only be an Object or null"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_dyn_prototype_unsupported("Object.create"); return NULL; } ScrDyn *object = prototype->kind == SCR_DYN_NULL ? scr_dyn_new_obj_null_proto() : scr_dyn_new_obj(); @@ -1781,6 +1849,13 @@ ScrDyn *scr_dyn_get_prototype(ScrDyn *object) { } ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { + /* A primitive receiver is returned unchanged when the prototype is null or + * any object or function; nothing is mutated, so the prototype's kind is the + * only check. */ + if (object->kind == SCR_DYN_BOOL || object->kind == SCR_DYN_NUM || object->kind == SCR_DYN_STR || + object->kind == SCR_DYN_BIGINT || object->kind == SCR_DYN_SYMBOL) { + return scr_dyn_prototype_valid(prototype) ? scr_dyn_retain(object) : NULL; + } if (scr_dyn_class_reflection_fence(object) || scr_dyn_class_reflection_fence(prototype)) return NULL; if (object->kind == SCR_DYN_TYPED_REF) { ScrDyn *view = scr_dyn_typed_ref_materialize(object); @@ -1809,18 +1884,14 @@ ScrDyn *scr_dyn_set_prototype(ScrDyn *object, ScrDyn *prototype) { scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); return NULL; } + if (!scr_dyn_prototype_valid(prototype)) return NULL; bool typed = scr_dyn_typed_ref_prototype(prototype); if (scr_exc_pending()) return NULL; - bool primitive = object->kind == SCR_DYN_BOOL || object->kind == SCR_DYN_NUM || object->kind == SCR_DYN_STR || - object->kind == SCR_DYN_BIGINT || object->kind == SCR_DYN_SYMBOL; - if ((object->kind != SCR_DYN_OBJ && object->kind != SCR_DYN_ARR && !primitive) || + if ((object->kind != SCR_DYN_OBJ && object->kind != SCR_DYN_ARR) || (prototype->kind != SCR_DYN_OBJ && prototype->kind != SCR_DYN_ARR && prototype->kind != SCR_DYN_FUNC && prototype->kind != SCR_DYN_NULL && !typed)) { - static const char message[] = "Object prototype may only be an Object or null"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); + scr_dyn_prototype_unsupported("Object.setPrototypeOf"); return NULL; } - /* A primitive receiver is returned unchanged once the prototype is valid. */ - if (primitive) return scr_dyn_retain(object); for (const ScrDyn *current = scr_dyn_chain_link(prototype); current;) { if (current->kind == SCR_DYN_FUNC) { ScrDyn *table = scr_dyn_fn_properties(current); @@ -2607,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; } @@ -3087,6 +3220,8 @@ bool scr_dyn_typed_array_is(const ScrDyn *d, int elem) { return d && d->kind == SCR_DYN_BYTES && !d->v.bytes->is_data_view && d->v.bytes->elem == (ScrBytesElem)elem; } +static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind); + /* Node util.types reads internal brands, never user properties or prototypes. */ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (!value) return false; @@ -3105,6 +3240,7 @@ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (PROBE("isInt8Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_I8); if (PROBE("isInt16Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_I16); if (PROBE("isInt32Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_I32); + if (PROBE("isFloat16Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_F16); if (PROBE("isFloat32Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_F32); if (PROBE("isFloat64Array")) return scr_dyn_typed_array_is(value, SCR_BYTES_F64); if (PROBE("isMap")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_MAP; @@ -3117,6 +3253,16 @@ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { if (PROBE("isAsyncFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 2) != 0; if (PROBE("isGeneratorFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 1) != 0; if (PROBE("isGeneratorObject")) return scr_dyn_generator(value); + if (PROBE("isDate")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_DATE; + if (PROBE("isMapIterator")) return scr_native_iterator_over(value, 2); + if (PROBE("isSetIterator")) return scr_native_iterator_over(value, 3); + if (PROBE("isKeyObject")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_KEY_OBJECT; + /* Boxed primitives, BigInt64Array, BigUint64Array, CryptoKey and external + * values cannot be created here, so no value has these brands. */ + if (PROBE("isNumberObject") || PROBE("isStringObject") || PROBE("isBooleanObject") || + PROBE("isBigIntObject") || PROBE("isSymbolObject") || PROBE("isBoxedPrimitive") || + PROBE("isBigInt64Array") || PROBE("isBigUint64Array") || PROBE("isCryptoKey") || PROBE("isExternal")) + return false; if (PROBE("isNativeError")) { ScrError *error = scr_errdyn_err_of(value); if (!error) return false; @@ -3131,7 +3277,7 @@ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) { * anonymous with length zero. Keep their native closure identity intact. */ ScrDyn *scr_dyn_util_type_value(const ScrDyn *value, const ScrStr *probe) { static const char *const named[] = { "isDataView", "isTypedArray", "isUint8Array", "isUint8ClampedArray", "isUint16Array", "isUint32Array", - "isInt8Array", "isInt16Array", "isInt32Array", "isFloat32Array", "isFloat64Array" }; + "isInt8Array", "isInt16Array", "isInt32Array", "isFloat16Array", "isFloat32Array", "isFloat64Array" }; const char *name = ""; if (probe->len == 17 && memcmp(probe->data, "isArrayBufferView", 17) == 0) name = "isView"; for (size_t i = 0; i < sizeof named / sizeof named[0]; i++) @@ -3150,16 +3296,16 @@ ScrBytes *scr_dyn_bytes_unbox(const ScrDyn *d) { /* Numeric typed-array constructor values share native function identity. * The cache owns one reference until runtime cleanup; arbitrary strings or * functions cannot impersonate one of these constructors. */ -static SCR_TL ScrDyn *scr_bytes_ctors[9]; +static SCR_TL ScrDyn *scr_bytes_ctors[SCR_BYTES_ELEM_COUNT]; static void scr_bytes_ctors_cleanup(void) { - for (size_t i = 0; i < 9; i++) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { scr_dyn_release(scr_bytes_ctors[i]); scr_bytes_ctors[i] = NULL; } } static ScrDyn *scr_bytes_ctor_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { (void)args; (void)argc; - for (size_t i = 0; i < 9; i++) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { if (!scr_bytes_ctors[i] || scr_bytes_ctors[i]->v.fn.clo != closure) continue; ScrJsonBuf message; scr_jb_init(&message); @@ -3175,7 +3321,7 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function); static ScrDyn *scr_bytes_constructor_for(ScrBytesElem elem) { if (!scr_bytes_ctors[elem]) { bool initialized = false; - for (size_t i = 0; i < 9; i++) if (scr_bytes_ctors[i]) initialized = true; + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) if (scr_bytes_ctors[i]) initialized = true; if (!initialized) scr_atexit(scr_bytes_ctors_cleanup); scr_bytes_ctors[elem] = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_bytes_ctor_call, 3, "typed-array-constructor", scr_bytes_elem_name(elem)); @@ -3188,7 +3334,7 @@ static ScrDyn *scr_bytes_constructor_for(ScrBytesElem elem) { return scr_dyn_retain(scr_bytes_ctors[elem]); } ScrDyn *scr_bytes_constructor(const ScrStr *name) { - for (size_t i = 0; i < 9; i++) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { const char *candidate = scr_bytes_elem_name((ScrBytesElem)i); if (strlen(candidate) == name->len && memcmp(candidate, name->data, name->len) == 0) return scr_bytes_constructor_for((ScrBytesElem)i); @@ -3255,7 +3401,7 @@ bool scr_bytes_instanceof(const ScrDyn *value, const ScrDyn *callee) { } if (scr_array_buffer_ctor && callee->v.fn.clo == scr_array_buffer_ctor->v.fn.clo) return scr_array_buffer_is(value); if (scr_array_ctor && callee->v.fn.clo == scr_array_ctor->v.fn.clo) return value->kind == SCR_DYN_ARR || scr_dyn_ref_is_array(value); - for (size_t i = 0; i < 9; i++) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { if (scr_bytes_ctors[i] && callee->v.fn.clo == scr_bytes_ctors[i]->v.fn.clo) return scr_dyn_typed_array_is(value, (int)i); } @@ -3268,7 +3414,7 @@ ScrDyn *scr_bytes_construct(const ScrDyn *callee, const ScrDyn *args, const ScrS return scr_array_ctor_call(callee->v.fn.clo, args->v.arr.items, args->v.arr.len); if (callee->kind == SCR_DYN_FUNC && scr_array_buffer_ctor && callee->v.fn.clo == scr_array_buffer_ctor->v.fn.clo) return scr_array_buffer_new(args->v.arr.len ? args->v.arr.items[0] : scr_dyn_undefined()); - for (size_t i = 0; i < 9; i++) { + for (size_t i = 0; i < SCR_BYTES_ELEM_COUNT; i++) { if (callee->kind != SCR_DYN_FUNC || !scr_bytes_ctors[i] || callee->v.fn.clo != scr_bytes_ctors[i]->v.fn.clo) continue; const ScrDyn *input = args->v.arr.len > 0 ? args->v.arr.items[0] : scr_dyn_undefined(); const ScrDyn *offset = args->v.arr.len > 1 ? args->v.arr.items[1] : scr_dyn_undefined(); @@ -3505,6 +3651,9 @@ ScrDyn *scr_dyn_bind(ScrDyn *target, ScrDyn *const *args, size_t argc) { * they can box its handles. */ static SCR_TL const ScrDynHandleOps *scr_dynh_ops[SCR_DYNH_COUNT]; +/* BroadcastChannel's EventTarget table (scr_et_broadcast_install). */ +SCR_TL const ScrEtBroadcastOps *scr_et_broadcast_ops = NULL; + void scr_dyn_handle_install(ScrDynHandleTag tag, const ScrDynHandleOps *ops) { scr_dynh_ops[tag] = ops; } @@ -3832,7 +3981,7 @@ ScrDyn *scr_dyn_new_handle(void *h, ScrDynHandleTag tag) { d->v.handle.ptr = scr_dyn_handle_ops(tag)->retain(h); d->v.handle.tag = tag; d->v.handle.traced = tag == SCR_DYNH_WEAK_MAP || tag == SCR_DYNH_WEAK_SET || - tag == SCR_DYNH_WORKER || tag == SCR_DYNH_MESSAGE_PORT || + tag == SCR_DYNH_WORKER || tag == SCR_DYNH_MESSAGE_PORT || tag == SCR_DYNH_DISPOSABLE_STACK || ((tag == SCR_DYNH_SET || tag == SCR_DYNH_MAP) && ((ScrMap *)h)->traced != 0); return d; @@ -3931,6 +4080,14 @@ void *scr_dyn_handle_unbox(const ScrDyn *d, ScrDynHandleTag tag, const ScrDynPat return scr_dyn_handle_ops(tag)->retain(d->v.handle.ptr); } +SCR_TL ScrDyn *(*scr_dyn_promise_member_fn)(const char *key, size_t len) = NULL; + +ScrDyn *scr_dyn_promise_key_get(const ScrDyn *d, const ScrStr *k) { + (void)d; + ScrDyn *method = scr_dyn_promise_member_fn ? scr_dyn_promise_member_fn(k->data, k->len) : NULL; + return method ? method : scr_dyn_retain(scr_dyn_undefined()); +} + ScrDyn *scr_dyn_handle_key_get(const ScrDyn *d, const ScrStr *k) { const ScrDynHandleOps *ops = scr_dyn_handle_ops(d->v.handle.tag); ScrDyn *r = ops->get(d->v.handle.ptr, k->data, k->len); @@ -4278,6 +4435,13 @@ typedef struct { static ScrDyn *scr_iterator_read(const ScrDyn *value, const char *key, size_t len); +/* A Map (kind 2) or Set (kind 3) iterator: util.types.isMapIterator and + * isSetIterator. */ +static bool scr_native_iterator_over(const ScrDyn *value, unsigned kind) { + return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_ITERATOR && + ((const ScrNativeIterator *)value->v.handle.ptr)->kind == kind; +} + static SCR_TL ScrDyn *scr_iterator_functions[11]; static SCR_TL bool scr_iterator_functions_registered; @@ -4538,6 +4702,33 @@ ScrDyn *scr_dyn_native_handle_iterator(ScrDyn *source, unsigned selection) { return scr_native_collection_iterator(source, selection); } +/* The values a native iterator (or a live-stepping handle's entries + * iterator) produces from its current position, into a fresh array; NULL + * with the exception pending. */ +static ScrDyn *scr_native_iterator_drain(const ScrDyn *src) { + ScrDyn *iterator = src->v.handle.tag == SCR_DYNH_ITERATOR + ? scr_dyn_retain((ScrDyn *)src) + : scr_dyn_native_handle_iterator((ScrDyn *)src, 2); + ScrDyn *out = scr_dyn_new_arr(); + for (;;) { + ScrDyn *step = scr_native_iterator_next(iterator->v.handle.ptr); + if (!step) { + scr_dyn_release(out); + out = NULL; + break; + } + bool done = scr_dyn_truthy(scr_dyn_obj_get(step, "done", 4)); + if (!done) { + const ScrDyn *value = scr_dyn_obj_get(step, "value", 5); + scr_dyn_arr_push(out, scr_dyn_retain((ScrDyn *)(value ? value : scr_dyn_undefined()))); + } + scr_dyn_release(step); + if (done) break; + } + scr_dyn_release(iterator); + return out; +} + static ScrDyn *scr_builtin_iterator_checked(int kind) { ScrDyn *self = scr_dyn_this_get(); ScrDyn *result = NULL; @@ -4804,6 +4995,11 @@ ScrDyn *scr_dyn_symbol_key_get(const ScrDyn *value, const ScrDyn *key, bool opti } bool builtin = scr_iterator_symbol && key->v.symbol.value == scr_iterator_symbol->v.symbol.value && scr_builtin_iterable(value); if (builtin && value->kind != SCR_DYN_ARR) return scr_builtin_iterator_method(value); + if (value->kind == SCR_DYN_HANDLE) { + const ScrDynHandleOps *ops = scr_dyn_handle_ops(value->v.handle.tag); + ScrDyn *member = ops->symbol_get ? ops->symbol_get(value->v.handle.ptr, key) : NULL; + if (member || scr_exc_pending()) return member; + } ScrDyn *receiver = scr_dyn_symbol_receiver(value); if (!receiver) return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); ScrDynEntry *entry = scr_dyn_symbol_entry(receiver, key, false); @@ -5327,7 +5523,7 @@ ScrStr *scr_dyn_object_tag(const ScrDyn *d) { d->v.handle.tag == SCR_DYNH_WEAK_MAP || d->v.handle.tag == SCR_DYNH_WEAK_SET || d->v.handle.tag == SCR_DYNH_SET || d->v.handle.tag == SCR_DYNH_MAP || d->v.handle.tag == SCR_DYNH_REGEXP || d->v.handle.tag == SCR_DYNH_DATE || d->v.handle.tag == SCR_DYNH_BLOB || - d->v.handle.tag == SCR_DYNH_FILE) { + d->v.handle.tag == SCR_DYNH_FILE || d->v.handle.tag == SCR_DYNH_DISPOSABLE_STACK) { ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "[object "); @@ -5484,8 +5680,8 @@ static bool scr_dyn_json_write(ScrJsonBuf *b, const ScrDyn *d) { } } -/* util.format's %j argument (+1): the stringify text, "undefined" for a - * root the stringify drops, or NULL with a pending exception (a handle +/* The JSON text of a dyn value (+1): the stringify text, "undefined" for + * a root the stringify drops, or NULL with a pending exception (a handle * inside the tree). */ ScrStr *scr_dyn_format_j(const ScrDyn *d) { ScrJsonBuf b; @@ -5845,11 +6041,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); @@ -5865,6 +6078,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, @@ -5900,6 +6153,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) { @@ -5912,7 +6166,8 @@ ScrStr *scr_dyn_to_string(const ScrDyn *d, const ScrStr *enc) { if ((d->v.handle.tag >= SCR_DYNH_ABORT_SIGNAL && d->v.handle.tag <= SCR_DYNH_ABORT_CONTROLLER) || d->v.handle.tag == SCR_DYNH_EVENT_TARGET || d->v.handle.tag == SCR_DYNH_CUSTOM_EVENT || d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || d->v.handle.tag == SCR_DYNH_SHARED_ARRAY_BUFFER || d->v.handle.tag == SCR_DYNH_SET || d->v.handle.tag == SCR_DYNH_MAP || - d->v.handle.tag == SCR_DYNH_BLOB || d->v.handle.tag == SCR_DYNH_FILE) { + d->v.handle.tag == SCR_DYNH_BLOB || d->v.handle.tag == SCR_DYNH_FILE || + d->v.handle.tag == SCR_DYNH_DISPOSABLE_STACK) { ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, "[object "); @@ -6014,6 +6269,52 @@ ScrStr *scr_dyn_to_string_argument(const ScrDyn *d, const ScrDyn *argument, cons return scr_dyn_to_string_method(d, NULL, what); } +/* The METHOD-CALL spelling `d.valueOf()`: a primitive answers itself (the + * primitive prototypes' valueOf), a Date its time value, an object with a + * valueOf member the member's result with the object as receiver, and any + * other object itself (Object.prototype.valueOf). A null-prototype + * dictionary has no valueOf; it throws " is not a function", and + * null and undefined throw Node's property-read TypeError. `what` carries + * the source spelling. Returns +1. */ +ScrDyn *scr_dyn_value_of(const ScrDyn *d, const ScrStr *what) { + if (d->kind == SCR_DYN_NULL || d->kind == SCR_DYN_UNDEF) { + static const char msg[] = "Cannot read properties of undefined (reading 'valueOf')"; + static const char msgn[] = "Cannot read properties of null (reading 'valueOf')"; + if (d->kind == SCR_DYN_NULL) scr_throw_error_msg(SCR_ERR_TYPE, msgn, sizeof msgn - 1); + else scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return NULL; + } + if (d->kind == SCR_DYN_PROXY) { + scr_dyn_proxy_unsupported("valueOf"); + return NULL; + } + if (d->kind == SCR_DYN_HANDLE && d->v.handle.tag == SCR_DYNH_DATE) { + const ScrDynHandleOps *ops = scr_dyn_handle_ops_of(d); + return ops->invoke(d->v.handle.ptr, (ScrDyn *)d, "valueOf", NULL, 0, "valueOf"); + } + if (d->kind == SCR_DYN_OBJ && scr_dyn_property_owner(d, "valueOf", 7)) { + ScrDyn *member = scr_dyn_obj_read(d, "valueOf", 7); + if (!member) return NULL; + if (member->kind != SCR_DYN_FUNC) { + scr_dyn_release(member); + } else { + scr_dyn_this_push_dyn(d); + ScrDyn *value = scr_dyn_call(member, NULL, 0, what->data); + scr_dyn_this_pop(); + scr_dyn_release(member); + return value; + } + } else if (d->kind != SCR_DYN_OBJ || scr_dyn_has_object_prototype(d)) { + return scr_dyn_retain((ScrDyn *)d); + } + ScrJsonBuf b; + scr_jb_init(&b); + scr_jb_write(&b, what->data, what->len); + scr_jb_puts(&b, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&b)); + return NULL; +} + /* JS String() over the dyn kind — the WebIDL ToString the web globals * (atob/btoa, DOMException's name resolution) run on their arguments: * the unit kinds RENDER ("null"/"undefined") where the .toString() twin @@ -6247,6 +6548,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; @@ -6362,6 +6670,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); @@ -6668,6 +6983,13 @@ bool scr_dyn_in(const ScrDyn *v, const ScrStr *key) { if (v->prototype) return scr_dyn_in(v->prototype, key); return !v->null_proto && (scr_dyn_array_proto_name(key) || scr_dyn_object_proto_name(key)); } + if (v->kind == SCR_DYN_PROMISE) { + /* Promise.prototype's methods, then Object.prototype's names. */ + static const char *const names[] = {"then", "catch", "finally"}; + for (size_t i = 0; i < 3; i++) + if (strlen(names[i]) == key->len && memcmp(names[i], key->data, key->len) == 0) return true; + return scr_dyn_object_proto_name(key); + } return false; } @@ -6908,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) { @@ -7998,7 +8227,10 @@ ScrDyn *scr_json_parse(ScrStr *text) { * layout the compiler describes in a ScrJsonSchema. It accepts only input * for which the checked-dynamic route (scr_json_parse + the generated * dynCheck builder) would succeed with the same value: every declared key - * present exactly once with a value of its declared kind; undeclared keys, + * present exactly once with a value of its declared kind, except that a + * missing undefined-armed union member takes its ABSENT state (the checked + * route's missing key reads undefined: parsed objects have no prototype + * chain to inherit from); undeclared keys, * or declared keys out of declaration order, are kept beside the record in * source order (scr_record_source_note's contract). Anything else — a * syntax error, a kind mismatch, a missing or repeated key, excessive @@ -8123,6 +8355,101 @@ static inline __attribute__((always_inline)) bool scr_jschema_member(ScrJsonP *p return scr_jschema_scalar(p, type->kind, out); } +/* An inline union field slot (INLINE_UNION_FIELD in the backend). */ +typedef struct { + uint32_t tag; + uint64_t bits; +} ScrJsonInlineUnion; + +static void scr_jschema_inline_store(char *slot, size_t tag, uint64_t bits) { + ScrJsonInlineUnion pair = { (uint32_t)tag, bits }; + memcpy(slot, &pair, sizeof pair); +} + +/* A union member value written into its field slot. Each JSON value kind + * selects at most one arm (the compiler admits no two arms one kind could + * both match), so the checked route's first full match is that arm, and + * any other value declines. */ +static __attribute__((noinline)) bool scr_jschema_union(ScrJsonP *p, const ScrJsonUnion *u, + char *slot) { + scr_json_ws(p); + if (p->pos >= p->len) return false; + char c = p->s[p->pos]; + size_t arm; + if (c == 'n') arm = SCR_JARM_NULL; + else if (c == '"') arm = SCR_JARM_STR; + else if (c == '{') arm = SCR_JARM_OBJ; + else if (c == '[') arm = SCR_JARM_ARR; + else if (c == 't' || c == 'f') arm = SCR_JARM_BOOL; + else if (c == '-' || (c >= '0' && c <= '9')) arm = SCR_JARM_NUM; + else return false; + size_t tag = u->tags[arm]; + if (tag == SCR_JSCHEMA_NO_ARM) return false; + if (arm == SCR_JARM_NULL) { + if (!scr_json_lit(p, "null", 4)) return false; + if (u->repr == SCR_JUNION_INLINE) scr_jschema_inline_store(slot, tag, 0); + else memcpy(slot, &u->null_value, sizeof u->null_value); /* immortal */ + return true; + } + ScrJsonSlot v; + if (!scr_jschema_read(p, u->arms[arm], &v)) return false; + uint64_t bits = 0; + if (arm == SCR_JARM_NUM) memcpy(&bits, &v.f, sizeof bits); + else if (arm == SCR_JARM_BOOL) bits = v.b ? 1 : 0; + else bits = (uint64_t)(uintptr_t)v.ptr; + switch (u->repr) { + case SCR_JUNION_NULLABLE: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* moves in */ + case SCR_JUNION_INLINE: scr_jschema_inline_store(slot, tag, bits); break; + default: { + void *box = u->box((uint32_t)tag, bits); /* the payload moves in */ + memcpy(slot, &box, sizeof box); + } + } + return true; +} + +/* A declared member value written into its (zeroed) slot of `rec`. */ +static inline __attribute__((always_inline)) bool scr_jschema_field(ScrJsonP *p, + const ScrJsonSchemaField *f, + char *rec) { + char *slot = rec + f->offset; + const size_t kind = f->type->kind; + ScrJsonSlot v; + if (scr_jschema_is_scalar(kind)) { + scr_json_ws(p); + if (p->pos >= p->len || !scr_jschema_scalar(p, kind, &v)) return false; + switch (kind) { + case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break; + case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break; + default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */ + } + return true; + } + if (kind == SCR_JSCHEMA_UNION) return scr_jschema_union(p, f->type->uni, slot); + if (!scr_jschema_read(p, f->type, &v)) return false; + memcpy(slot, &v.ptr, sizeof v.ptr); /* zeroed slot; moves in */ + return true; +} + +/* The declared members a record's text omitted (`missing`, by field + * index): each undefined-armed union member takes its ABSENT state, as the + * checked route's missing key does. Any other omitted member declines. */ +static __attribute__((noinline)) bool scr_jschema_absent(const ScrJsonSchema *s, char *rec, + uint64_t missing) { + while (missing) { + const ScrJsonSchemaField *f = &s->fields[__builtin_ctzll(missing)]; + missing &= missing - 1; + if (f->type->kind != SCR_JSCHEMA_UNION) return false; + const ScrJsonUnion *u = f->type->uni; + size_t tag = u->tags[SCR_JARM_ABSENT]; + if (tag == SCR_JSCHEMA_NO_ARM) return false; + char *slot = rec + f->offset; + if (u->repr == SCR_JUNION_INLINE) scr_jschema_inline_store(slot, tag, 1); /* ABSENT_FIELD_PAYLOAD */ + else memcpy(slot, &u->absent_value, sizeof u->absent_value); /* immortal */ + } + return true; +} + /* The quoted key at p->pos when it is exactly `f`'s name, written without * escapes: the bytes consumed (name length + 2), else 0. Names of more * than eight bytes, and names holding a byte JSON text would escape, @@ -8145,6 +8472,32 @@ static inline size_t scr_jschema_key_direct(const ScrJsonP *p, const ScrJsonSche return (special & high & bytes) ? 0 : len + 2; } +/* An undeclared member of a record with an index signature, after its + * key: the value, read as the index value type, joins the overflow map in + * source order, as the checked route copies the parsed object's entries. + * A repeated key (last value, first position) and `__proto__` decline. */ +static __attribute__((noinline)) bool scr_jschema_overflow(ScrJsonP *p, const ScrJsonSchema *s, + char *rec, const char *key, + size_t key_len) { + scr_json_ws(p); + if (p->pos >= p->len || p->s[p->pos] != ':') return false; + p->pos++; + if (key_len == 9 && memcmp(key, "__proto__", 9) == 0) return false; + ScrJsonSlot v; + if (!scr_jschema_member(p, s->elem, &v)) return false; + ScrMap *map; + memcpy(&map, rec + s->ovf_offset, sizeof map); + uint32_t before = map->nlive; + ScrStr *name = scr_str_new(key, key_len); + switch (s->elem->kind) { + case SCR_JSCHEMA_F64: scr_map_set_str_f64(map, name, v.f); break; + case SCR_JSCHEMA_BOOL: scr_map_set_str_bool(map, name, v.b); break; + default: scr_map_set_str_ref(map, name, v.ptr); break; /* moves in */ + } + scr_str_release(name); + return map->nlive != before; +} + /* A record's own-key view, started at the first member that leaves the * layout's declaration order: placeholders for the declared members read so * far, which arrived in that order. */ @@ -8179,14 +8532,7 @@ static __attribute__((noinline, cold)) bool scr_jschema_record_kept( if (seen & bit) goto fail; /* duplicate: last-wins stays on the checked route */ seen |= bit; guess = idx + 1; - ScrJsonSlot v; - if (!scr_jschema_member(p, f->type, &v)) goto fail; - char *slot = rec + f->offset; - switch (f->type->kind) { - case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break; - case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break; - default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */ - } + if (!scr_jschema_field(p, f, rec)) goto fail; scr_dyn_obj_set(view, key, key_len, scr_dyn_retain(scr_dyn_undefined())); } else { /* A repeated undeclared key stays on the checked route too. */ @@ -8230,7 +8576,7 @@ static __attribute__((noinline, cold)) bool scr_jschema_record_kept( } } } - if (seen != all) goto fail; + if (seen != all && !scr_jschema_absent(s, rec, all & ~seen)) goto fail; ScrRecordSource entry = { rec, view, (const char *)&s->fields[0].name, (const char *)&s->fields[0].name_len, sizeof *s->fields, sizeof *s->fields, n }; @@ -8285,9 +8631,19 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot } } /* An undeclared member, or one before the previous match, keeps the - * source beside the record: the rest of the object parses there. */ - if (idx == n || idx + 1 < guess) + * source beside the record: the rest of the object parses there. + * Records with an index signature keep no source; their undeclared + * members join the overflow map. */ + if (s->elem) { + if (idx == n) { + bool joined = scr_jschema_overflow(p, s, rec, key, key_len); + scr_str_release(decoded); + if (!joined) goto fail; + goto separator; + } + } else if (idx == n || idx + 1 < guess) { return scr_jschema_record_kept(p, s, out, rec, seen, guess, idx, key, key_len, decoded); + } scr_str_release(decoded); } scr_json_ws(p); @@ -8299,15 +8655,9 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot if (seen & bit) goto fail; /* duplicate: last-wins stays on the checked route */ seen |= bit; guess = idx + 1; - ScrJsonSlot v; - if (!scr_jschema_member(p, f->type, &v)) goto fail; - char *slot = rec + f->offset; - switch (f->type->kind) { - case SCR_JSCHEMA_F64: memcpy(slot, &v.f, sizeof v.f); break; - case SCR_JSCHEMA_BOOL: *(uint8_t *)slot = v.b ? 1 : 0; break; - default: memcpy(slot, &v.ptr, sizeof v.ptr); break; /* zeroed slot; moves in */ - } + if (!scr_jschema_field(p, f, rec)) goto fail; } + separator: scr_json_ws(p); if (p->pos >= p->len) goto fail; if (p->s[p->pos] == ',') { @@ -8319,7 +8669,7 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot break; } close: - if (seen != all) goto fail; + if (seen != all && !scr_jschema_absent(s, rec, all & ~seen)) goto fail; out->ptr = rec; return true; fail: @@ -8664,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. */ @@ -8674,7 +9027,9 @@ static ScrDyn *scr_json_replace(const ScrDyn *holder, const ScrStr *key, const S ? scr_dyn_typed_ref_materialize(value) : NULL; const ScrDyn *inspected = view ? view : value; if (scr_exc_pending()) { scr_dyn_release(view); scr_dyn_release(value); return NULL; } - if (inspected->kind == SCR_DYN_HANDLE && (inspected->v.handle.tag == SCR_DYNH_DATE || inspected->v.handle.tag == SCR_DYNH_URL)) { + if (inspected->kind == SCR_DYN_HANDLE && (inspected->v.handle.tag == SCR_DYNH_DATE || inspected->v.handle.tag == SCR_DYNH_URL || + inspected->v.handle.tag == SCR_DYNH_NET_SOCKET_ADDRESS || + inspected->v.handle.tag == SCR_DYNH_NET_BLOCK_LIST)) { const ScrDynHandleOps *ops = scr_dyn_handle_ops_of(inspected); ScrDyn *converted = ops->invoke(inspected->v.handle.ptr, value, "toJSON", NULL, 0, "toJSON"); scr_dyn_release(value); @@ -8699,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); @@ -8948,6 +9310,243 @@ ScrDyn *scr_json_stringify_value(const ScrDyn *value, const ScrDyn *replacer, co return result; } +/* util.format's %j over a pending stringify exception (Node's + * tryStringify): a TypeError whose first message line is "Converting + * circular structure to JSON" is cleared and the result is the text + * "[Circular]" (+1). Any other exception is put back and the result is + * NULL. */ +static ScrStr *scr_json_circular_or_rethrow(void) { + ScrCaught *caught = scr_exc_take(); + ScrDyn *thrown = scr_caught_to_dyn(caught); + ScrError *error = scr_errdyn_err_of(thrown); + scr_dyn_release(thrown); + static const char circular[] = "Converting circular structure to JSON"; + bool is_circular = false; + if (error && error->name && error->message) { + const ScrStr *message = error->message; + const char *newline = memchr(message->data, '\n', message->len); + size_t first_line = newline ? (size_t)(newline - message->data) : message->len; + is_circular = error->name->len == 9 && memcmp(error->name->data, "TypeError", 9) == 0 && + first_line == sizeof circular - 1 && memcmp(message->data, circular, first_line) == 0; + } + scr_error_release(error); + if (!is_circular) scr_rethrow(caught); + scr_caught_release(caught); + return is_circular ? scr_str_new("[Circular]", 10) : NULL; +} + +/* util.format's %j over a dynamic value (+1): JSON.stringify(d), the text + * "undefined" when the stringify returns undefined, and "[Circular]" for a + * circular structure. NULL with any other exception pending. */ +ScrStr *scr_util_format_j(const ScrDyn *d) { + ScrStr *gap = scr_str_new("", 0); + ScrDyn *result = scr_json_stringify_replacer(d, scr_dyn_undefined(), gap); + scr_str_release(gap); + if (!result) return scr_exc_pending() ? scr_json_circular_or_rethrow() : NULL; + ScrStr *text = result->kind == SCR_DYN_STR ? scr_str_retain(result->v.str) : scr_str_new("undefined", 9); + scr_dyn_release(result); + return text; +} + +/* util.format's %j over a typed value whose serializer can throw (+1): the + * serialized text when no exception is pending, "[Circular]" for a circular + * structure, NULL with any other exception pending. Borrows text. */ +ScrStr *scr_util_format_j_text(ScrStr *text) { + if (!scr_exc_pending()) return scr_str_retain(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); } @@ -9164,6 +9763,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 = 0; +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; @@ -9174,6 +9828,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; @@ -10233,6 +10891,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; @@ -12053,6 +12712,7 @@ SCR_ARRAY_BUFFER_VIEW(i16, SCR_BYTES_I16) SCR_ARRAY_BUFFER_VIEW(u8, SCR_BYTES_U8) SCR_ARRAY_BUFFER_VIEW(u32, SCR_BYTES_U32) SCR_ARRAY_BUFFER_VIEW(i32, SCR_BYTES_I32) +SCR_ARRAY_BUFFER_VIEW(f16, SCR_BYTES_F16) SCR_ARRAY_BUFFER_VIEW(f32, SCR_BYTES_F32) SCR_ARRAY_BUFFER_VIEW(f64, SCR_BYTES_F64) #undef SCR_ARRAY_BUFFER_VIEW diff --git a/packages/runtime/src/scr_json.h b/packages/runtime/src/scr_json.h index c292646bd..8a353f40a 100644 --- a/packages/runtime/src/scr_json.h +++ b/packages/runtime/src/scr_json.h @@ -12,16 +12,43 @@ ScrDyn *scr_json_parse(ScrStr *text); /* Compiler-emitted description of a statically known JSON.parse target * (`JSON.parse(text) as T`). Constant data in the program image; the LLVM - * backend mirrors this layout ({size, size, ptr, ptr, ptr, ptr, ptr} and - * {ptr, size, size, ptr}). Records list their declared fields (at most - * 64), each with its byte offset in the record struct; bool fields are one - * byte, f64 fields doubles, every other kind a +1 pointer. */ + * backend mirrors this layout ({size, size, ptr, ptr, ptr, ptr, ptr, ptr, size}, + * {ptr, size, size, ptr} and {size, [7 x size], [7 x ptr], ptr, ptr, ptr}). + * Records list their declared fields (at most 64), each with its byte + * offset in the record struct; bool fields are one byte, f64 fields + * doubles, inline union fields a {uint32 tag, uint64 bits} pair, every + * other kind a +1 pointer. Unions appear only as record field types. A + * record with an index signature names its index value type in `elem` and + * its overflow map's offset in `ovf_offset`. */ enum { SCR_JSCHEMA_F64 = 0, SCR_JSCHEMA_BOOL = 1, SCR_JSCHEMA_STR = 2, SCR_JSCHEMA_REC = 3, SCR_JSCHEMA_ARR = 4, + SCR_JSCHEMA_UNION = 5, +}; +/* A union's arms by the JSON value kind that selects them. ABSENT names + * the undefined arm, which no JSON value selects: a missing member takes + * the field's ABSENT state through it. */ +enum { + SCR_JARM_NULL = 0, + SCR_JARM_NUM = 1, + SCR_JARM_BOOL = 2, + SCR_JARM_STR = 3, + SCR_JARM_OBJ = 4, + SCR_JARM_ARR = 5, + SCR_JARM_ABSENT = 6, + SCR_JARM_COUNT = 7, +}; +#define SCR_JSCHEMA_NO_ARM SIZE_MAX +/* How a union field slot stores its value (the compiler's choice for the + * union type): the reference payload itself (nullable unions), the tag + * and bits in the record (scalar unions), or a tagged box. */ +enum { + SCR_JUNION_NULLABLE = 0, + SCR_JUNION_INLINE = 1, + SCR_JUNION_BOXED = 2, }; typedef struct ScrJsonSchema ScrJsonSchema; typedef struct { @@ -30,14 +57,26 @@ typedef struct { size_t offset; const ScrJsonSchema *type; } ScrJsonSchemaField; +typedef struct { + size_t repr; + size_t tags[SCR_JARM_COUNT]; /* canonical arm tag, or SCR_JSCHEMA_NO_ARM */ + const ScrJsonSchema *arms[SCR_JARM_COUNT]; /* value arms (NUM..ARR) */ + void *null_value; /* nullable, boxed: the null arm's value */ + void *absent_value; /* nullable, boxed: the ABSENT field state */ + /* boxed: a +1 box of the value arm `tag`; bits hold its double, its + * bool, or its +1 payload pointer (moved in). */ + void *(*box)(uint32_t tag, uint64_t bits); +} ScrJsonUnion; struct ScrJsonSchema { size_t kind; size_t nfields; const ScrJsonSchemaField *fields; /* records */ - const ScrJsonSchema *elem; /* arrays */ + const ScrJsonSchema *elem; /* arrays; records: the index value type */ void *(*rec_new)(void); /* records: zeroed instance, rc 1 */ ScrArr *(*arr_new)(size_t); /* arrays: empty array of the element kind */ void (*release)(void *); /* records and arrays */ + const ScrJsonUnion *uni; /* unions */ + size_t ovf_offset; /* records with an index signature */ }; /* One-pass parse straight into the native layout. Returns the +1 record @@ -123,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/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index cdf939bb9..3946331be 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -691,6 +691,117 @@ static void json_schema_parse(void) { #endif } +/* Union members: a nullable string with an undefined arm, an inline + * number | null | undefined pair, and a boxed number | string. */ +typedef struct { + uint32_t tag; + uint64_t bits; +} TestInline; +typedef struct { + size_t rc; + ScrStr *name; /* string | undefined: the payload, NULL, or test_absent */ + TestInline age; /* number (0) | null (1) | undefined (2) */ + ScrUnion *key; /* number (0) | string (1) */ +} TestOpt; +static size_t test_absent[4] = {SIZE_MAX, 0, 0, 0}; +static int test_opts_live = 0; +static void *test_opt_new(void) { + TestOpt *opt = calloc(1, sizeof *opt); + assert(opt); + opt->rc = 1; + test_opts_live++; + return opt; +} +static void test_opt_release(void *ptr) { + TestOpt *opt = ptr; + if (!opt || --opt->rc) return; + if (opt->name && opt->name != (ScrStr *)test_absent) scr_str_release(opt->name); + scr_union_release(opt->key); + test_opts_live--; + scr_weak_dispose(opt); + free(opt); +} +static void *test_key_box(uint32_t tag, uint64_t bits) { + if (tag == 0) { + double d; + memcpy(&d, &bits, sizeof d); + return scr_union_new_f64(0, d); + } + return scr_union_new_ref(1, (void *)(uintptr_t)bits, scr_str_retain_v, scr_str_release_v, NULL); +} +#define NO_ARM SCR_JSCHEMA_NO_ARM +static const ScrJsonUnion test_name_union = { + SCR_JUNION_NULLABLE, {NO_ARM, NO_ARM, NO_ARM, 0, NO_ARM, NO_ARM, 1}, + {NULL, NULL, NULL, &test_str, NULL, NULL, NULL}, NULL, test_absent, NULL}; +static const ScrJsonUnion test_age_union = { + SCR_JUNION_INLINE, {1, 0, NO_ARM, NO_ARM, NO_ARM, NO_ARM, 2}, + {NULL, &test_f64, NULL, NULL, NULL, NULL, NULL}, NULL, NULL, NULL}; +static const ScrJsonUnion test_key_union = { + SCR_JUNION_BOXED, {NO_ARM, 0, NO_ARM, 1, NO_ARM, NO_ARM, NO_ARM}, + {NULL, &test_f64, NULL, &test_str, NULL, NULL, NULL}, NULL, NULL, test_key_box}; +#undef NO_ARM +static const ScrJsonSchema test_name = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_name_union}; +static const ScrJsonSchema test_age = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_age_union}; +static const ScrJsonSchema test_key = {SCR_JSCHEMA_UNION, 0, NULL, NULL, NULL, NULL, NULL, &test_key_union}; +static const ScrJsonSchemaField test_opt_fields[] = { + {"name", 4, offsetof(TestOpt, name), &test_name}, + {"age", 3, offsetof(TestOpt, age), &test_age}, + {"key", 3, offsetof(TestOpt, key), &test_key}, +}; +static const ScrJsonSchema test_opt = {SCR_JSCHEMA_REC, 3, test_opt_fields, NULL, test_opt_new, NULL, test_opt_release}; + +static TestOpt *opt_parse(const char *text) { + ScrStr *input = scr_str_new(text, strlen(text)); + TestOpt *opt = scr_json_parse_schema(input, &test_opt); + scr_str_release(input); + assert(!scr_exc_pending()); + return opt; +} + +static void json_schema_union_members(void) { + TestOpt *opt = opt_parse("{\"name\":\"n\",\"age\":2.5,\"key\":\"k\"}"); + assert(opt && str_is(opt->name, "n") && opt->age.tag == 0); + double age; + memcpy(&age, &opt->age.bits, sizeof age); + assert(age == 2.5); + assert(opt->key->tag == 1 && str_is(scr_union_peek(opt->key), "k")); + assert(!scr_record_source_has(opt)); + test_opt_release(opt); + /* Missing undefined-armed members take their ABSENT states; null lands + * in the null arm with zero bits. */ + opt = opt_parse("{\"key\":-1}"); + assert(opt && opt->name == (ScrStr *)test_absent); + assert(opt->age.tag == 2 && opt->age.bits == 1); + assert(opt->key->tag == 0 && scr_union_get_f64(opt->key) == -1); + test_opt_release(opt); + opt = opt_parse("{\"age\":null,\"key\":0}"); + assert(opt && opt->name == (ScrStr *)test_absent && opt->age.tag == 1 && opt->age.bits == 0); + test_opt_release(opt); + /* A reordered member keeps the source beside the record, absent members + * included. */ + opt = opt_parse("{\"key\":\"z\",\"name\":\"y\"}"); + assert(opt && str_is(opt->name, "y") && opt->age.tag == 2 && opt->age.bits == 1); + assert(scr_record_source_has(opt)); + test_opt_release(opt); + /* No arm of the value's kind, or a missing member without an undefined + * arm, declines. */ + static const char *const declined[] = { + "{\"name\":null,\"key\":1}", + "{\"age\":\"1\",\"key\":1}", + "{\"key\":true}", + "{\"key\":null}", + "{\"name\":\"a\"}", + "{}", + "{\"key\":1,\"key\":2}", + "{\"age\":nul,\"key\":1}", + }; + for (size_t i = 0; i < sizeof declined / sizeof *declined; i++) { + assert(!opt_parse(declined[i])); + assert(test_opts_live == 0); + } + assert(test_opts_live == 0); +} + int main(void) { json_string_boundaries(); json_indent_ownership(); @@ -702,6 +813,7 @@ int main(void) { json_intact_regions(); json_schema_parse(); json_schema_special_names(); + json_schema_union_members(); /* Native Set boxes may own headerless scalar/string maps. Collecting an * enclosing cycle must neither trace those leaves nor skip their release. */ for (int i = 0; i < 2000; i++) { diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index d5b581ede..9307d9b01 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; @@ -1942,16 +1975,20 @@ void scr_os_ifaddrs_free(ScrIfaddrs *s) { * event-free binaries and in the standalone runtime C tests). */ SCR_TL void (*scr_process_exit_hook)(double code) = NULL; SCR_TL void (*scr_warning_emit_hook)(const char *name, const char *code, ScrStr *message) = NULL; +SCR_TL bool scr_warning_trace_hint_shown = false; SCR_TL void (*scr_stdin_destroy_hook)(void) = NULL; -void scr_process_set_exit_code(double code) { +/* process.exitCode's validation: a safe integer, or a RangeError + * (ERR_OUT_OF_RANGE). Node converts valid codes to signed int32 for + * exitCode and exit listeners; the OS observes the low eight bits. */ +static bool scr_process_exit_code_of(double code, int *out) { if (!isfinite(code) || trunc(code) != code) { char recv[48], msg[160]; scr_num_received(code, recv); int len = snprintf(msg, sizeof msg, "The value of \"code\" is out of range. It must be an integer. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } if (fabs(code) > 9007199254740991.0) { char recv[48], msg[192]; @@ -1959,15 +1996,19 @@ void scr_process_set_exit_code(double code) { int len = snprintf(msg, sizeof msg, "The value of \"code\" is out of range. It must be >= -9007199254740991 && <= 9007199254740991. Received %s", recv); scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)len, "ERR_OUT_OF_RANGE"); - return; + return false; } - /* Node converts valid safe integers to signed int32 for exitCode and - * exit listeners. The OS subsequently observes the low eight bits. */ double low = fmod(code, 4294967296.0); if (low < 0) low += 4294967296.0; int64_t signed_code = (int64_t)low; if (signed_code > INT_MAX) signed_code -= 4294967296LL; - scr_exit_code_note((int)signed_code); + *out = (int)signed_code; + return true; +} + +void scr_process_set_exit_code(double code) { + int converted; + if (scr_process_exit_code_of(code, &converted)) scr_exit_code_note(converted); } double scr_process_exit_code_or_zero(void) { @@ -2025,6 +2066,11 @@ static void scr_detach_teardown(void) { #endif void scr_process_exit(double code) { + /* process.exit(code) assigns process.exitCode first, so a code that is + * not a safe integer throws its RangeError and the program continues. */ + int converted; + if (!scr_process_exit_code_of(code, &converted)) return; + code = converted; /* Node runs 'exit' listeners on explicit process.exit() too — they run * HERE, synchronously, before the teardown-free exit below. The hook is * non-NULL only when the events unit is linked (scr_events.c). */ @@ -10170,6 +10216,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); @@ -10860,7 +10908,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); @@ -10868,9 +10916,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/packages/runtime/src/scr_lib.h b/packages/runtime/src/scr_lib.h index 64f3e77a5..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, @@ -64,6 +71,23 @@ int scr_stdio_write_raw(int fd, const void *data, size_t len); /* `wide` marks string bytes that may hold lone surrogates, which become * U+FFFD when they reach the stream itself. */ extern SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide); +/* A worker created with the stdout or stderr option: answers true when it + * took the bytes for fd 1 or 2 instead of the process descriptor. */ +extern SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len); +/* A worker created with the stdin option: process.stdin reads the chunks + * the parent writes to worker.stdin instead of fd 0. The worker's event + * dispatch queues them (scr_stdin_fed_push; NULL ends the input) and the + * stdin service takes them (+1, or NULL with *ended set once the input + * ended). scr_stdin_fed_attach (the worker unit) starts that dispatch + * when process.stdin is first used; scr_stdin_fed_waiting (the events + * unit) says whether process.stdin still wants data, which keeps the + * worker's loop waiting on its mailbox. */ +extern SCR_TL bool scr_stdin_fed; +extern SCR_TL void (*scr_stdin_fed_attach)(void); +extern SCR_TL bool (*scr_stdin_fed_waiting)(void); +void scr_stdin_fed_push(struct ScrBytes *chunk /* moves; NULL ends the input */); +struct ScrBytes *scr_stdin_fed_take(bool *ended); +void scr_stdin_fed_clear(void); /* process.stdout/.stderr .write — raw bytes (no newline or formatting; data * borrowed), promptly visible and ordered with console output. Constantly * true (the synchronous runtime never queues backpressure). */ diff --git a/packages/runtime/src/scr_library.c b/packages/runtime/src/scr_library.c index 60e11ae0c..b2a5d4c75 100644 --- a/packages/runtime/src/scr_library.c +++ b/packages/runtime/src/scr_library.c @@ -371,6 +371,30 @@ void scr_library_bytes_out(ScrBytes *b, const uint8_t **out, size_t *out_len) { *out_len = b->len; } +ScrStr *scr_library_cstr_in(const char *p, const char *trap_msg) { + if (p == NULL) scr_trap(trap_msg); + return scr_str_from_utf8_lossy((const uint8_t *)p, strlen(p)); +} + +/* A C string ends at its first NUL: text holding U+0000 cannot cross. */ +static void scr_library_cstr_check(const ScrStr *s) { + if (memchr(s->data, 0, s->len) != NULL) { + scr_trap("scriptc: TypeError: a cstring value contains U+0000, which a C string cannot " + "hold\n"); + } +} + +const char *scr_library_cstr_out(ScrStr *s) { + scr_library_arena_keep(s, true); + scr_library_cstr_check(s); + return s->data; /* NUL-terminated after len (ScrStr layout) */ +} + +const char *scr_library_cstr_arg(const ScrStr *s) { + scr_library_cstr_check(s); + return s->data; +} + /* ── the reset registry + session reset ───────────────────────────────── * Where an executable's always-linked units atexit() their lazy teardowns, * library builds register here (scr_atexit in scr_runtime.h): registered diff --git a/packages/runtime/src/scr_mailbox.c b/packages/runtime/src/scr_mailbox.c index 89cb6ce83..f983bd289 100644 --- a/packages/runtime/src/scr_mailbox.c +++ b/packages/runtime/src/scr_mailbox.c @@ -128,6 +128,7 @@ ScrMailbox *scr_mailbox_retain(ScrMailbox *mailbox) { void scr_mail_event_free(ScrMailEvent *event) { if (!event) return; scr_message_free(event->message); + free(event->data); free(event); } @@ -177,6 +178,24 @@ ScrMailEvent *scr_mailbox_take(ScrMailbox *mailbox) { return event; } +ScrMailEvent *scr_mailbox_take_kind(ScrMailbox *mailbox, unsigned kind, uint64_t source) { + scr_mailbox_lock(mailbox); + ScrMailEvent **slot = &mailbox->head; + while (*slot && ((*slot)->kind != kind || (*slot)->source != source)) slot = &(*slot)->next; + ScrMailEvent *event = *slot; + if (event) { + *slot = event->next; + if (mailbox->tail == &event->next) mailbox->tail = slot; + event->next = NULL; + } + if (!mailbox->head) { + mailbox->tail = &mailbox->head; + scr_mailbox_drain(mailbox); + } + scr_mailbox_unlock(mailbox); + return event; +} + void scr_mailbox_close(ScrMailbox *mailbox) { scr_mailbox_lock(mailbox); mailbox->closed = true; @@ -420,3 +439,80 @@ void scr_channel_drop(ScrChannel *channel, int side) { scr_mail_event_free(event); } } + +/* ── BroadcastChannel registry ───────────────────────────────────────── */ + +/* Every open BroadcastChannel in the process, by name: each has a channel + * whose end 0 queues its incoming messages. */ +typedef struct ScrBroadcastEntry { + char *name; + size_t length; + ScrChannel *channel; /* +1 */ + struct ScrBroadcastEntry *next; +} ScrBroadcastEntry; + +static ScrBroadcastEntry *scr_broadcast_entries; +#ifdef _WIN32 +static SRWLOCK scr_broadcast_lock = SRWLOCK_INIT; +#define SCR_BROADCAST_LOCK() AcquireSRWLockExclusive(&scr_broadcast_lock) +#define SCR_BROADCAST_UNLOCK() ReleaseSRWLockExclusive(&scr_broadcast_lock) +#else +static pthread_mutex_t scr_broadcast_lock = PTHREAD_MUTEX_INITIALIZER; +#define SCR_BROADCAST_LOCK() (void)pthread_mutex_lock(&scr_broadcast_lock) +#define SCR_BROADCAST_UNLOCK() (void)pthread_mutex_unlock(&scr_broadcast_lock) +#endif + +void scr_broadcast_join(const char *name, size_t length, ScrChannel *channel) { + ScrBroadcastEntry *entry = calloc(1, sizeof(*entry)); + if (!entry) scr_trap("scriptc: out of memory\n"); + entry->name = malloc(length ? length : 1); + if (!entry->name) scr_trap("scriptc: out of memory\n"); + memcpy(entry->name, name, length); + entry->length = length; + entry->channel = scr_channel_retain(channel); + SCR_BROADCAST_LOCK(); + /* registration order is delivery order to the subscribers */ + ScrBroadcastEntry **tail = &scr_broadcast_entries; + while (*tail) tail = &(*tail)->next; + *tail = entry; + SCR_BROADCAST_UNLOCK(); +} + +void scr_broadcast_leave(ScrChannel *channel) { + ScrBroadcastEntry *gone = NULL; + SCR_BROADCAST_LOCK(); + for (ScrBroadcastEntry **at = &scr_broadcast_entries; *at; at = &(*at)->next) { + if ((*at)->channel != channel) continue; + gone = *at; + *at = gone->next; + break; + } + SCR_BROADCAST_UNLOCK(); + if (!gone) return; + scr_channel_release(gone->channel); + free(gone->name); + free(gone); +} + +/* The other open channels named `name` (+1 each, in registration order); + * the caller frees the array. */ +size_t scr_broadcast_peers(const char *name, size_t length, ScrChannel *self, ScrChannel ***out) { + size_t n = 0, cap = 0; + ScrChannel **peers = NULL; + SCR_BROADCAST_LOCK(); + for (ScrBroadcastEntry *entry = scr_broadcast_entries; entry; entry = entry->next) { + if (entry->channel == self || entry->length != length || + (length && memcmp(entry->name, name, length) != 0)) + continue; + if (n == cap) { + cap = cap ? cap * 2 : 4; + ScrChannel **grown = realloc(peers, cap * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + peers = grown; + } + peers[n++] = scr_channel_retain(entry->channel); + } + SCR_BROADCAST_UNLOCK(); + *out = peers; + return n; +} diff --git a/packages/runtime/src/scr_mailbox.h b/packages/runtime/src/scr_mailbox.h index 75d99bbc8..678d17723 100644 --- a/packages/runtime/src/scr_mailbox.h +++ b/packages/runtime/src/scr_mailbox.h @@ -9,6 +9,8 @@ typedef struct ScrMailEvent { uint64_t source; int code; ScrMessage *message; + unsigned char *data; /* raw bytes (a worker's captured stdout or stderr), owned */ + size_t length; struct ScrMailEvent *next; } ScrMailEvent; @@ -19,6 +21,8 @@ void scr_mailbox_release(ScrMailbox *mailbox); * closed. Taking returns an owned event; no runtime object crosses a queue. */ bool scr_mailbox_post(ScrMailbox *mailbox, ScrMailEvent *event); ScrMailEvent *scr_mailbox_take(ScrMailbox *mailbox); +/* The first queued event with this kind and source, or NULL. */ +ScrMailEvent *scr_mailbox_take_kind(ScrMailbox *mailbox, unsigned kind, uint64_t source); void scr_mail_event_free(ScrMailEvent *event); void scr_mailbox_close(ScrMailbox *mailbox); bool scr_mailbox_pending(ScrMailbox *mailbox); @@ -46,4 +50,12 @@ size_t scr_channel_count(ScrChannel *channel, int side); uint64_t scr_channel_peer_closed(ScrChannel *channel, int side); /* the notice's order, 0: none */ void scr_channel_drop(ScrChannel *channel, int side); +/* BroadcastChannel: the process-wide registry of open channels by name. + * Each instance's channel queues its messages on end 0 (posts go through + * side 1). peers answers the others with the name (+1 each, array + * malloc'd). */ +void scr_broadcast_join(const char *name, size_t length, ScrChannel *channel); +void scr_broadcast_leave(ScrChannel *channel); +size_t scr_broadcast_peers(const char *name, size_t length, ScrChannel *self, ScrChannel ***out); + #endif diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index a0b3b8d4e..82428a6fe 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -31,6 +31,7 @@ typedef struct { char *key; size_t length; size_t value; + bool hidden; /* an Error's non-enumerable or inherited property */ } ScrMessageProperty; typedef struct { @@ -73,6 +74,7 @@ typedef struct { ScrDyn *view; ScrDyn *keys; size_t cursor; + bool uncaught; /* an Error encoded as an uncaught exception */ } ScrMessageVisit; typedef struct { @@ -91,6 +93,7 @@ typedef struct { size_t depth; size_t stack_capacity; const ScrDyn *transfer; /* the transfer list (a dyn array), or NULL */ + bool uncaught; /* the next value is an uncaught exception or its cause */ } ScrMessageEncoder; static bool scr_message_listed(const ScrMessageEncoder *encoder, const ScrDyn *value) { @@ -211,7 +214,44 @@ static size_t scr_message_storage(ScrMessageEncoder *encoder, const ScrBytes *by return node; } +/* The nearest built-in constructor an uncaught Error keeps: Node's + * serializer knows Error and its six native subclasses. */ +static int scr_message_error_kind(const ScrError *error) { + static const int kinds[] = { + SCR_ERR_TYPE, SCR_ERR_RANGE, SCR_ERR_SYNTAX, SCR_ERR_REFERENCE, SCR_ERR_EVAL, SCR_ERR_URI, + }; + for (size_t i = 0; i < sizeof kinds / sizeof kinds[0]; i++) + if (scr_error_vts[kinds[i]].pre <= error->vt->pre && error->vt->pre <= scr_error_vts[kinds[i]].post) + return kinds[i]; + return SCR_ERR_ERROR; +} + +/* An uncaught Error's keys: name and message, which it may inherit, then + * its other own properties in order. */ +static ScrDyn *scr_message_uncaught_keys(const ScrDyn *view) { + ScrDyn *own = scr_dyn_get_own_property_names(view); + if (!own) return NULL; + ScrDyn *keys = scr_dyn_new_arr(); + static const char *const first[] = {"name", "message"}; + for (size_t i = 0; i < 2; i++) { + ScrStr *key = scr_str_new(first[i], strlen(first[i])); + scr_dyn_arr_push(keys, scr_dyn_new_str(key)); + scr_str_release(key); + } + for (size_t i = 0; i < own->v.arr.len; i++) { + const ScrStr *key = own->v.arr.items[i]->v.str; + if ((key->len == 4 && !memcmp(key->data, "name", 4)) || (key->len == 7 && !memcmp(key->data, "message", 7)) || + (key->len && key->data[0] == '%')) + continue; + scr_dyn_arr_push(keys, scr_dyn_retain(own->v.arr.items[i])); + } + scr_dyn_release(own); + return keys; +} + static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) { + bool uncaught = encoder->uncaught; + encoder->uncaught = false; const void *identity = NULL; unsigned domain = 0; switch (input->kind) { @@ -225,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); @@ -234,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; @@ -251,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; @@ -264,6 +310,16 @@ static size_t scr_message_value(ScrMessageEncoder *encoder, const ScrDyn *input) ScrMessageNode *node = &encoder->message->nodes[id]; ScrError *error = scr_errdyn_err_of(input); if (!error && value != input) error = scr_errdyn_err_of(value); + if (error && uncaught && error->vt != &scr_error_vts[SCR_ERR_DOMEX]) { + node->kind = SCR_MESSAGE_ERROR; + node->number = scr_message_error_kind(error); + scr_error_release(error); + encoder->visits[id].uncaught = true; + encoder->visits[id].keys = scr_message_uncaught_keys(value); + if (!encoder->visits[id].keys) return SIZE_MAX; + scr_message_push(encoder, id); + return id; + } if (error) { node->kind = SCR_MESSAGE_ERROR; node->number = SCR_ERR_ERROR; @@ -355,7 +411,7 @@ unsupported: } static void scr_message_property(ScrMessageEncoder *encoder, size_t id, - const ScrStr *key, size_t value) { + const ScrStr *key, size_t value, bool hidden) { ScrMessageNode *node = &encoder->message->nodes[id]; if (node->count == node->capacity) { size_t capacity = scr_message_grow(node->capacity); @@ -366,7 +422,7 @@ static void scr_message_property(ScrMessageEncoder *encoder, size_t id, node->capacity = capacity; } node->properties[node->count++] = (ScrMessageProperty){ - (char *)scr_message_copy(key->data, key->len), key->len, value, + (char *)scr_message_copy(key->data, key->len), key->len, value, hidden, }; } @@ -504,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; @@ -521,9 +578,15 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) { } static ScrMessage *scr_message_encode_with(const ScrDyn *value, const ScrDyn *transfer); +static ScrMessage *scr_message_encode_run(ScrMessageEncoder encoder, const ScrDyn *value); ScrMessage *scr_message_encode(const ScrDyn *value) { return scr_message_encode_with(value, NULL); } +ScrMessage *scr_message_encode_uncaught(const ScrDyn *value) { + ScrMessage *message = scr_message_alloc(1, sizeof(*message)); + return scr_message_encode_run((ScrMessageEncoder){.message = message, .uncaught = true}, value); +} + ScrMessage *scr_message_encode_transfer(const ScrDyn *value, const ScrDyn *transfer) { if (!transfer || transfer->kind != SCR_DYN_ARR || transfer->v.arr.len == 0) return scr_message_encode_with(value, NULL); @@ -552,21 +615,33 @@ static ScrMessage *scr_message_encode_with(const ScrDyn *value, const ScrDyn *tr ScrMessage *published = transfer ? NULL : scr_message_try_publish(value); if (published) return published; ScrMessage *message = scr_message_alloc(1, sizeof(*message)); - ScrMessageEncoder encoder = {.message = message, .transfer = transfer}; + return scr_message_encode_run((ScrMessageEncoder){.message = message, .transfer = transfer}, value); +} + +static ScrMessage *scr_message_encode_run(ScrMessageEncoder encoder, const ScrDyn *value) { + ScrMessage *message = encoder.message; if (scr_message_value(&encoder, value) == SIZE_MAX) goto done; while (encoder.depth && !scr_exc_pending()) { size_t id = encoder.stack[encoder.depth - 1]; ScrMessageVisit *visit = &encoder.visits[id]; if (visit->cursor == visit->keys->v.arr.len) { encoder.depth--; continue; } ScrDyn *key = visit->keys->v.arr.items[visit->cursor++]; - if (key->kind != SCR_DYN_STR || - (message->nodes[id].kind != SCR_MESSAGE_ERROR && !scr_dyn_property_is_enumerable(visit->view, key->v.str))) continue; + bool error = message->nodes[id].kind == SCR_MESSAGE_ERROR; + if (key->kind != SCR_DYN_STR || (!error && !scr_dyn_property_is_enumerable(visit->view, key->v.str))) continue; ScrDyn *child = scr_dyn_bag_get(visit->view, key->v.str, visit->view); if (!child || scr_exc_pending()) { scr_dyn_release(child); break; } + bool hidden = error; + if (visit->uncaught) { + /* Node keeps an uncaught Error's data properties except functions + * and symbols, and serializes its cause the same way. */ + if (child->kind == SCR_DYN_FUNC || child->kind == SCR_DYN_SYMBOL) { scr_dyn_release(child); continue; } + hidden = !scr_dyn_has_own(visit->view, key->v.str) || !scr_dyn_property_is_enumerable(visit->view, key->v.str); + encoder.uncaught = key->v.str->len == 5 && !memcmp(key->v.str->data, "cause", 5); + } size_t next = scr_message_value(&encoder, child); scr_dyn_release(child); if (next == SIZE_MAX) break; - scr_message_property(&encoder, id, key->v.str, next); + scr_message_property(&encoder, id, key->v.str, next, hidden); } done: for (size_t i = 0; i < message->count; i++) { @@ -710,7 +785,15 @@ ScrDyn *scr_message_decode(const ScrMessage *message) { } else scr_dyn_obj_set(values[i], property->key, property->length, value); } if (node->kind == SCR_MESSAGE_ERROR) { - for (size_t j = 0; j < values[i]->v.obj.len; j++) values[i]->v.obj.entries[j].enumerable = false; + for (size_t j = 0; j < values[i]->v.obj.len; j++) { + ScrDynEntry *entry = &values[i]->v.obj.entries[j]; + entry->enumerable = false; + for (size_t k = 0; k < node->count; k++) { + const ScrMessageProperty *property = &node->properties[k]; + if (!property->hidden && property->length == entry->key_len && !memcmp(property->key, entry->key, entry->key_len)) + entry->enumerable = true; + } + } ScrError *error = scr_errdyn_err_of(values[i]); scr_error_commit_dyn(error, values[i]); scr_error_release(error); diff --git a/packages/runtime/src/scr_message.h b/packages/runtime/src/scr_message.h index ba0a7391c..ffd155fe6 100644 --- a/packages/runtime/src/scr_message.h +++ b/packages/runtime/src/scr_message.h @@ -12,6 +12,12 @@ ScrMessage *scr_message_encode(const ScrDyn *value); * message (a port in the value that is not listed throws DataCloneError); * transfer is NULL or a dyn array. */ ScrMessage *scr_message_encode_transfer(const ScrDyn *value, const ScrDyn *transfer); +/* A worker's uncaught exception, as Node's error serializer sends it: an + * Error keeps the nearest of Error and its six native subclasses, and its + * name, message and other data properties (functions and symbols aside) + * with their enumerability, own or inherited; its cause encodes the same + * way. Other values encode as scr_message_encode encodes them. */ +ScrMessage *scr_message_encode_uncaught(const ScrDyn *value); /* MessagePort transfer, installed by the worker events unit: channel_of * answers a channel port's end (+1 channel; NULL for other values, side diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 862b5ac19..1e6369348 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -527,6 +527,8 @@ struct ScrNetSocket { bool dial_deferred; ScrStr **dial_ips; size_t dial_n, dial_i; + /* a host name the dial resolves first (+1; NULL for IP literals) */ + ScrStr *dial_host; /* the in-flight dial chain's LAST failure — surfaced (moved into * pending_err) only when the list exhausts; a later success drops it */ ScrStr *dial_err; @@ -591,8 +593,28 @@ struct ScrNetSocket { bool t_want_write; /* a handshake send hit EAGAIN: arm the write filter */ bool in_registry; struct ScrNetSocket *next; + /* an HTTP CONNECT tunnel (scr_net_sock_set_tunnel), set from the dial + * until the proxy answers 200: writes buffer and the transport + * handshake waits until the tunnel is up */ + struct ScrNetTunnel *tunnel; }; +/* The CONNECT head still being sent from out_off, and the proxy's + * response head as it arrives. */ +typedef struct ScrNetTunnel { + char *out; + size_t out_len, out_off; + char *in; + size_t in_len; +} ScrNetTunnel; + +static void scr_net_tunnel_free(ScrNetTunnel *t) { + if (t == NULL) return; + free(t->out); + free(t->in); + free(t); +} + #ifdef SCR_RC_AUDIT static SCR_TL long scr_net_live = 0; long scr_net_live_count(void) { return scr_net_live; } @@ -662,6 +684,8 @@ void scr_net_sock_release(ScrNetSocket *s) { for (size_t i = 0; i < s->dial_n; i++) scr_str_release(s->dial_ips[i]); free(s->dial_ips); scr_str_release(s->dial_err); + scr_str_release(s->dial_host); + scr_net_tunnel_free(s->tunnel); free(s->wbuf); free(s->rbuf); if (s->pipe_dst) scr_net_sock_release(s->pipe_dst); @@ -907,6 +931,7 @@ static ScrNetSocket *scr_net_sock_new(void) { static void scr_net_sock_update_read(ScrNetSocket *s) { bool want = s->fd >= 0 && !s->connecting && !s->rd_eof && !s->user_paused && (s->data_ls.n > 0 || s->pipe_dst != NULL || s->native_data != NULL || + s->tunnel != NULL /* the proxy's CONNECT answer */ || s->flowing /* resume(): flow (and discard sans listeners) */ || s->readable_ls.n > 0 /* paused-mode consumer: bytes buffer */ || s->tops != NULL /* a transport is ALWAYS a consumer: the TLS @@ -932,7 +957,9 @@ static void scr_net_sock_update_read(ScrNetSocket *s) { } static void scr_net_sock_update_write(ScrNetSocket *s) { - bool want = s->fd >= 0 && (s->connecting || s->wlen > s->whead || s->t_want_write); + bool want = s->fd >= 0 && + (s->connecting || (s->tunnel != NULL ? s->tunnel->out_off < s->tunnel->out_len + : s->wlen > s->whead || s->t_want_write)); if (want && !s->write_armed) { scr_net_watch_write(s->fd, s, true); s->write_armed = true; @@ -957,7 +984,7 @@ static void scr_net_sock_close_fd(ScrNetSocket *s) { /* The FIN half: called when the write half is ENDING and the buffer just * drained (or was empty). Full close once the read half is done too. */ static void scr_net_sock_maybe_finish_write(ScrNetSocket *s) { - if (s->fd < 0 || !s->wr_ending || s->wr_done || s->wlen > s->whead) return; + if (s->fd < 0 || !s->wr_ending || s->wr_done || s->wlen > s->whead || s->tunnel != NULL) return; /* a transport says goodbye first (TLS close_notify), best-effort */ if (s->tops && s->t_est) s->tops->shutdown_write(s->tctx); shutdown(s->fd, SHUT_WR); @@ -1006,7 +1033,8 @@ static void scr_net_sock_write_raw(ScrNetSocket *s, const char *data, size_t len if (!pre_dial && (s->fd < 0 || s->wr_ending || s->close_emitted)) return; if (len == 0) return; s->bytes_written += len; /* accepted bytes (Node counts buffered ones too) */ - if (pre_dial || s->connecting || s->wlen > s->whead || (s->tops && !s->t_est)) { + if (pre_dial || s->connecting || s->tunnel != NULL || s->wlen > s->whead || + (s->tops && !s->t_est)) { /* mid-connect and mid-handshake bytes buffer; the flush after * 'connect'/establishment sends them */ scr_net_sock_buffer(s, data, len); @@ -1050,7 +1078,7 @@ void scr_net_sock_writev_native(ScrNetSocket *s, const ScrNetSlice *slices, size int cnt = 0; size_t total = 0; bool immediate = n <= 8 && s->fd >= 0 && !s->wr_ending && !s->close_emitted && - !s->connecting && s->wlen == s->whead && !s->tops; + !s->connecting && s->tunnel == NULL && s->wlen == s->whead && !s->tops; if (immediate) { for (size_t i = 0; i < n; i++) { if (slices[i].len == 0) continue; @@ -1103,6 +1131,7 @@ void scr_net_sock_writev_native(ScrNetSocket *s, const ScrNetSlice *slices, size /* Flush on write-readiness; drives the FIN when ENDING. */ static void scr_net_sock_flush(ScrNetSocket *s) { + if (s->tunnel != NULL) return; /* nothing moves until the proxy answers */ if (s->tops && !s->t_est) return; /* nothing moves until the handshake ends */ while (s->fd >= 0 && s->wlen > s->whead) { ssize_t sent = s->tops ? s->tops->xwrite(s->tctx, s->wbuf + s->whead, s->wlen - s->whead) @@ -1170,6 +1199,7 @@ static bool scr_net_sock_deliver(ScrNetSocket *s, const char *buf, size_t n) { if (s->pipe_dst) scr_net_sock_write_raw(s->pipe_dst, buf, n); if (s->data_ls.n > 0) { ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)n); + chunk->is_buffer = true; /* Node delivers a Buffer */ memcpy(chunk->data, buf, n); ScrNetL *snap; size_t nl = scr_net_ls_snapshot(&s->data_ls, &snap); @@ -1203,6 +1233,7 @@ static void scr_net_sock_transport_pump(ScrNetSocket *s); static void scr_net_sock_read(ScrNetSocket *s) { size_t reads_left = 32; /* at most 2 MiB with the 64 KiB read buffer */ + if (s->tunnel != NULL) return; /* readiness feeds the tunnel pump instead */ if (s->tops && !s->t_est) return; /* readiness feeds the handshake pump instead */ for (;;) { if (s->fd < 0 || s->user_paused) return; @@ -1295,6 +1326,8 @@ static void scr_net_sock_read(ScrNetSocket *s) { static void scr_net_sock_transport_pump(ScrNetSocket *s); static void scr_net_sock_dial_next(ScrNetSocket *s); +static void scr_net_sock_connected(ScrNetSocket *s); +static void scr_net_sock_tunnel_pump(ScrNetSocket *s); /* Connect completion (write-readiness while connecting). */ static void scr_net_sock_connect_done(ScrNetSocket *s) { @@ -1339,6 +1372,19 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { scr_str_release(s->dial_err); s->dial_err = NULL; } + if (s->tunnel != NULL) { + /* TCP to the proxy is up: the CONNECT exchange runs first */ + scr_net_sock_touch(s); + scr_net_sock_tunnel_pump(s); + return; + } + scr_net_sock_connected(s); +} + +/* The connection is usable (TCP up, and the proxy tunnel when there is + * one): the transport handshake starts, or 'connect' fires and buffered + * writes flush. */ +static void scr_net_sock_connected(ScrNetSocket *s) { scr_net_sock_update_write(s); /* keeps the filter iff bytes are buffered */ scr_net_sock_update_read(s); scr_net_sock_touch(s); @@ -1360,6 +1406,106 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { scr_net_sock_flush(s); } +/* Fails a tunnel being set up: the message is the socket's 'error'. */ +static void scr_net_sock_tunnel_fail(ScrNetSocket *s, const char *msg) { + if (!s->pending_err) s->pending_err = scr_str_new(msg, strlen(msg)); + s->had_error = true; + scr_net_sock_close_fd(s); +} + +int scr_net_tunnel_status(const char *head, size_t len) { + if (len >= 12 && memcmp(head, "HTTP/1.", 7) == 0 && head[8] == ' ' && + isdigit((unsigned char)head[9]) && isdigit((unsigned char)head[10]) && + isdigit((unsigned char)head[11])) { + return (head[9] - '0') * 100 + (head[10] - '0') * 10 + (head[11] - '0'); + } + return -1; +} + +void scr_net_tunnel_failure(int status, char *msg, size_t cap) { + if (status < 0) { + /* llhttp's message for a response that does not start with a status line */ + snprintf(msg, cap, "Response does not match the HTTP/1.1 protocol (Expected HTTP/, RTSP/ or ICE/)"); + } else { + snprintf(msg, cap, "Proxy response (%d) !== 200 when HTTP Tunneling", status); + } +} + +/* The proxy's response head is at most this long. */ +#define SCR_NET_TUNNEL_HEAD_MAX 16384u + +/* Drives the CONNECT exchange off readiness: sends the request head, + * then reads the proxy's response head. A 200 status makes the + * connection usable (bytes after the head belong to the tunnel); any + * other status fails the socket with undici's message. */ +static void scr_net_sock_tunnel_pump(ScrNetSocket *s) { + ScrNetTunnel *t = s->tunnel; + while (s->fd >= 0 && t->out_off < t->out_len) { + ssize_t sent = + scr_net_send(s->fd, t->out + t->out_off, t->out_len - t->out_off); + if (sent < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + char msg[96]; + snprintf(msg, sizeof msg, "write %s", scr_net_errname(errno)); + scr_net_sock_tunnel_fail(s, msg); + return; + } + t->out_off += (size_t)sent; + } + scr_net_sock_update_write(s); + if (s->fd < 0 || t->out_off < t->out_len) return; + for (;;) { + char buf[4096]; + ssize_t n = read(s->fd, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + scr_net_sock_update_read(s); + return; + } + char msg[96]; + snprintf(msg, sizeof msg, "read %s", scr_net_errname(errno)); + scr_net_sock_tunnel_fail(s, msg); + return; + } + if (n == 0) { + scr_net_sock_tunnel_fail(s, "socket hang up"); + return; + } + if (t->in_len + (size_t)n > SCR_NET_TUNNEL_HEAD_MAX) { + scr_net_sock_tunnel_fail(s, "Proxy response head is too large"); + return; + } + char *grown = realloc(t->in, t->in_len + (size_t)n); + if (!grown) scr_net_oom(); + t->in = grown; + memcpy(t->in + t->in_len, buf, (size_t)n); + t->in_len += (size_t)n; + size_t end = 0; + for (size_t i = 3; i < t->in_len; i++) { + if (memcmp(t->in + i - 3, "\r\n\r\n", 4) == 0) { + end = i + 1; + break; + } + } + if (end == 0) continue; + int status = scr_net_tunnel_status(t->in, end); + if (status != 200) { + char msg[128]; + scr_net_tunnel_failure(status, msg, sizeof msg); + scr_net_sock_tunnel_fail(s, msg); + return; + } + scr_net_sock_append_rbuf(s, t->in + end, t->in_len - end); + scr_net_tunnel_free(t); + s->tunnel = NULL; + scr_net_sock_touch(s); + scr_net_sock_connected(s); + return; + } +} + /* Drive the transport handshake off readiness until it settles. On * establishment: the protocol layer attaches (https' parser), a * deferred server 'connection' fires ('secureConnection' timing), the @@ -1368,7 +1514,7 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { * handshake flight delivers. Failure already tore the socket down via * scr_net_sock_transport_error. */ static void scr_net_sock_transport_pump(ScrNetSocket *s) { - if (!s->tops || s->t_est || s->fd < 0 || s->connecting) return; + if (!s->tops || s->t_est || s->fd < 0 || s->connecting || s->tunnel != NULL) return; int r = s->tops->handshake(s->tctx); if (r == 0) { scr_net_sock_update_read(s); @@ -2023,9 +2169,9 @@ static void scr_net_server_accept(ScrNetServer *srv) { /* ── the client ──────────────────────────────────────────────────────── */ -/* connect(port, host): numeric IPv4/IPv6 hosts and "localhost" (pinned to - * 127.0.0.1 — the documented divergence from Node's family autoselect). - * The connect callback registers as once('connect'). Never throws: every +/* connect(port, host): numeric IPv4/IPv6 hosts, "localhost" (pinned to + * 127.0.0.1 — the documented divergence from Node's family autoselect), + * and host names resolved through getaddrinfo. The connect callback registers as once('connect'). Never throws: every * failure is the async 'error' event, like Node. */ ScrStr *scr_net_blocking_lookup(ScrStr *host /*borrowed*/) { @@ -2056,12 +2202,112 @@ ScrStr *scr_net_blocking_lookup(ScrStr *host /*borrowed*/) { return scr_str_new(ip, strlen(ip)); } +/* Records a host connect target: IP literals dial as given, "localhost" + * is pinned to 127.0.0.1 (the documented divergence from Node's family + * autoselect), and any other name is resolved when the dial starts. */ +static void scr_net_sock_set_peer(ScrNetSocket *s, ScrStr *host /*borrowed, nullable*/) { + const char *h = host && host->len > 0 ? host->data : "localhost"; + if (strcmp(h, "localhost") == 0) h = "127.0.0.1"; + snprintf(s->peer_ip, sizeof s->peer_ip, "%s", h); + struct in_addr a4; + struct in6_addr a6; + if (inet_pton(AF_INET, h, &a4) != 1 && inet_pton(AF_INET6, h, &a6) != 1) + s->dial_host = scr_str_retain(host); +} + +/* Resolves dial_host the way Node's lookupAndConnect does: getaddrinfo + * for stream sockets of any family, with AI_ADDRCONFIG except on + * Windows. The answers are deduplicated and ordered like Node's + * autoSelectFamily list (families alternate, starting with the first + * answer's), then dialed in order through the address chain: a failed + * connect tries the next address, and the last failure is the 'error'. + * The attempt timer that starts the next address while one is still + * pending is not modeled. A failed lookup is Node's deferred + * "getaddrinfo " error. */ +static void scr_net_sock_dial_host(ScrNetSocket *s) { + ScrStr *host = s->dial_host; + s->dial_host = NULL; + struct addrinfo hints; + memset(&hints, 0, sizeof hints); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; +#ifndef _WIN32 + hints.ai_flags = AI_ADDRCONFIG; +#endif + struct addrinfo *res = NULL; + int rc = getaddrinfo(host->data, NULL, &hints, &res); + size_t n = 0; + for (struct addrinfo *ai = rc == 0 ? res : NULL; ai != NULL; ai = ai->ai_next) n++; + ScrStr **ips = n > 0 ? calloc(n, sizeof *ips) : NULL; + if (n > 0 && !ips) scr_net_oom(); + /* by family: [0] is the first answer's family, [1] the other */ + size_t count[2] = { 0, 0 }; + ScrStr **fam[2] = { NULL, NULL }; + int first = AF_UNSPEC; + for (struct addrinfo *ai = rc == 0 ? res : NULL; ai != NULL; ai = ai->ai_next) { + char ip[INET6_ADDRSTRLEN]; + const char *out = NULL; + if (ai->ai_family == AF_INET) { + out = inet_ntop(AF_INET, &((struct sockaddr_in *)ai->ai_addr)->sin_addr, ip, sizeof ip); + } else if (ai->ai_family == AF_INET6) { + out = inet_ntop(AF_INET6, &((struct sockaddr_in6 *)ai->ai_addr)->sin6_addr, ip, sizeof ip); + } + if (out == NULL) continue; + if (first == AF_UNSPEC) first = ai->ai_family; + size_t f = ai->ai_family == first ? 0 : 1; + if (fam[f] == NULL) { + fam[f] = calloc(n, sizeof *fam[f]); + if (!fam[f]) scr_net_oom(); + } + size_t len = strlen(ip); + bool seen = false; + for (size_t i = 0; i < count[f] && !seen; i++) + seen = fam[f][i]->len == len && memcmp(fam[f][i]->data, ip, len) == 0; + if (!seen) fam[f][count[f]++] = scr_str_new(ip, len); + } + if (res) freeaddrinfo(res); + size_t total = 0; + for (size_t i = 0; i < count[0] || i < count[1]; i++) { + if (i < count[0]) ips[total++] = fam[0][i]; + if (i < count[1]) ips[total++] = fam[1][i]; + } + free(fam[0]); + free(fam[1]); + if (total == 0) { + bool notfound = rc == 0 || rc == EAI_NONAME +#ifdef EAI_NODATA + || rc == EAI_NODATA +#endif + ; + const char *code = notfound ? "ENOTFOUND" : rc == EAI_AGAIN ? "EAI_AGAIN" : "EAI_FAIL"; + char msg[320]; + snprintf(msg, sizeof msg, "getaddrinfo %s %.*s", code, + (int)(host->len < 255 ? host->len : 255), host->data); + free(ips); + scr_str_release(host); + s->pending_err = scr_str_new(msg, strlen(msg)); + s->had_error = true; + s->emit_close = true; + s->connecting = false; + return; + } + scr_str_release(host); + s->dial_ips = ips; + s->dial_n = total; + s->dial_i = 0; + scr_net_sock_dial_next(s); +} + /* The dial itself (peer_ip/peer_port already set): the sockaddr arms, * socket(), and the nonblocking connect(). Shared by the immediate dial * (scr_net_connect) and the DEFERRED one (the http agent's maxSockets * queue — the socket exists, buffers writes, and dials when a slot * frees). */ static void scr_net_sock_dial_peer(ScrNetSocket *s) { + if (s->dial_host != NULL) { + scr_net_sock_dial_host(s); + return; + } const char *h = s->peer_ip; struct sockaddr_in a4; struct sockaddr_in6 a6; @@ -2081,8 +2327,8 @@ static void scr_net_sock_dial_peer(ScrNetSocket *s) { salen = sizeof a6; } if (!sa) { - /* Non-numeric host: no resolver in this slice. Node's shape for a - * failed lookup is ENOTFOUND; deliver it deferred. */ + /* not an IP literal and not resolved: Node's shape for a failed + * lookup is ENOTFOUND; deliver it deferred. */ char msg[160]; snprintf(msg, sizeof msg, "getaddrinfo ENOTFOUND %s", h); s->pending_err = scr_str_new(msg, strlen(msg)); @@ -2142,9 +2388,7 @@ ScrNetSocket *scr_net_connect(double port, ScrStr *host /*borrowed, nullable*/, ScrNetSocket *s = scr_net_sock_new(); if (cb) scr_net_ls_add(&s->conn_ls, cb, NULL, true); s->peer_port = (int)port; - const char *h = host && host->len > 0 ? host->data : "localhost"; - if (strcmp(h, "localhost") == 0) h = "127.0.0.1"; - snprintf(s->peer_ip, sizeof s->peer_ip, "%s", h); + scr_net_sock_set_peer(s, host); if (!scr_net_poller_init()) { fputs("scriptc: event poller init failed\n", stderr); abort(); @@ -2283,9 +2527,7 @@ void scr_net_connect_opts_chk(const ScrDyn *opts, const ScrStr *fence) { ScrNetSocket *scr_net_connect_deferred(double port, ScrStr *host /*borrowed, nullable*/) { ScrNetSocket *s = scr_net_sock_new(); s->peer_port = (int)port; - const char *h = host && host->len > 0 ? host->data : "localhost"; - if (strcmp(h, "localhost") == 0) h = "127.0.0.1"; - snprintf(s->peer_ip, sizeof s->peer_ip, "%s", h); + scr_net_sock_set_peer(s, host); if (!scr_net_poller_init()) { fputs("scriptc: event poller init failed\n", stderr); abort(); @@ -2755,6 +2997,1038 @@ ScrNetSocket *scr_net_sock_reset_and_destroy(ScrNetSocket *s) { return scr_net_sock_retain(s); } + +/* ── net.SocketAddress and net.BlockList ────────────────────────────────── + * Both are native checked-dynamic handles. SocketAddress follows + * lib/internal/socketaddress.js (the option checks in Node's order, then + * the address parse, which fails as ERR_INVALID_ADDRESS); BlockList + * follows lib/internal/blocklist.js over Node's C++ rules: an address + * matches an address rule when it compares the same, a range when it lies + * between the ends, and a subnet by prefix. Across families an IPv4 + * address compares as its IPv4-mapped IPv6 form (::ffff:a.b.c.d), as + * Node's SocketAddress::compare and is_in_network do. */ + +typedef struct { + int family; /* 4 or 6 */ + unsigned char b[16]; /* network order; IPv4 uses the first 4 bytes */ +} ScrNetIp; + +typedef struct ScrNetSockAddr { + size_t rc; + ScrNetIp ip; + double port; + double flowlabel; +} ScrNetSockAddr; + +static void *scr_net_sockaddr_retain(void *p) { + ScrNetSockAddr *a = p; + a->rc++; + return a; +} + +static void scr_net_sockaddr_release(void *p) { + ScrNetSockAddr *a = p; + if (a != NULL && --a->rc == 0) free(a); +} + +static void scr_net_addr_install(void); + +/* uv_ip4_addr / uv_ip6_addr: the IPv6 form may carry a %scope, which the + * address keeps out of its text, as uv_ip6_name prints it. */ +static bool scr_net_ip_parse(const ScrStr *text, int family, ScrNetIp *out) { + char buf[INET6_ADDRSTRLEN + 64]; + if (text->len >= sizeof buf || memchr(text->data, '\0', text->len) != NULL) return false; + memcpy(buf, text->data, text->len); + buf[text->len] = '\0'; + memset(out, 0, sizeof *out); + out->family = family; + if (family == 4) return inet_pton(AF_INET, buf, out->b) == 1; + char *pct = strchr(buf, '%'); + if (pct != NULL) *pct = '\0'; + return inet_pton(AF_INET6, buf, out->b) == 1; +} + +static ScrStr *scr_net_ip_text(const ScrNetIp *ip) { + char buf[INET6_ADDRSTRLEN]; + if (!inet_ntop(ip->family == 4 ? AF_INET : AF_INET6, ip->b, buf, sizeof buf)) buf[0] = '\0'; + return scr_str_new(buf, strlen(buf)); +} + +static ScrDyn *scr_net_sockaddr_box(const ScrNetIp *ip, double port, double flowlabel) { + scr_net_addr_install(); + ScrNetSockAddr *a = malloc(sizeof *a); + if (!a) scr_net_oom(); + a->rc = 1; + a->ip = *ip; + a->port = port; + a->flowlabel = flowlabel; + ScrDyn *box = scr_dyn_new_handle(a, SCR_DYNH_NET_SOCKET_ADDRESS); + scr_net_sockaddr_release(a); + return box; +} + +static const ScrNetSockAddr *scr_net_sockaddr_of(const ScrDyn *v) { + if (v == NULL || v->kind != SCR_DYN_HANDLE || v->v.handle.tag != SCR_DYNH_NET_SOCKET_ADDRESS) return NULL; + return (const ScrNetSockAddr *)v->v.handle.ptr; +} + +static ScrDyn *scr_net_dyn_str(const char *s) { + ScrStr *str = scr_str_new(s, strlen(s)); + ScrDyn *d = scr_dyn_new_str(str); + scr_str_release(str); + return d; +} + +/* { address, port, family, flowlabel }: toJSON() and the inspect view. */ +static ScrDyn *scr_net_sockaddr_detail(const ScrNetSockAddr *a) { + ScrDyn *out = scr_dyn_new_obj(); + ScrStr *text = scr_net_ip_text(&a->ip); + scr_dyn_obj_set(out, "address", 7, scr_dyn_new_str(text)); + scr_str_release(text); + scr_dyn_obj_set(out, "port", 4, scr_dyn_new_num(a->port)); + scr_dyn_obj_set(out, "family", 6, scr_net_dyn_str(a->ip.family == 4 ? "ipv4" : "ipv6")); + scr_dyn_obj_set(out, "flowlabel", 9, scr_dyn_new_num(a->flowlabel)); + return out; +} + +/* The inspect text of a SocketAddress, as ERR_INVALID_ARG_VALUE's + * "Received" renders it. */ +static int scr_net_sockaddr_inspect_text(const ScrNetSockAddr *a, char *out, size_t cap) { + char ip[INET6_ADDRSTRLEN]; + if (!inet_ntop(a->ip.family == 4 ? AF_INET : AF_INET6, a->ip.b, ip, sizeof ip)) ip[0] = '\0'; + char port[48], flow[48]; + scr_num_received(a->port, port); + scr_num_received(a->flowlabel, flow); + return snprintf(out, cap, "SocketAddress { address: '%s', port: %s, family: '%s', flowlabel: %s }", ip, + port, a->ip.family == 4 ? "ipv4" : "ipv6", flow); +} + +static bool scr_net_is_plain_object(const ScrDyn *v) { + return v->kind == SCR_DYN_OBJ; +} + +/* ERR_OUT_OF_RANGE: The value of "" is out of range. It must be . Received */ +static void scr_net_out_of_range(const char *name, const char *range, double got) { + char rec[48], msg[256]; + scr_num_received(got, rec); + int n = snprintf(msg, sizeof msg, "The value of \"%s\" is out of range. It must be %s. Received %s", name, + range, rec); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, n < 0 ? 0 : (size_t)n, "ERR_OUT_OF_RANGE"); +} + +static void scr_net_invalid_address(void) { + static const char msg[] = "Invalid socket address"; + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, sizeof msg - 1, "ERR_INVALID_ADDRESS"); +} + +/* new SocketAddress(options): validateObject, the destructuring defaults + * (the address default reads the family as given), the family switch + * after toLowerCase(), validateString(address), validatePort(port) and + * validateUint32(flowlabel), then the address parse. NULL with the + * exception pending on failure. */ +static ScrDyn *scr_net_sockaddr_from_options(const ScrDyn *options) { + if (options == NULL || options->kind == SCR_DYN_UNDEF) options = NULL; + else if (!scr_net_is_plain_object(options)) { + scr_dyn_arg_type_fail("options", "of type object", options); + return NULL; + } + const ScrDyn *family = options ? scr_dyn_obj_get(options, "family", 6) : NULL; + const ScrDyn *address = options ? scr_dyn_obj_get(options, "address", 7) : NULL; + const ScrDyn *port = options ? scr_dyn_obj_get(options, "port", 4) : NULL; + const ScrDyn *flowlabel = options ? scr_dyn_obj_get(options, "flowlabel", 9) : NULL; + if (family != NULL && family->kind == SCR_DYN_UNDEF) family = NULL; + if (address != NULL && address->kind == SCR_DYN_UNDEF) address = NULL; + if (port != NULL && port->kind == SCR_DYN_UNDEF) port = NULL; + if (flowlabel != NULL && flowlabel->kind == SCR_DYN_UNDEF) flowlabel = NULL; + bool raw_ipv4 = family == NULL || + (family->kind == SCR_DYN_STR && family->v.str->len == 4 && + memcmp(family->v.str->data, "ipv4", 4) == 0); + int fam = 0; + if (family == NULL) { + fam = 4; + } else if (family->kind == SCR_DYN_STR && family->v.str->len == 4) { + char lower[4]; + for (size_t i = 0; i < 4; i++) { + unsigned char c = (unsigned char)family->v.str->data[i]; + lower[i] = (char)(c >= 'A' && c <= 'Z' ? c + 32 : c); + } + if (memcmp(lower, "ipv4", 4) == 0) fam = 4; + else if (memcmp(lower, "ipv6", 4) == 0) fam = 6; + } + if (fam == 0) { + scr_dyn_arg_value_fail("options.family", NULL, family); + return NULL; + } + if (address != NULL && address->kind != SCR_DYN_STR) { + scr_dyn_prop_type_fail("options.address", "of type string", address); + return NULL; + } + double port_num = 0; + if (port != NULL) { + bool ok = false; + if (port->kind == SCR_DYN_NUM) { + port_num = port->v.num; + ok = true; + } else if (port->kind == SCR_DYN_STR) { + ScrStr *ps = scr_str_retain(port->v.str); + ScrStr *trimmed = scr_str_trim(ps); + ok = trimmed->len > 0; + scr_str_release(trimmed); + port_num = scr_string_to_number(ps); + scr_str_release(ps); + } + ok = ok && port_num == port_num && port_num >= 0 && port_num <= 4294967295.0 && + trunc(port_num) == port_num && port_num <= 65535; + if (!ok) { + char detail[64], msg[160]; + const char *d = scr_dyn_specific_type(port, detail, sizeof detail); + int n = snprintf(msg, sizeof msg, "options.port should be >= 0 and < 65536. Received %s.", d); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, n < 0 ? 0 : (size_t)n, "ERR_SOCKET_BAD_PORT"); + return NULL; + } + } + double flow = 0; + if (flowlabel != NULL) { + if (flowlabel->kind != SCR_DYN_NUM) { + scr_dyn_prop_type_fail("options.flowlabel", "of type number", flowlabel); + return NULL; + } + flow = flowlabel->v.num; + if (!(isfinite(flow) && trunc(flow) == flow)) { + scr_net_out_of_range("options.flowlabel", "an integer", flow); + return NULL; + } + if (flow < 0 || flow > 4294967295.0) { + scr_net_out_of_range("options.flowlabel", ">= 0 && <= 4294967295", flow); + return NULL; + } + } + ScrNetIp ip; + bool parsed; + if (address != NULL) { + parsed = scr_net_ip_parse(address->v.str, fam, &ip); + } else { + ScrStr *def = raw_ipv4 ? scr_str_new("127.0.0.1", 9) : scr_str_new("::", 2); + parsed = scr_net_ip_parse(def, fam, &ip); + scr_str_release(def); + } + if (!parsed) { + scr_net_invalid_address(); + return NULL; + } + /* `port | 0` and `flowlabel | 0` (the binding reads the flow label + * back as unsigned) */ + return scr_net_sockaddr_box(&ip, (double)(int32_t)(uint32_t)port_num, flow); +} + +/* new net.SocketAddress([options]) — args is the argument array. +1. */ +ScrDyn *scr_net_socket_address_new(const ScrDyn *args) { + const ScrDyn *options = + args->kind == SCR_DYN_ARR && args->v.arr.len > 0 && scr_dyn_arr_has_index(args, 0) ? args->v.arr.items[0] : NULL; + ScrDyn *out = scr_net_sockaddr_from_options(options); + return out != NULL ? out : scr_dyn_retain(scr_dyn_undefined()); +} + +/* SocketAddress.parse(input): URL.parse(`http://${input}`)'s hostname and + * port; a bracketed hostname is an IPv6 address. Any failure answers + * undefined. +1. */ +ScrDyn *scr_net_socket_address_parse(const ScrDyn *input) { + if (input->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("input", "of type string", input); + return NULL; + } + ScrStr *prefix = scr_str_new("http://", 7); + ScrStr *url_text = scr_str_concat(prefix, input->v.str); + scr_str_release(prefix); + ScrDyn *result = NULL; + if (scr_url_can_parse(url_text)) { + ScrUrl *url = scr_url_new(url_text); + ScrStr *host = scr_url_hostname(url); + ScrStr *port = scr_url_port(url); + double port_num = port->len > 0 ? scr_string_to_number(port) : 0; + ScrDyn *options = scr_dyn_new_obj(); + bool v6 = host->len >= 2 && host->data[0] == '[' && host->data[host->len - 1] == ']'; + ScrStr *addr = v6 ? scr_str_new(host->data + 1, host->len - 2) : scr_str_retain(host); + scr_dyn_obj_set(options, "address", 7, scr_dyn_new_str(addr)); + scr_str_release(addr); + scr_dyn_obj_set(options, "port", 4, scr_dyn_new_num((double)(int32_t)(uint32_t)port_num)); + if (v6) scr_dyn_obj_set(options, "family", 6, scr_net_dyn_str("ipv6")); + result = scr_net_sockaddr_from_options(options); + if (result == NULL) scr_exc_clear(); + scr_dyn_release(options); + scr_str_release(port); + scr_str_release(host); + scr_url_release(url); + } + scr_str_release(url_text); + return result != NULL ? result : scr_dyn_retain(scr_dyn_undefined()); +} + +bool scr_net_is_socket_address(const ScrDyn *v) { return scr_net_sockaddr_of(v) != NULL; } + +static ScrDyn *scr_net_sockaddr_get(void *h, const char *key, size_t len) { + const ScrNetSockAddr *a = h; + if (len == 7 && memcmp(key, "address", 7) == 0) { + ScrStr *text = scr_net_ip_text(&a->ip); + ScrDyn *d = scr_dyn_new_str(text); + scr_str_release(text); + return d; + } + if (len == 4 && memcmp(key, "port", 4) == 0) return scr_dyn_new_num(a->port); + if (len == 6 && memcmp(key, "family", 6) == 0) return scr_net_dyn_str(a->ip.family == 4 ? "ipv4" : "ipv6"); + if (len == 9 && memcmp(key, "flowlabel", 9) == 0) return scr_dyn_new_num(a->flowlabel); + return NULL; +} + +/* The getters have no setters (strict-mode TypeError). */ +static bool scr_net_getter_only_set(const char *cls, const char *const *names, const char *key, size_t len) { + for (size_t i = 0; names[i] != NULL; i++) { + if (strlen(names[i]) == len && memcmp(names[i], key, len) == 0) { + char msg[160]; + int n = snprintf(msg, sizeof msg, "Cannot set property %.*s of #<%s> which has only a getter", (int)len, + key, cls); + scr_throw_error_msg(SCR_ERR_TYPE, msg, n < 0 ? 0 : (size_t)n); + return true; + } + } + return false; +} + +static bool scr_net_sockaddr_set(void *h, const char *key, size_t len, const ScrDyn *value) { + (void)h; + (void)value; + static const char *const names[] = { "address", "port", "family", "flowlabel", NULL }; + return scr_net_getter_only_set("SocketAddress", names, key, len); +} + +static void scr_net_not_a_function(const char *what) { + char msg[160]; + int n = snprintf(msg, sizeof msg, "%s is not a function", what); + scr_throw_error_msg(SCR_ERR_TYPE, msg, n < 0 ? 0 : (size_t)n); +} + +static ScrDyn *scr_net_sockaddr_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + (void)self; + (void)args; + (void)argc; + if (strcmp(method, "toJSON") == 0) return scr_net_sockaddr_detail((const ScrNetSockAddr *)h); + scr_net_not_a_function(what); + return NULL; +} + +static ScrDyn *scr_net_sockaddr_inspect_view(void *h) { + return scr_net_sockaddr_detail((const ScrNetSockAddr *)h); +} + +static const unsigned char scr_net_v4_mapped[12] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff }; + +/* SocketAddress::compare: -1, 0 or 1, or 2 when the two cannot compare + * (an IPv6 address that is not IPv4-mapped against an IPv4 one). */ +static int scr_net_ip_compare(const ScrNetIp *a, const ScrNetIp *b) { + if (a->family == b->family) { + int c = memcmp(a->b, b->b, a->family == 4 ? 4 : 16); + return c < 0 ? -1 : c > 0 ? 1 : 0; + } + const ScrNetIp *v4 = a->family == 4 ? a : b; + const ScrNetIp *v6 = a->family == 4 ? b : a; + if (memcmp(v6->b, scr_net_v4_mapped, 12) != 0) return 2; + int c = memcmp(v4->b, v6->b + 12, 4); + c = c < 0 ? -1 : c > 0 ? 1 : 0; + return a->family == 4 ? c : -c; +} + +static bool scr_net_prefix_match(const unsigned char *a, const unsigned char *net, int prefix) { + int full = prefix / 8, rest = prefix % 8; + if (memcmp(a, net, (size_t)full) != 0) return false; + if (rest == 0) return true; + unsigned char mask = (unsigned char)(0xff << (8 - rest)); + return (a[full] & mask) == (net[full] & mask); +} + +/* SocketAddress::is_in_network. */ +static bool scr_net_ip_in_subnet(const ScrNetIp *a, const ScrNetIp *net, int prefix) { + if (a->family == net->family) return scr_net_prefix_match(a->b, net->b, prefix); + if (prefix == 0) return true; + if (a->family == 4) { + if (prefix > 128) return false; + unsigned char mapped[16]; + memcpy(mapped, scr_net_v4_mapped, 12); + memcpy(mapped + 12, a->b, 4); + return scr_net_prefix_match(mapped, net->b, prefix); + } + if (prefix > 32 || memcmp(a->b, scr_net_v4_mapped, 12) != 0) return false; + return scr_net_prefix_match(a->b + 12, net->b, prefix); +} + +enum { SCR_NET_RULE_ADDRESS, SCR_NET_RULE_SUBNET, SCR_NET_RULE_RANGE }; + +typedef struct { + int kind; + ScrNetIp a; /* the address, the subnet's network or the range start */ + ScrNetIp b; /* the range end */ + int prefix; + size_t seq; +} ScrNetBlockRule; + +typedef struct ScrNetBlockList { + size_t rc; + ScrNetBlockRule *rules; + size_t n, cap, seq; +} ScrNetBlockList; + +static void *scr_net_blocklist_retain(void *p) { + ScrNetBlockList *l = p; + l->rc++; + return l; +} + +static void scr_net_blocklist_release(void *p) { + ScrNetBlockList *l = p; + if (l == NULL || --l->rc != 0) return; + free(l->rules); + free(l); +} + +ScrDyn *scr_net_block_list_new(const ScrDyn *args) { + (void)args; + scr_net_addr_install(); + ScrNetBlockList *l = calloc(1, sizeof *l); + if (!l) scr_net_oom(); + l->rc = 1; + ScrDyn *box = scr_dyn_new_handle(l, SCR_DYNH_NET_BLOCK_LIST); + scr_net_blocklist_release(l); + return box; +} + +bool scr_net_is_block_list(const ScrDyn *v) { + return v != NULL && v->kind == SCR_DYN_HANDLE && v->v.handle.tag == SCR_DYNH_NET_BLOCK_LIST; +} + +static void scr_net_blocklist_push(ScrNetBlockList *l, const ScrNetBlockRule *rule) { + if (l->n == l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->rules = realloc(l->rules, l->cap * sizeof *l->rules); + if (!l->rules) scr_net_oom(); + } + l->rules[l->n] = *rule; + l->rules[l->n].seq = l->seq++; + l->n++; +} + +/* Node lists IPv4 address rules, then IPv6 ones, then subnets, then + * ranges, each group newest first. */ +static int scr_net_rule_group(const ScrNetBlockRule *r) { + if (r->kind == SCR_NET_RULE_ADDRESS) return r->a.family == 4 ? 0 : 1; + return r->kind == SCR_NET_RULE_SUBNET ? 2 : 3; +} + +static int scr_net_rule_order(const void *x, const void *y) { + const ScrNetBlockRule *a = *(const ScrNetBlockRule *const *)x; + const ScrNetBlockRule *b = *(const ScrNetBlockRule *const *)y; + int ga = scr_net_rule_group(a), gb = scr_net_rule_group(b); + if (ga != gb) return ga < gb ? -1 : 1; + return a->seq > b->seq ? -1 : a->seq < b->seq ? 1 : 0; +} + +static ScrStr *scr_net_rule_text(const ScrNetBlockRule *r) { + char a[INET6_ADDRSTRLEN], b[INET6_ADDRSTRLEN], out[160]; + if (!inet_ntop(r->a.family == 4 ? AF_INET : AF_INET6, r->a.b, a, sizeof a)) a[0] = '\0'; + const char *fam = r->a.family == 4 ? "IPv4" : "IPv6"; + int n; + if (r->kind == SCR_NET_RULE_ADDRESS) { + n = snprintf(out, sizeof out, "Address: %s %s", fam, a); + } else if (r->kind == SCR_NET_RULE_SUBNET) { + n = snprintf(out, sizeof out, "Subnet: %s %s/%d", fam, a, r->prefix); + } else { + if (!inet_ntop(r->b.family == 4 ? AF_INET : AF_INET6, r->b.b, b, sizeof b)) b[0] = '\0'; + n = snprintf(out, sizeof out, "Range: %s %s-%s", fam, a, b); + } + return scr_str_new(out, n < 0 ? 0 : (size_t)n); +} + +static ScrDyn *scr_net_blocklist_rules(const ScrNetBlockList *l) { + ScrDyn *out = scr_dyn_new_arr(); + if (l->n == 0) return out; + const ScrNetBlockRule **order = malloc(l->n * sizeof *order); + if (!order) scr_net_oom(); + for (size_t i = 0; i < l->n; i++) order[i] = &l->rules[i]; + qsort(order, l->n, sizeof *order, scr_net_rule_order); + for (size_t i = 0; i < l->n; i++) { + ScrStr *text = scr_net_rule_text(order[i]); + scr_dyn_arr_push(out, scr_dyn_new_str(text)); + scr_str_release(text); + } + free(order); + return out; +} + +static bool scr_net_blocklist_check_ip(const ScrNetBlockList *l, const ScrNetIp *ip) { + for (size_t i = 0; i < l->n; i++) { + const ScrNetBlockRule *r = &l->rules[i]; + if (r->kind == SCR_NET_RULE_ADDRESS && scr_net_ip_compare(ip, &r->a) == 0) return true; + if (r->kind == SCR_NET_RULE_SUBNET && scr_net_ip_in_subnet(ip, &r->a, r->prefix)) return true; + if (r->kind == SCR_NET_RULE_RANGE) { + int lo = scr_net_ip_compare(ip, &r->a), hi = scr_net_ip_compare(ip, &r->b); + if ((lo == 0 || lo == 1) && (hi == 0 || hi == -1)) return true; + } + } + return false; +} + +static void scr_net_blocklist_remove(ScrNetBlockList *l, const ScrNetBlockRule *rule); +static bool scr_net_blocklist_has_address(const ScrNetBlockList *l, const ScrNetIp *ip); +static bool scr_net_subnet_prefix(const ScrDyn *prefix, int family, int *out); +static bool scr_net_parse_cidr(const ScrDyn *cidr, ScrDyn **address, double *prefix, const char **family); + +/* The `family` argument: validateString, then the SocketAddress family + * switch. */ +static const ScrDyn *scr_net_family_arg(ScrDyn *const *args, size_t argc, size_t i) { + if (i < argc && args[i]->kind != SCR_DYN_UNDEF) return args[i]; + return NULL; +} + +/* A rule's address argument: a SocketAddress as is, or validateString + * and `new SocketAddress({ address, family })`. false with the exception + * pending. */ +static bool scr_net_rule_address(ScrDyn *const *args, size_t argc, size_t i, const char *name, + const ScrDyn *family, ScrNetIp *out, const ScrNetSockAddr **sa) { + const ScrDyn *v = i < argc ? args[i] : scr_dyn_undefined(); + const ScrNetSockAddr *given = scr_net_sockaddr_of(v); + if (given != NULL) { + *out = given->ip; + if (sa != NULL) *sa = given; + return true; + } + if (v->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail(name, "of type string", v); + return false; + } + if (family != NULL && family->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("family", "of type string", family); + return false; + } + ScrDyn *options = scr_dyn_new_obj(); + scr_dyn_obj_set(options, "address", 7, scr_dyn_retain((ScrDyn *)v)); + if (family != NULL) scr_dyn_obj_set(options, "family", 6, scr_dyn_retain((ScrDyn *)family)); + ScrDyn *made = scr_net_sockaddr_from_options(options); + scr_dyn_release(options); + if (made == NULL) return false; + *out = scr_net_sockaddr_of(made)->ip; + scr_dyn_release(made); + if (sa != NULL) *sa = NULL; + return true; +} + +static void scr_net_blocklist_add_address(ScrNetBlockList *l, ScrDyn *const *args, size_t argc, + bool remove) { + ScrNetBlockRule rule = { .kind = SCR_NET_RULE_ADDRESS }; + if (!scr_net_rule_address(args, argc, 0, "address", scr_net_family_arg(args, argc, 1), &rule.a, NULL)) + return; + if (remove) { + scr_net_blocklist_remove(l, &rule); + return; + } + if (scr_net_blocklist_has_address(l, &rule.a)) return; /* already listed: the rule keeps its place */ + scr_net_blocklist_push(l, &rule); +} + +static void scr_net_blocklist_add_range(ScrNetBlockList *l, ScrDyn *const *args, size_t argc, + bool remove) { + ScrNetBlockRule rule = { .kind = SCR_NET_RULE_RANGE }; + const ScrDyn *family = scr_net_family_arg(args, argc, 2); + const ScrNetSockAddr *start_sa = NULL; + if (!scr_net_rule_address(args, argc, 0, "start", family, &rule.a, &start_sa)) return; + if (!scr_net_rule_address(args, argc, 1, "end", family, &rule.b, NULL)) return; + if (remove) { + scr_net_blocklist_remove(l, &rule); + return; + } + if (scr_net_ip_compare(&rule.a, &rule.b) == 1) { + /* ERR_INVALID_ARG_VALUE('start', start, 'must come before end'), + * with `start` the SocketAddress the call built or was given */ + ScrNetSockAddr shown = { .rc = 1, .ip = rule.a, .port = 0, .flowlabel = 0 }; + if (start_sa != NULL) shown = *start_sa; + char insp[192], msg[320]; + scr_net_sockaddr_inspect_text(&shown, insp, sizeof insp); + int n = snprintf(msg, sizeof msg, "The argument 'start' must come before end. Received %s", insp); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, n < 0 ? 0 : (size_t)n, "ERR_INVALID_ARG_VALUE"); + return; + } + scr_net_blocklist_push(l, &rule); +} + +static void scr_net_blocklist_subnet(ScrNetBlockList *l, ScrDyn *const *args, size_t argc, bool remove) { + ScrNetBlockRule rule = { .kind = SCR_NET_RULE_SUBNET }; + if (!scr_net_rule_address(args, argc, 0, "network", scr_net_family_arg(args, argc, 2), &rule.a, NULL)) + return; + if (!scr_net_subnet_prefix(1 < argc ? args[1] : scr_dyn_undefined(), rule.a.family, &rule.prefix)) return; + if (remove) scr_net_blocklist_remove(l, &rule); + else scr_net_blocklist_push(l, &rule); +} + +/* addCIDR / removeCIDR: parseCIDR, then addSubnet(address, prefix, family). */ +static void scr_net_blocklist_cidr(ScrNetBlockList *l, const ScrDyn *cidr, bool remove) { + ScrDyn *address = NULL; + double prefix = 0; + const char *family = NULL; + if (!scr_net_parse_cidr(cidr, &address, &prefix, &family)) return; + ScrDyn *sub[3] = { address, scr_dyn_new_num(prefix), scr_net_dyn_str(family) }; + scr_net_blocklist_subnet(l, sub, 3, remove); + for (size_t i = 0; i < 3; i++) scr_dyn_release(sub[i]); +} + +/* addAddresses(addresses[, family]) and addCIDRs(cidrs): every entry is + * validated and parsed before any rule is added. */ +static void scr_net_blocklist_add_addresses(ScrNetBlockList *l, ScrDyn *const *args, size_t argc) { + const ScrDyn *list = argc > 0 ? args[0] : scr_dyn_undefined(); + if (list->kind != SCR_DYN_ARR) { + scr_dyn_arg_type_fail("addresses", "an instance of Array", list); + return; + } + const ScrDyn *family = scr_net_family_arg(args, argc, 1); + if (family != NULL && family->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("family", "of type string", family); + return; + } + size_t n = list->v.arr.len; + ScrNetIp *ips = malloc((n ? n : 1) * sizeof *ips); + if (!ips) scr_net_oom(); + for (size_t i = 0; i < n; i++) { + ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : (ScrDyn *)scr_dyn_undefined(); + if (scr_net_sockaddr_of(item) == NULL && item->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "addresses[%zu]", i); + scr_dyn_arg_type_fail(name, "of type string", item); + free(ips); + return; + } + ScrDyn *one[2] = { item, (ScrDyn *)(family != NULL ? family : scr_dyn_undefined()) }; + if (!scr_net_rule_address(one, 2, 0, "address", family, &ips[i], NULL)) { + free(ips); + return; + } + } + for (size_t i = 0; i < n; i++) { + if (scr_net_blocklist_has_address(l, &ips[i])) continue; + ScrNetBlockRule rule = { .kind = SCR_NET_RULE_ADDRESS, .a = ips[i] }; + scr_net_blocklist_push(l, &rule); + } + free(ips); +} + +static void scr_net_blocklist_add_cidrs(ScrNetBlockList *l, ScrDyn *const *args, size_t argc) { + const ScrDyn *list = argc > 0 ? args[0] : scr_dyn_undefined(); + if (list->kind != SCR_DYN_ARR) { + scr_dyn_arg_type_fail("cidrs", "an instance of Array", list); + return; + } + size_t n = list->v.arr.len; + for (size_t i = 0; i < n; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(list, i) ? list->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) { + char name[48]; + snprintf(name, sizeof name, "cidrs[%zu]", i); + scr_dyn_arg_type_fail(name, "of type string", item); + return; + } + ScrDyn *address = NULL; + double prefix = 0; + const char *family = NULL; + if (!scr_net_parse_cidr(item, &address, &prefix, &family)) return; + scr_dyn_release(address); + } + for (size_t i = 0; i < n && !scr_exc_pending(); i++) scr_net_blocklist_cidr(l, list->v.arr.items[i], false); +} + +/* check(address[, family]): a SocketAddress applies as is; a string is + * parsed with inet_pton in the family's form (any family other than + * "ipv4", in any case, means IPv6), and one that does not parse answers + * false, as Node's checkString does. */ +static ScrDyn *scr_net_blocklist_check(const ScrNetBlockList *l, ScrDyn *const *args, size_t argc) { + const ScrDyn *v = argc > 0 ? args[0] : scr_dyn_undefined(); + const ScrNetSockAddr *given = scr_net_sockaddr_of(v); + if (given != NULL) return scr_dyn_new_bool(scr_net_blocklist_check_ip(l, &given->ip)); + const ScrDyn *family = scr_net_family_arg(args, argc, 1); + if (v->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("address", "of type string", v); + return NULL; + } + if (family != NULL && family->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("family", "of type string", family); + return NULL; + } + bool v4 = family == NULL; + if (family != NULL && family->v.str->len == 4) { + char lower[4]; + for (size_t i = 0; i < 4; i++) { + unsigned char c = (unsigned char)family->v.str->data[i]; + lower[i] = (char)(c >= 'A' && c <= 'Z' ? c + 32 : c); + } + v4 = memcmp(lower, "ipv4", 4) == 0; + } + char buf[INET6_ADDRSTRLEN + 8]; + const ScrStr *text = v->v.str; + if (text->len >= sizeof buf || memchr(text->data, '\0', text->len) != NULL) return scr_dyn_new_bool(false); + memcpy(buf, text->data, text->len); + buf[text->len] = '\0'; + ScrNetIp ip; + memset(&ip, 0, sizeof ip); + ip.family = v4 ? 4 : 6; + if (inet_pton(v4 ? AF_INET : AF_INET6, buf, ip.b) != 1) return scr_dyn_new_bool(false); + return scr_dyn_new_bool(scr_net_blocklist_check_ip(l, &ip)); +} + +/* The remove* calls drop every rule equal to the one given. */ +static void scr_net_blocklist_remove(ScrNetBlockList *l, const ScrNetBlockRule *rule) { + size_t w = 0; + for (size_t i = 0; i < l->n; i++) { + const ScrNetBlockRule *r = &l->rules[i]; + bool same = r->kind == rule->kind && r->a.family == rule->a.family && + memcmp(r->a.b, rule->a.b, 16) == 0 && + (rule->kind != SCR_NET_RULE_SUBNET || r->prefix == rule->prefix) && + (rule->kind != SCR_NET_RULE_RANGE || + (r->b.family == rule->b.family && memcmp(r->b.b, rule->b.b, 16) == 0)); + if (!same) l->rules[w++] = *r; + } + l->n = w; +} + +static bool scr_net_blocklist_has_address(const ScrNetBlockList *l, const ScrNetIp *ip) { + for (size_t i = 0; i < l->n; i++) { + const ScrNetBlockRule *r = &l->rules[i]; + if (r->kind == SCR_NET_RULE_ADDRESS && r->a.family == ip->family && memcmp(r->a.b, ip->b, 16) == 0) + return true; + } + return false; +} + +/* validateInt32(prefix, 'prefix', 0, max) for the network's family. */ +static bool scr_net_subnet_prefix(const ScrDyn *prefix, int family, int *out) { + if (prefix->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("prefix", "of type number", prefix); + return false; + } + double p = prefix->v.num; + if (!(isfinite(p) && trunc(p) == p)) { + scr_net_out_of_range("prefix", "an integer", p); + return false; + } + if (p < 0 || p > (family == 4 ? 32 : 128)) { + scr_net_out_of_range("prefix", family == 4 ? ">= 0 && <= 32" : ">= 0 && <= 128", p); + return false; + } + *out = (int)p; + return true; +} + +/* parseCIDR(cidr): the text before the last '/', and a prefix that reads + * back as the same decimal integer; a ':' makes it IPv6. */ +static bool scr_net_parse_cidr(const ScrDyn *cidr, ScrDyn **address, double *prefix, const char **family) { + if (cidr->kind != SCR_DYN_STR) { + scr_dyn_arg_type_fail("cidr", "of type string", cidr); + return false; + } + const ScrStr *text = cidr->v.str; + size_t slash = text->len; + for (size_t i = text->len; i > 0; i--) { + if (text->data[i - 1] == '/') { + slash = i - 1; + break; + } + } + if (slash == text->len) { + scr_dyn_arg_value_fail("cidr", "must contain a prefix length (e.g. \"10.0.0.0/8\")", cidr); + return false; + } + const char *digits = text->data + slash + 1; + size_t ndigits = text->len - slash - 1; + /* NumberParseInt(prefixStr, 10) and `${prefix}` === prefixStr: an + * optional '-' and decimal digits with no leading zero (except "0") */ + bool neg = ndigits > 0 && digits[0] == '-'; + size_t start = neg ? 1 : 0; + bool ok = ndigits > start && ndigits - start <= 9; + for (size_t i = start; ok && i < ndigits; i++) ok = digits[i] >= '0' && digits[i] <= '9'; + if (ok && ndigits - start > 1 && digits[start] == '0') ok = false; + if (ok && neg && ndigits - start == 1 && digits[start] == '0') ok = false; /* "-0" reads back as "0" */ + if (!ok) { + scr_dyn_arg_value_fail("cidr", "prefix length must be a valid integer", cidr); + return false; + } + double value = 0; + for (size_t i = start; i < ndigits; i++) value = value * 10 + (digits[i] - '0'); + *prefix = neg ? -value : value; + ScrStr *addr = scr_str_new(text->data, slash); + *family = memchr(addr->data, ':', addr->len) != NULL ? "ipv6" : "ipv4"; + *address = scr_dyn_new_str(addr); + scr_str_release(addr); + return true; +} + +/* fromJSON(data): a JSON string or an array of rule strings, each added + * with addSubnet, addAddress or addRange; other strings are ignored. */ +static void scr_net_blocklist_from_json(ScrNetBlockList *l, ScrDyn *const *args, size_t argc); + +static ScrDyn *scr_net_blocklist_invoke(void *h, ScrDyn *self, const char *method, ScrDyn *const *args, + size_t argc, const char *what) { + (void)self; + ScrNetBlockList *l = h; + const ScrDyn *first = argc > 0 ? args[0] : scr_dyn_undefined(); + if (strcmp(method, "addAddress") == 0) scr_net_blocklist_add_address(l, args, argc, false); + else if (strcmp(method, "addAddresses") == 0) scr_net_blocklist_add_addresses(l, args, argc); + else if (strcmp(method, "addRange") == 0) scr_net_blocklist_add_range(l, args, argc, false); + else if (strcmp(method, "addSubnet") == 0) scr_net_blocklist_subnet(l, args, argc, false); + else if (strcmp(method, "addCIDR") == 0) scr_net_blocklist_cidr(l, first, false); + else if (strcmp(method, "addCIDRs") == 0) scr_net_blocklist_add_cidrs(l, args, argc); + else if (strcmp(method, "removeAddress") == 0) scr_net_blocklist_add_address(l, args, argc, true); + else if (strcmp(method, "removeRange") == 0) scr_net_blocklist_add_range(l, args, argc, true); + else if (strcmp(method, "removeSubnet") == 0) scr_net_blocklist_subnet(l, args, argc, true); + else if (strcmp(method, "removeCIDR") == 0) scr_net_blocklist_cidr(l, first, true); + else if (strcmp(method, "clear") == 0) l->n = 0; + else if (strcmp(method, "check") == 0) return scr_net_blocklist_check(l, args, argc); + else if (strcmp(method, "toJSON") == 0) return scr_net_blocklist_rules(l); + else if (strcmp(method, "fromJSON") == 0) scr_net_blocklist_from_json(l, args, argc); + else { + scr_net_not_a_function(what); + return NULL; + } + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static ScrDyn *scr_net_blocklist_get(void *h, const char *key, size_t len) { + if (len == 5 && memcmp(key, "rules", 5) == 0) return scr_net_blocklist_rules((const ScrNetBlockList *)h); + if (len == 4 && memcmp(key, "size", 4) == 0) return scr_dyn_new_num((double)((const ScrNetBlockList *)h)->n); + return NULL; +} + +static bool scr_net_blocklist_set(void *h, const char *key, size_t len, const ScrDyn *value) { + (void)h; + (void)value; + static const char *const names[] = { "rules", "size", NULL }; + return scr_net_getter_only_set("BlockList", names, key, len); +} + +static ScrDyn *scr_net_blocklist_inspect_view(void *h) { + ScrDyn *out = scr_dyn_new_obj(); + scr_dyn_obj_set(out, "rules", 5, scr_net_blocklist_rules((const ScrNetBlockList *)h)); + return out; +} + +static const ScrDynHandleOps scr_net_sockaddr_ops = { + .cls = "SocketAddress", + .retain = scr_net_sockaddr_retain, + .release = scr_net_sockaddr_release, + .invoke = scr_net_sockaddr_invoke, + .get = scr_net_sockaddr_get, + .set = scr_net_sockaddr_set, + .inspect_view = scr_net_sockaddr_inspect_view, +}; + +static const ScrDynHandleOps scr_net_blocklist_ops = { + .cls = "BlockList", + .retain = scr_net_blocklist_retain, + .release = scr_net_blocklist_release, + .invoke = scr_net_blocklist_invoke, + .get = scr_net_blocklist_get, + .set = scr_net_blocklist_set, + .inspect_view = scr_net_blocklist_inspect_view, +}; + +static void scr_net_addr_install(void) { + static SCR_TL bool installed; + if (installed) return; + installed = true; + scr_dyn_handle_install(SCR_DYNH_NET_SOCKET_ADDRESS, &scr_net_sockaddr_ops); + scr_dyn_handle_install(SCR_DYNH_NET_BLOCK_LIST, &scr_net_blocklist_ops); +} + +/* The JSON forms of the handles (scr_json.c calls toJSON through invoke). */ + +/* fromJSON's patterns (lib/internal/blocklist.js #parseIPInfo), matched + * at the leftmost position as the regular expressions are: a dotted quad + * of 1-3 digit groups for IPv4, and 1-45 of [0-9a-fA-F:.] for IPv6, whose + * patterns also ignore case. */ +static size_t scr_net_match_quad(const char *s, size_t n) { + size_t i = 0; + for (int part = 0; part < 4; part++) { + if (part > 0) { + if (i >= n || s[i] != '.') return 0; + i++; + } + size_t d = 0; + while (i < n && d < 3 && s[i] >= '0' && s[i] <= '9') i++, d++; + if (d == 0) return 0; + } + return i; +} + +static size_t scr_net_match_v6(const char *s, size_t n) { + size_t i = 0; + while (i < n && i < 45 && + ((s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f') || (s[i] >= 'A' && s[i] <= 'F') || + s[i] == ':' || s[i] == '.')) + i++; + return i; +} + +static size_t scr_net_match_digits(const char *s, size_t n, size_t max) { + size_t i = 0; + while (i < n && i < max && s[i] >= '0' && s[i] <= '9') i++; + return i; +} + +static bool scr_net_lit_at(const char *s, size_t n, const char *lit, bool fold) { + size_t m = strlen(lit); + if (m > n) return false; + for (size_t i = 0; i < m; i++) { + unsigned char a = (unsigned char)s[i], b = (unsigned char)lit[i]; + if (fold) { + if (a >= 'A' && a <= 'Z') a += 32; + if (b >= 'A' && b <= 'Z') b += 32; + } + if (a != b) return false; + } + return true; +} + +/* One pattern: ``, `/` or `-`. + * Fills the captured spans; false when no position matches. */ +typedef struct { + const char *a; + size_t alen; + const char *b; + size_t blen; +} ScrNetCapture; + +static bool scr_net_match_rule(const ScrStr *item, const char *lit, bool v6, char tail, size_t tail_max, + ScrNetCapture *cap) { + const char *s = item->data; + size_t n = item->len, m = strlen(lit); + for (size_t at = 0; at + m <= n; at++) { + if (!scr_net_lit_at(s + at, n - at, lit, v6)) continue; + size_t i = at + m; + size_t a = v6 ? scr_net_match_v6(s + i, n - i) : scr_net_match_quad(s + i, n - i); + if (a == 0) continue; + cap->a = s + i; + cap->alen = a; + if (tail == 0) return true; + i += a; + if (i >= n || s[i] != tail) continue; + i++; + size_t b = tail == '/' ? scr_net_match_digits(s + i, n - i, tail_max) + : v6 ? scr_net_match_v6(s + i, n - i) + : scr_net_match_quad(s + i, n - i); + if (b == 0) continue; + cap->b = s + i; + cap->blen = b; + return true; + } + return false; +} + +static bool scr_net_str_has(const ScrStr *s, const char *needle) { + size_t m = strlen(needle); + for (size_t i = 0; i + m <= s->len; i++) + if (memcmp(s->data + i, needle, m) == 0) return true; + return false; +} + +static ScrDyn *scr_net_span_dyn(const char *p, size_t n) { + ScrStr *s = scr_str_new(p, n); + ScrDyn *d = scr_dyn_new_str(s); + scr_str_release(s); + return d; +} + +/* Runs one parsed rule through the add* entry point, as #parseIPInfo + * calls this.addSubnet / addAddress / addRange. */ +static void scr_net_from_json_item(ScrNetBlockList *l, const ScrStr *item) { + ScrNetCapture c; + for (int pass = 0; pass < 2; pass++) { + bool v6 = pass == 1; + if (!scr_net_str_has(item, v6 ? "IPv6" : "IPv4")) continue; + ScrDyn *family = scr_net_dyn_str(v6 ? "ipv6" : "ipv4"); + ScrDyn *args[3] = { NULL, NULL, family }; + bool done = true; + if (scr_net_match_rule(item, v6 ? "Subnet: IPv6 " : "Subnet: IPv4 ", v6, '/', v6 ? 3 : 2, &c)) { + args[0] = scr_net_span_dyn(c.a, c.alen); + double prefix = 0; + for (size_t i = 0; i < c.blen; i++) prefix = prefix * 10 + (c.b[i] - '0'); + args[1] = scr_dyn_new_num(prefix); + scr_net_blocklist_subnet(l, args, 3, false); + } else if (scr_net_match_rule(item, v6 ? "Address: IPv6 " : "Address: IPv4 ", v6, 0, 0, &c)) { + args[0] = scr_net_span_dyn(c.a, c.alen); + args[1] = family; + scr_net_blocklist_add_address(l, args, 2, false); + args[1] = NULL; + } else if (scr_net_match_rule(item, v6 ? "Range: IPv6 " : "Range: IPv4 ", v6, '-', 0, &c)) { + args[0] = scr_net_span_dyn(c.a, c.alen); + args[1] = scr_net_span_dyn(c.b, c.blen); + scr_net_blocklist_add_range(l, args, 3, false); + } else { + done = false; + } + scr_dyn_release(args[0]); + scr_dyn_release(args[1]); + scr_dyn_release(family); + if (done) return; + } +} + +static bool scr_net_all_strings(const ScrDyn *v) { + for (size_t i = 0; i < v->v.arr.len; i++) { + const ScrDyn *item = scr_dyn_arr_has_index(v, i) ? v->v.arr.items[i] : scr_dyn_undefined(); + if (item->kind != SCR_DYN_STR) return false; + } + return true; +} + +static void scr_net_blocklist_from_json(ScrNetBlockList *l, ScrDyn *const *args, size_t argc) { + const ScrDyn *data = argc > 0 ? args[0] : scr_dyn_undefined(); + ScrDyn *parsed = NULL; + if (data->kind == SCR_DYN_STR) { + parsed = scr_json_parse(data->v.str); + if (parsed == NULL || scr_exc_pending()) { + scr_dyn_release(parsed); + return; + } + data = parsed; + } + if (data->kind != SCR_DYN_ARR || !scr_net_all_strings(data)) { + scr_dyn_arg_type_fail("data", "of type string or string[]", data); + scr_dyn_release(parsed); + return; + } + for (size_t i = 0; i < data->v.arr.len && !scr_exc_pending(); i++) + scr_net_from_json_item(l, data->v.arr.items[i]->v.str); + scr_dyn_release(parsed); +} + +/* BlockList.PRIVATE_RANGES: one frozen array of Node's CIDR strings, + * released at exit. +1. */ +static SCR_TL ScrDyn *scr_net_private_ranges; + +static void scr_net_private_ranges_cleanup(void) { + ScrDyn *ranges = scr_net_private_ranges; + scr_net_private_ranges = NULL; + scr_dyn_release(ranges); +} + +ScrDyn *scr_net_block_list_private_ranges(void) { + ScrDyn *ranges = scr_net_private_ranges; + if (ranges == NULL) { + static const char *const cidrs[] = { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", + "::1/128", "169.254.0.0/16", "fe80::/10", "fc00::/7" }; + ScrDyn *arr = scr_dyn_new_arr(); + for (size_t i = 0; i < sizeof cidrs / sizeof cidrs[0]; i++) scr_dyn_arr_push(arr, scr_net_dyn_str(cidrs[i])); + ranges = scr_dyn_freeze(arr); /* +1 on the same array */ + scr_dyn_release(arr); + scr_net_private_ranges = ranges; + scr_atexit(scr_net_private_ranges_cleanup); + } + return scr_dyn_retain(ranges); +} + /* The address fields as dyn values (+1): remotePort (0), remoteFamily * (1), localAddress (2), localPort (3) and localFamily (4), each * undefined when unknown. */ @@ -2776,9 +4050,9 @@ ScrDyn *scr_net_sock_addr_field(ScrNetSocket *s, double field) { double scr_net_sock_bytes_read(ScrNetSocket *s) { return (double)s->bytes_read; } /* socket.connecting: a dial in progress (deferred, looking up, or - * awaiting the TCP handshake). */ + * awaiting the TCP handshake or the proxy tunnel). */ bool scr_net_sock_connecting(ScrNetSocket *s) { - return s->fd >= 0 ? s->connecting : (s->dial_deferred || s->lookup_wait); + return s->fd >= 0 ? s->connecting || s->tunnel != NULL : (s->dial_deferred || s->lookup_wait); } /* socket.pending: no open handle yet (or anymore), or still connecting. */ @@ -2983,6 +4257,7 @@ ScrBytes *scr_net_sock_read_bytes(ScrNetSocket *s, double n) { size_t want = n > 0 ? (size_t)n : avail; if (avail == 0 || want == 0 || want > avail) return NULL; ScrBytes *out = scr_bytes_new(SCR_BYTES_U8, (double)want); + out->is_buffer = true; /* socket.read() answers a Buffer */ size_t took = scr_net_sock_take_buffered(s, (char *)out->data, want); (void)took; return out; @@ -3122,7 +4397,7 @@ void scr_net_sock_on_connect(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool onc scr_closure_release(cb); return; } - if (!s->connecting) { + if (!s->connecting && s->tunnel == NULL) { /* already connected: Node fires a late once('connect')… never — the * event is past. Release, like listeners after 'exit'. */ scr_closure_release(cb); @@ -3234,7 +4509,7 @@ bool scr_net_sock_writable(ScrNetSocket *s) { } bool scr_net_sock_established(ScrNetSocket *s) { - return s->fd >= 0 && !s->connecting && (!s->tops || s->t_est); + return s->fd >= 0 && !s->connecting && s->tunnel == NULL && (!s->tops || s->t_est); } /* The protocol layer's timeout/error hooks (scr_http.c's client and the @@ -3262,6 +4537,17 @@ void scr_net_sock_write_native(ScrNetSocket *s, const char *buf, size_t n) { /* ── the transport hooks (scr_tls.c) ─────────────────────────────────── */ +void scr_net_sock_set_tunnel(ScrNetSocket *s, const char *request, size_t len) { + if (s->fd < 0 || !s->connecting || s->tunnel != NULL) return; + ScrNetTunnel *t = calloc(1, sizeof *t); + if (!t) scr_net_oom(); + t->out = malloc(len); + if (!t->out) scr_net_oom(); + memcpy(t->out, request, len); + t->out_len = len; + s->tunnel = t; +} + void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx) { s->tops = ops; s->tctx = tctx; @@ -3680,6 +4966,7 @@ static void scr_net_dispatch(void) { * failure may have closed. */ if (evs[i].events & SCRP_WRITABLE) { if (s->connecting) scr_net_sock_connect_done(s); + else if (s->tunnel != NULL) scr_net_sock_tunnel_pump(s); else if (s->tops && !s->t_est) { s->t_want_write = false; scr_net_sock_transport_pump(s); @@ -3690,7 +4977,8 @@ static void scr_net_dispatch(void) { } } if ((evs[i].events & SCRP_READABLE) && !scr_exc_pending() && s->fd >= 0) { - if (s->tops && !s->t_est) scr_net_sock_transport_pump(s); + if (s->tunnel != NULL) scr_net_sock_tunnel_pump(s); + else if (s->tops && !s->t_est) scr_net_sock_transport_pump(s); else scr_net_sock_read(s); } } diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index a35175996..12e30acb4 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -249,6 +249,15 @@ ScrDyn *scr_net_sock_address(ScrNetSocket *s); /* +1: socket.address() */ /* socket.resetAndDestroy(): RST close (deferred to 'connect' while * connecting); throws ERR_INVALID_HANDLE_TYPE on TLS. +1: chaining. */ ScrNetSocket *scr_net_sock_reset_and_destroy(ScrNetSocket *s); +/* net.SocketAddress and net.BlockList: native handles. The constructors + * take the argument array; parse answers undefined when the input is not + * a socket address. All +1, borrowed arguments; may throw. */ +ScrDyn *scr_net_socket_address_new(const ScrDyn *args); +ScrDyn *scr_net_socket_address_parse(const ScrDyn *input); +bool scr_net_is_socket_address(const ScrDyn *value); +ScrDyn *scr_net_block_list_new(const ScrDyn *args); +bool scr_net_is_block_list(const ScrDyn *value); +ScrDyn *scr_net_block_list_private_ranges(void); /* server.dropMaxConnection: a stored value (+1 get, borrowed set). */ ScrDyn *scr_net_server_drop_max_connection_get(ScrNetServer *s); void scr_net_server_drop_max_connection_set(ScrNetServer *s, const ScrDyn *value); @@ -332,6 +341,18 @@ typedef struct ScrNetTransportOps { void (*free)(void *tctx); } ScrNetTransportOps; void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx); +/* Routes a client socket through an HTTP CONNECT proxy. Call it right + * after the dial (scr_net_connect to the proxy) and before any transport + * is set: once TCP is up, `request` (the CONNECT head, copied) is sent + * and the proxy's response head is read. A 200 status starts the + * transport handshake, or fires 'connect'; any other status fails the + * socket with "Proxy response () !== 200 when HTTP Tunneling". */ +void scr_net_sock_set_tunnel(ScrNetSocket *s, const char *request, size_t len); +/* The status code of a CONNECT response head (head[0..len) ends with the + * blank line), or -1 when it does not start with an HTTP/1.x status + * line; and undici's message for a status other than 200 (or -1). */ +int scr_net_tunnel_status(const char *head, size_t len); +void scr_net_tunnel_failure(int status, char *msg, size_t cap); int scr_net_sock_fd(ScrNetSocket *s); /* Drain up to n bytes of the socket's receive buffer (peeked/unshifted * bytes — the demux path); consumers call it before the fd. */ 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_os.c b/packages/runtime/src/scr_os.c index 0bc50fb3f..76f69588e 100644 --- a/packages/runtime/src/scr_os.c +++ b/packages/runtime/src/scr_os.c @@ -296,23 +296,27 @@ static void scr_os_system_error(const char *syscall, int uv_code) { if (cell->kind == SCR_EXC_OBJ) scr_error_set_code((ScrError *)cell->payload, "ERR_SYSTEM_ERROR"); } -static bool scr_os_validate_int32(const char *name, double value) { +static bool scr_os_validate_int32_in(const char *name, double value, int min, int max) { if (isfinite(value) && trunc(value) == value) { - if (value >= -2147483648.0 && value <= 2147483647.0) return true; + if (value >= (double)min && value <= (double)max) return true; } char received[48]; scr_num_received(value, received); char text[192]; int n = isfinite(value) && trunc(value) == value ? snprintf(text, sizeof text, - "The value of \"%s\" is out of range. It must be >= -2147483648 && <= 2147483647. Received %s", - name, received) + "The value of \"%s\" is out of range. It must be >= %d && <= %d. Received %s", + name, min, max, received) : snprintf(text, sizeof text, "The value of \"%s\" is out of range. It must be an integer. Received %s", name, received); scr_throw_error_msg_code(SCR_ERR_RANGE, text, n < 0 ? 0 : (size_t)n, "ERR_OUT_OF_RANGE"); return false; } +static bool scr_os_validate_int32(const char *name, double value) { + return scr_os_validate_int32_in(name, value, INT32_MIN, INT32_MAX); +} + double scr_os_get_priority(double pid) { if (!scr_os_validate_int32("pid", pid)) return 0; #ifdef _WIN32 @@ -351,6 +355,48 @@ double scr_os_get_priority(double pid) { #endif } +/* ── setPriority ────────────────────────────────────────────────────── + * validateInt32(pid, 'pid') and validateInt32(priority, 'priority', -20, + * 19) (a one-argument call passes pid 0), then uv_os_setpriority; a + * failure is Node's ERR_SYSTEM_ERROR. */ + +void scr_os_set_priority(double pid, double priority) { + if (!scr_os_validate_int32("pid", pid)) return; + if (!scr_os_validate_int32_in("priority", priority, SCR_OS_PRIORITY_HIGHEST, SCR_OS_PRIORITY_LOW)) return; + int value = (int)priority; +#ifdef _WIN32 + /* libuv's mapping of nice values to Windows priority classes. */ + DWORD klass = value < SCR_OS_PRIORITY_HIGH ? REALTIME_PRIORITY_CLASS + : value < SCR_OS_PRIORITY_ABOVE_NORMAL ? HIGH_PRIORITY_CLASS + : value < SCR_OS_PRIORITY_NORMAL ? ABOVE_NORMAL_PRIORITY_CLASS + : value < SCR_OS_PRIORITY_BELOW_NORMAL ? NORMAL_PRIORITY_CLASS + : value < SCR_OS_PRIORITY_LOW ? BELOW_NORMAL_PRIORITY_CLASS + : IDLE_PRIORITY_CLASS; + HANDLE handle = pid == 0 ? GetCurrentProcess() + : OpenProcess(PROCESS_SET_INFORMATION, FALSE, (DWORD)(int)pid); + if (handle == NULL) { + DWORD err = GetLastError(); + scr_os_system_error("uv_os_setpriority", err == ERROR_ACCESS_DENIED ? SCR_NODE_UV__EPERM + : err == ERROR_INVALID_PARAMETER ? SCR_NODE_UV__EINVAL + : SCR_NODE_UV__UNKNOWN); + return; + } + BOOL ok = SetPriorityClass(handle, klass); + DWORD err = ok ? 0 : GetLastError(); + if (pid != 0) CloseHandle(handle); + if (!ok) { + scr_os_system_error("uv_os_setpriority", err == ERROR_ACCESS_DENIED ? SCR_NODE_UV__EPERM + : SCR_NODE_UV__UNKNOWN); + } +#elif defined(__wasi__) + (void)value; + scr_os_system_error("uv_os_setpriority", SCR_NODE_UV__ENOSYS); +#else + if (setpriority(PRIO_PROCESS, (id_t)(int)pid, value) != 0) + scr_os_system_error("uv_os_setpriority", SCR_NODE_UV__ERR(errno)); +#endif +} + /* ── cpus ───────────────────────────────────────────────────────────── * uv_cpu_info, snapshotted once per os.cpus() call: scr_os_cpus_take * reads every CPU and answers the count; the accessors read that diff --git a/packages/runtime/src/scr_os.h b/packages/runtime/src/scr_os.h index b40a8027a..3c5009fc5 100644 --- a/packages/runtime/src/scr_os.h +++ b/packages/runtime/src/scr_os.h @@ -27,6 +27,10 @@ ScrArr *scr_os_loadavg(void); /* os.getPriority(pid): validateInt32, then the scheduling priority; * failures throw Node's RangeError or SystemError (ERR_SYSTEM_ERROR). */ double scr_os_get_priority(double pid); +/* os.setPriority(pid, priority): validateInt32 for both (priority within + * -20..19), then the scheduling priority; failures throw Node's RangeError + * or SystemError (ERR_SYSTEM_ERROR). */ +void scr_os_set_priority(double pid, double priority); /* os.cpus(): take answers the CPU count of a fresh snapshot; model and * field (0 speed, 1 user, 2 nice, 3 sys, 4 idle, 5 irq) read it. */ double scr_os_cpus_take(void); diff --git a/packages/runtime/src/scr_qs.c b/packages/runtime/src/scr_qs.c index a61f95d94..1b110ba0d 100644 --- a/packages/runtime/src/scr_qs.c +++ b/packages/runtime/src/scr_qs.c @@ -478,8 +478,8 @@ ScrDyn *scr_qs_parse_dyn(const ScrStr *qs) { /* ── stringify ───────────────────────────────────────────────────────── */ /* Node's encodeStringified over one dyn value: strings escape, finite - * numbers render then escape, booleans are bare, everything else is the - * empty value. Appends to b. */ + * numbers render then escape, bigints and booleans are bare, everything + * else is the empty value. Appends to b. */ static void qs_stringify_value(QsBuf *b, const ScrDyn *v) { switch (v->kind) { case SCR_DYN_STR: { @@ -497,12 +497,18 @@ static void qs_stringify_value(QsBuf *b, const ScrDyn *v) { scr_str_release(num); return; } + case SCR_DYN_BIGINT: { + ScrStr *text = scr_bigint_to_string(v->v.bigint, 10); + qb_append_str(b, text); + scr_str_release(text); + return; + } case SCR_DYN_BOOL: if (v->v.b) qb_append(b, "true", 4); else qb_append(b, "false", 5); return; default: - return; /* null/undefined/objects/arrays/functions → '' */ + return; /* null/undefined/symbols/objects/arrays/functions → '' */ } } diff --git a/packages/runtime/src/scr_rc.h b/packages/runtime/src/scr_rc.h index 7069839c7..cdc03c966 100644 --- a/packages/runtime/src/scr_rc.h +++ b/packages/runtime/src/scr_rc.h @@ -235,6 +235,11 @@ void scr_rt_free(void *p); * of [lo, hi), aligned to 256 KiB, in ahead of use; builds without the * helper ignore it. */ void scr_mem_prefault(void *lo, void *hi); +/* Reorders ptrs, distinct pointers `offset` bytes into blocks that are + * about to be freed in array order, so that the free lists hand the blocks + * back in ascending address order (scr_alloc.c). Builds without the + * small-object allocator leave the array as it is. */ +void scr_mem_order_for_free(void **ptrs, size_t n, size_t offset); /* Size classes: requests up to 128 bytes take a class per 8-byte step * (small objects often sit just under a 16-byte boundary), larger ones a @@ -336,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/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 2526ca595..b24261a4d 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -85,6 +85,7 @@ static int scr_lre_flags(const ScrStr *flags) { case 's': mask |= LRE_FLAG_DOTALL; break; case 'u': mask |= LRE_FLAG_UNICODE; break; case 'y': mask |= LRE_FLAG_STICKY; break; + case 'v': mask |= LRE_FLAG_UNICODE_SETS; break; default: scr_trap_fmt("scriptc: internal error: unexpected regex flag '%c'\n", flags->data[i]); @@ -201,7 +202,7 @@ static uint8_t *scr_regex_bc(ScrRegex *re) { const char *pat = re->source->data; size_t pat_len = re->source->len; char *cesu = NULL; - if (!(flags & LRE_FLAG_UNICODE)) { + if (!(flags & (LRE_FLAG_UNICODE | LRE_FLAG_UNICODE_SETS))) { cesu = scr_pattern_cesu8(re->source, &pat_len); if (cesu) pat = cesu; } @@ -2042,11 +2043,9 @@ ScrRegex *scr_regex_new(ScrStr *pattern, ScrStr *flags) { } /* Valid in Node, absent from the engine: name the gap rather than * report a flag Node accepts as invalid. */ - if (seen_flags & ((1u << 6) | (1u << 7))) { + if (seen_flags & (1u << 6)) { static const char d_msg[] = "the regex 'd' flag (match indices) is not supported yet [SC1120]"; - static const char v_msg[] = "the regex 'v' flag (unicode sets) is not supported yet [SC1120]"; - if (seen_flags & (1u << 6)) scr_throw_error_msg(SCR_ERR_ERROR, d_msg, sizeof d_msg - 1); - else scr_throw_error_msg(SCR_ERR_ERROR, v_msg, sizeof v_msg - 1); + scr_throw_error_msg(SCR_ERR_ERROR, d_msg, sizeof d_msg - 1); return NULL; } ScrRegex *re = calloc(1, sizeof *re); @@ -2057,11 +2056,11 @@ ScrRegex *scr_regex_new(ScrStr *pattern, ScrStr *flags) { re->source = pattern->len > 0 ? scr_str_retain(pattern) : scr_str_new("(?:)", 4); /* Public flags use getter order, independently of constructor spelling. * Validate first so duplicates and invalid flags retain their errors. */ - char canonical[6]; + char canonical[8]; size_t flag_count = 0; - const char *order = "gimsuy"; - for (unsigned i = 0; i < 6; i++) { - if (seen_flags & (1u << i)) canonical[flag_count++] = order[i]; + static const unsigned order_bits[] = {0, 1, 2, 3, 4, 7, 5}; + for (unsigned i = 0; i < sizeof order_bits / sizeof order_bits[0]; i++) { + if (seen_flags & (1u << order_bits[i])) canonical[flag_count++] = "gimsuvy"[i]; } re->flags = flag_count == flags->len && memcmp(canonical, flags->data, flag_count) == 0 ? scr_str_retain(flags) : scr_str_new(canonical, flag_count); @@ -2078,7 +2077,7 @@ ScrRegex *scr_regex_new(ScrStr *pattern, ScrStr *flags) { const char *pat = re->source->data; size_t pat_len = re->source->len; char *cesu = NULL; - if (!(lre_flags & LRE_FLAG_UNICODE)) { + if (!(lre_flags & (LRE_FLAG_UNICODE | LRE_FLAG_UNICODE_SETS))) { cesu = scr_pattern_cesu8(re->source, &pat_len); if (cesu) pat = cesu; } diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index df08ea318..244e2ec5d 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -6367,6 +6367,15 @@ const ScrChildStreamOps scr_stream_child_ops = { scr_stream_write_done, scr_stream_final_done, }; +static ScrDyn *scr_stream_dyn_view_v(void *stream) { return scr_stream_dyn_view(stream); } + +/* new Worker() with the stdout or stderr option (readable.workerStdio) */ +void scr_stream_worker_stdio(void) { +#ifdef SCR_WORKERS + scr_worker_stdio_default(&scr_stream_child_ops, scr_stream_dyn_view_v); +#endif +} + static ScrStream *scr_stream_view_receiver(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)) { diff --git a/packages/runtime/src/scr_stream.h b/packages/runtime/src/scr_stream.h index 1836c206d..82727c1dc 100644 --- a/packages/runtime/src/scr_stream.h +++ b/packages/runtime/src/scr_stream.h @@ -123,6 +123,7 @@ void scr_stream_init_passthrough(ScrStream *s, double rhwm, double whwm, bool au * (a subclass's overridden underscore-method wrappers, shadowed by * options callbacks per Node's instance-property rule) MOVE. */ ScrStream *scr_stream_new_readable_dyn(struct ScrDyn *opts); +void scr_stream_worker_stdio(void); ScrStream *scr_stream_new_writable_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_duplex_dyn(struct ScrDyn *opts); ScrStream *scr_stream_new_transform_dyn(struct ScrDyn *opts); diff --git a/packages/runtime/src/scr_symbol.c b/packages/runtime/src/scr_symbol.c index a29a28ad2..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); @@ -457,3 +462,371 @@ ScrDyn *scr_dyn_using_dispose(const ScrDyn *method, const ScrDyn *value) { scr_dyn_release(result); return scr_dyn_retain(scr_dyn_undefined()); } + +/* ── DisposableStack ─────────────────────────────────────────────────── + * The stack is a collector-headered object behind a traced dyn handle: its + * resources hold callbacks that can capture the stack itself. Each entry + * records how disposal calls it: `use` calls the value's dispose method + * with the value as `this`, `adopt` calls the callback with the value, and + * `defer` calls the callback with no arguments. Disposal runs the entries + * in reverse order; each later failure becomes a SuppressedError over the + * error so far, and a single failure is rethrown as it was thrown. */ +enum { SCR_DS_USE, SCR_DS_ADOPT, SCR_DS_DEFER }; + +typedef struct { + ScrDyn *value; /* the resource (use, adopt); NULL for defer */ + ScrDyn *method; + unsigned kind; + /* Which members the last trace visited: the collector frees those + * itself, and gcfree must not read them afterwards. */ + bool value_traced, method_traced; +} ScrDisposableResource; + +typedef struct { + size_t rc; + bool disposed; + ScrDisposableResource *items; + size_t len, cap; +} ScrDisposableStack; + +static bool scr_ds_traced(const ScrDyn *value) { + return value && value->kind > SCR_DYN_STR && value->kind != SCR_DYN_UNDEF; +} + +static void scr_ds_trace(void *ptr, ScrTraceVisit visit, void *ctx) { + ScrDisposableStack *s = ptr; + for (size_t i = 0; i < s->len; i++) { + ScrDisposableResource *r = &s->items[i]; + r->value_traced = scr_ds_traced(r->value); + r->method_traced = scr_ds_traced(r->method); + if (r->value_traced) visit(r->value, ctx); + if (r->method_traced) visit(r->method, ctx); + } +} + +/* The collector frees traced members itself; the rest are released here. */ +static void scr_ds_gcfree(void *ptr) { + ScrDisposableStack *s = ptr; + for (size_t i = 0; i < s->len; i++) { + if (!s->items[i].value_traced) scr_dyn_release(s->items[i].value); + if (!s->items[i].method_traced) scr_dyn_release(s->items[i].method); + } + free(s->items); + scr_obj_free_note(); + scr_cyc_free(ptr); +} + +static void scr_ds_destroy(void *ptr) { + ScrDisposableStack *s = ptr; + for (size_t i = 0; i < s->len; i++) { + scr_dyn_release(s->items[i].value); + scr_dyn_release(s->items[i].method); + } + s->len = 0; + scr_ds_gcfree(ptr); +} + +static void *scr_ds_retain(void *ptr) { + ((ScrDisposableStack *)ptr)->rc++; + return ptr; +} + +static void scr_ds_release(void *ptr) { + ScrDisposableStack *s = ptr; + if (!s) return; + if (--s->rc == 0) { + scr_cyc_dead(s); + scr_rc_destroy(s, scr_ds_destroy); + } else scr_cyc_released(s); +} + +static void scr_ds_push(ScrDisposableStack *s, ScrDyn *value, ScrDyn *method, unsigned kind) { + if (s->len == s->cap) { + size_t cap = s->cap ? s->cap * 2 : 4; + ScrDisposableResource *items = realloc(s->items, cap * sizeof *items); + if (!items) scr_trap("scriptc: out of memory\n"); + s->items = items; + s->cap = cap; + } + s->items[s->len++] = (ScrDisposableResource){value, method, kind, false, false}; +} + +/* V8's rendering of a value in a TypeError: primitives by ToString, + * arrays as [object Array], other objects as #. */ +static void scr_ds_put_value(ScrJsonBuf *b, const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_UNDEF: scr_jb_puts(b, "undefined"); return; + case SCR_DYN_NULL: scr_jb_puts(b, "null"); return; + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: { + ScrStr *text = scr_dyn_to_string(value, NULL); + if (text) scr_jb_write(b, text->data, text->len); + scr_str_release(text); + return; + } + case SCR_DYN_SYMBOL: { + ScrStr *text = value->v.symbol.render(value->v.symbol.value); + scr_jb_write(b, text->data, text->len); + scr_str_release(text); + return; + } + case SCR_DYN_ARR: scr_jb_puts(b, "[object Array]"); return; + case SCR_DYN_HANDLE: + scr_jb_puts(b, "#<"); + scr_jb_puts(b, scr_dyn_handle_cls(value)); + scr_jb_putc(b, '>'); + return; + default: scr_jb_puts(b, "#"); return; + } +} + +static ScrDisposableStack *scr_ds_of(const ScrDyn *value) { + return value && value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_DISPOSABLE_STACK + ? value->v.handle.ptr : NULL; +} + +bool scr_disposable_stack_is(const ScrDyn *value) { return scr_ds_of(value) != NULL; } + +/* The receiver of DisposableStack.prototype., or NULL with Node's + * TypeError (an incompatible receiver) or ReferenceError (a disposed + * stack, when `live` is required) pending. */ +static ScrDisposableStack *scr_ds_receiver(const ScrDyn *self, const char *name, bool getter, bool live) { + ScrDisposableStack *s = scr_ds_of(self); + ScrJsonBuf message; + if (!s) { + scr_jb_init(&message); + scr_jb_puts(&message, getter ? "Method get DisposableStack.prototype." : "Method DisposableStack.prototype."); + scr_jb_puts(&message, name); + scr_jb_puts(&message, " called on incompatible receiver "); + scr_ds_put_value(&message, self); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return NULL; + } + if (live && s->disposed) { + scr_jb_init(&message); + scr_jb_puts(&message, "Cannot call DisposableStack.prototype."); + scr_jb_puts(&message, name); + scr_jb_puts(&message, " on an already-disposed DisposableStack"); + scr_throw_error(SCR_ERR_REFERENCE, scr_jb_finish(&message)); + return NULL; + } + return s; +} + +static bool scr_ds_callable(const ScrDyn *value) { + if (scr_using_typeof_is(value, "function", 8)) return true; + ScrJsonBuf message; + scr_jb_init(&message); + scr_ds_put_value(&message, value); + scr_jb_puts(&message, " is not a function"); + scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message)); + return false; +} + +bool scr_disposable_stack_use(const ScrDyn *stack, const ScrDyn *value) { + ScrDisposableStack *s = scr_ds_receiver(stack, "use", false, true); + if (!s) return false; + if (value->kind == SCR_DYN_NULL || value->kind == SCR_DYN_UNDEF) return true; + ScrDyn *method = scr_dyn_using_method(value, false); + if (!method) return false; + scr_ds_push(s, scr_dyn_retain((ScrDyn *)value), method, SCR_DS_USE); + return true; +} + +bool scr_disposable_stack_use_callback(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *dispose) { + ScrDisposableStack *s = scr_ds_receiver(stack, "use", false, true); + if (!s) return false; + scr_ds_push(s, scr_dyn_retain((ScrDyn *)value), scr_dyn_retain((ScrDyn *)dispose), SCR_DS_ADOPT); + return true; +} + +bool scr_disposable_stack_adopt(const ScrDyn *stack, const ScrDyn *value, const ScrDyn *on_dispose) { + ScrDisposableStack *s = scr_ds_receiver(stack, "adopt", false, true); + if (!s || !scr_ds_callable(on_dispose)) return false; + scr_ds_push(s, scr_dyn_retain((ScrDyn *)value), scr_dyn_retain((ScrDyn *)on_dispose), SCR_DS_ADOPT); + return true; +} + +static bool scr_disposable_stack_defer(const ScrDyn *stack, const ScrDyn *on_dispose) { + ScrDisposableStack *s = scr_ds_receiver(stack, "defer", false, true); + if (!s || !scr_ds_callable(on_dispose)) return false; + scr_ds_push(s, NULL, scr_dyn_retain((ScrDyn *)on_dispose), SCR_DS_DEFER); + return true; +} + +/* DisposeResources over the stack's entries: NULL with the disposal error + * pending, or undefined (+1). */ +static ScrDyn *scr_ds_dispose(ScrDisposableStack *s) { + if (s->disposed) return scr_dyn_retain(scr_dyn_undefined()); + s->disposed = true; + ScrDisposableResource *items = s->items; + size_t len = s->len; + s->items = NULL; + s->len = s->cap = 0; + ScrCaught *error = NULL; + for (size_t i = len; i-- > 0;) { + ScrDisposableResource *r = &items[i]; + ScrDyn *result; + if (r->kind == SCR_DS_USE) { + result = scr_dyn_using_dispose(r->method, r->value); + } else { + scr_dyn_this_push_dyn(scr_dyn_undefined()); + result = r->kind == SCR_DS_ADOPT ? scr_dyn_call(r->method, &r->value, 1, "onDispose") + : scr_dyn_call(r->method, NULL, 0, "onDispose"); + scr_dyn_this_pop(); + } + if (result) { + scr_dyn_release(result); + } else { + if (error) scr_exc_suppress(error); + error = scr_exc_take(); + } + scr_dyn_release(r->value); + scr_dyn_release(r->method); + } + free(items); + if (!error) return scr_dyn_retain(scr_dyn_undefined()); + scr_rethrow(error); + scr_caught_release(error); + return NULL; +} + +static SCR_TL ScrDyn *scr_ds_method_values[6]; +static const char *const scr_ds_method_names[] = {"use", "adopt", "defer", "move", "dispose", "disposed"}; +static const uint32_t scr_ds_method_lengths[] = {1, 2, 1, 0, 0, 0}; +enum { SCR_DS_M_USE, SCR_DS_M_ADOPT, SCR_DS_M_DEFER, SCR_DS_M_MOVE, SCR_DS_M_DISPOSE, SCR_DS_M_DISPOSED }; + +static ScrDisposableStack *scr_ds_alloc(void); +static ScrDyn *scr_ds_box(ScrDisposableStack *s); + +static ScrDyn *scr_ds_method_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + unsigned index = (unsigned)scr_box_get_f64(clo->caps[0]); + ScrDyn *self = scr_dyn_this_get(); + const ScrDyn *arg0 = argc > 0 ? args[0] : scr_dyn_undefined(); + const ScrDyn *arg1 = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *out = NULL; + switch (index) { + case SCR_DS_M_USE: + if (scr_disposable_stack_use(self, arg0)) out = scr_dyn_retain((ScrDyn *)arg0); + break; + case SCR_DS_M_ADOPT: + if (scr_disposable_stack_adopt(self, arg0, arg1)) out = scr_dyn_retain((ScrDyn *)arg0); + break; + case SCR_DS_M_DEFER: + if (scr_disposable_stack_defer(self, arg0)) out = scr_dyn_retain(scr_dyn_undefined()); + break; + case SCR_DS_M_MOVE: { + /* The new stack takes the entries; this one becomes disposed. */ + ScrDisposableStack *s = scr_ds_receiver(self, "move", false, true); + if (!s) break; + ScrDisposableStack *moved = scr_ds_alloc(); + moved->items = s->items; + moved->len = s->len; + moved->cap = s->cap; + s->items = NULL; + s->len = s->cap = 0; + s->disposed = true; + out = scr_ds_box(moved); + scr_ds_release(moved); + break; + } + default: { + ScrDisposableStack *s = scr_ds_receiver(self, "dispose", false, false); + if (s) out = scr_ds_dispose(s); + break; + } + } + scr_dyn_release(self); + return out; +} + +/* The function value of method `index` (+1), cached per thread. */ +static ScrDyn *scr_ds_method_value(unsigned index) { + if (!scr_ds_method_values[index]) { + ScrClosure *clo = scr_closure_new(NULL, 1); + clo->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(clo->caps[0], (double)index); + scr_ds_method_values[index] = scr_dyn_new_func(clo, scr_ds_method_call, scr_ds_method_lengths[index], + "", scr_ds_method_names[index]); + } + return scr_dyn_retain(scr_ds_method_values[index]); +} + +static ScrDyn *scr_ds_get(void *ptr, const char *key, size_t len) { + ScrDisposableStack *s = ptr; + if (len == 8 && memcmp(key, "disposed", 8) == 0) return scr_dyn_new_bool(s->disposed); + for (unsigned i = 0; i < SCR_DS_M_DISPOSED; i++) { + if (strlen(scr_ds_method_names[i]) == len && memcmp(key, scr_ds_method_names[i], len) == 0) + return scr_ds_method_value(i); + } + return NULL; +} + +static bool scr_ds_set(void *ptr, const char *key, size_t len, const ScrDyn *value) { + (void)ptr; + (void)key; + (void)len; + (void)value; + return false; +} + +/* [Symbol.dispose] is the dispose method itself, as in Node. */ +static ScrDyn *scr_ds_symbol_get(void *ptr, const ScrDyn *key) { + (void)ptr; + if (key->kind != SCR_DYN_SYMBOL) return NULL; + ScrStr *name = scr_str_new("dispose", 7); + ScrSym *dispose = scr_sym_well_known(name); + scr_str_release(name); + bool match = key->v.symbol.value == dispose; + scr_sym_release(dispose); + return match ? scr_ds_method_value(SCR_DS_M_DISPOSE) : NULL; +} + +static ScrDyn *scr_ds_inspect_view(void *ptr) { + (void)ptr; + return scr_dyn_new_obj(); +} + +static void scr_ds_teardown(void) { + for (size_t i = 0; i < sizeof scr_ds_method_values / sizeof scr_ds_method_values[0]; i++) { + scr_dyn_release(scr_ds_method_values[i]); + scr_ds_method_values[i] = NULL; + } +} + +static const ScrDynHandleOps scr_ds_ops = { + .cls = "DisposableStack", + .retain = scr_ds_retain, + .release = scr_ds_release, + .get = scr_ds_get, + .set = scr_ds_set, + .inspect_view = scr_ds_inspect_view, + .json_empty_object = true, + .inspect_view_empty = true, + .symbol_get = scr_ds_symbol_get, +}; + +static ScrDisposableStack *scr_ds_alloc(void) { + static SCR_TL bool installed; + if (!installed) { + installed = true; + scr_dyn_handle_install(SCR_DYNH_DISPOSABLE_STACK, &scr_ds_ops); + scr_atexit(scr_ds_teardown); + } + static ScrCycType cyc_type = {&scr_ds_trace, &scr_ds_gcfree, SCR_CYC_KIND_NONE, 0}; + ScrDisposableStack *s = scr_cyc_alloc(sizeof *s, &cyc_type); + s->rc = 1; + scr_obj_alloc_note(); + return s; +} + +static ScrDyn *scr_ds_box(ScrDisposableStack *s) { return scr_dyn_new_handle(s, SCR_DYNH_DISPOSABLE_STACK); } + +ScrDyn *scr_disposable_stack_new(void) { + ScrDisposableStack *s = scr_ds_alloc(); + ScrDyn *out = scr_ds_box(s); + scr_ds_release(s); + return out; +} diff --git a/packages/runtime/src/scr_test.c b/packages/runtime/src/scr_test.c index 89301cbd3..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 @@ -120,6 +121,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 +445,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 +683,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) { @@ -808,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) { @@ -819,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); @@ -1042,16 +1617,529 @@ 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; + } + /* 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; + 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 +2186,11 @@ 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(); + 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 * 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..b1078cccd 100644 --- a/packages/runtime/src/scr_test.h +++ b/packages/runtime/src/scr_test.h @@ -57,6 +57,58 @@ 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; + * 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/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 1f481c4fd..0eefae6ff 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -1089,6 +1089,234 @@ void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx) { scr_tls_cli_wrap(sock, ctx); } +/* ── the fetch tunnel through an https:// proxy ─────────────────────── + * One transport carries three stages over the proxy socket: the TLS + * session to the proxy (verified against the proxy host), the CONNECT + * exchange inside it, then the target's TLS session (verified against + * the target host) inside the tunnel. An http:// target uses the + * tunnel's plaintext directly. */ + +enum { SCR_TUN_OUTER = 0, SCR_TUN_SEND, SCR_TUN_HEAD, SCR_TUN_INNER, SCR_TUN_UP }; + +typedef struct { + ScrTlsSock *outer; /* the session to the proxy */ + ScrTlsSock *inner; /* the session to the target; NULL for http:// */ + int stage; + char *req; /* the CONNECT head */ + size_t req_len, req_off; + char *head; /* the proxy's response head as it arrives */ + size_t head_len; + char *extra; /* tunnel bytes that arrived with the response head */ + size_t extra_len, extra_off; +} ScrTlsTunnel; + +static ScrTlsSock *scr_tls_cli_sock(ScrNetSocket *sock, ScrTlsCli *cli) { + ScrTlsSock *t = calloc(1, sizeof *t); + if (!t) scr_tls_oom(); + mbedtls_ssl_init(&t->ssl); + if (mbedtls_ssl_setup(&t->ssl, &cli->conf) != 0) scr_tls_oom(); + (void)mbedtls_ssl_set_hostname(&t->ssl, cli->hostname); + mbedtls_ssl_set_verify(&t->ssl, &scr_tls_vrfy_cb, t); + t->sock = sock; + t->cli = cli; + return t; +} + +/* Reads tunnel plaintext: bytes that arrived with the proxy's head + * first, then the proxy session. mbedTLS return conventions. */ +static int scr_tls_tunnel_read(ScrTlsTunnel *tun, unsigned char *buf, size_t len) { + if (tun->extra_off < tun->extra_len) { + size_t n = tun->extra_len - tun->extra_off; + if (n > len) n = len; + memcpy(buf, tun->extra + tun->extra_off, n); + tun->extra_off += n; + return (int)n; + } + int r = mbedtls_ssl_read(&tun->outer->ssl, buf, len); + if (r == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) return 0; +#if defined(MBEDTLS_ERR_SSL_RECEIVED_NEW_SESSION_TICKET) + if (r == MBEDTLS_ERR_SSL_RECEIVED_NEW_SESSION_TICKET) return MBEDTLS_ERR_SSL_WANT_READ; +#endif + return r; +} + +/* The target session's BIOs: records travel inside the proxy session. */ +static int scr_tls_tunnel_bio_send(void *ctx, const unsigned char *buf, size_t len) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)ctx; + return mbedtls_ssl_write(&tun->outer->ssl, buf, len); +} + +static int scr_tls_tunnel_bio_recv(void *ctx, unsigned char *buf, size_t len) { + return scr_tls_tunnel_read((ScrTlsTunnel *)ctx, buf, len); +} + +static int scr_tls_tunnel_handshake(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + ScrNetSocket *sock = tun->outer->sock; + if (tun->stage == SCR_TUN_OUTER) { + int r = scr_tls_handshake(tun->outer); + if (r <= 0) return r; + tun->outer->established = true; + tun->stage = SCR_TUN_SEND; + } + if (tun->stage == SCR_TUN_SEND) { + while (tun->req_off < tun->req_len) { + int r = mbedtls_ssl_write(&tun->outer->ssl, (const unsigned char *)tun->req + tun->req_off, + tun->req_len - tun->req_off); + if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) return 0; + if (r < 0) { + scr_net_sock_transport_error(sock, "write EPIPE"); + return -1; + } + tun->req_off += (size_t)r; + } + tun->stage = SCR_TUN_HEAD; + } + if (tun->stage == SCR_TUN_HEAD) { + for (;;) { + unsigned char buf[4096]; + int r = scr_tls_tunnel_read(tun, buf, sizeof buf); + if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) return 0; + if (r < 0 && r != MBEDTLS_ERR_SSL_CONN_EOF) { + scr_net_sock_transport_error(sock, "read ECONNRESET"); + return -1; + } + if (r <= 0) { + scr_net_sock_transport_error(sock, "socket hang up"); + return -1; + } + if (tun->head_len + (size_t)r > 16384) { + scr_net_sock_transport_error(sock, "Proxy response head is too large"); + return -1; + } + char *grown = realloc(tun->head, tun->head_len + (size_t)r); + if (!grown) scr_tls_oom(); + tun->head = grown; + memcpy(tun->head + tun->head_len, buf, (size_t)r); + tun->head_len += (size_t)r; + size_t end = 0; + for (size_t i = 3; i < tun->head_len; i++) { + if (memcmp(tun->head + i - 3, "\r\n\r\n", 4) == 0) { + end = i + 1; + break; + } + } + if (end == 0) continue; + int status = scr_net_tunnel_status(tun->head, end); + if (status != 200) { + char msg[128]; + scr_net_tunnel_failure(status, msg, sizeof msg); + scr_net_sock_transport_error(sock, msg); + return -1; + } + tun->extra = malloc(tun->head_len - end + 1); + if (!tun->extra) scr_tls_oom(); + memcpy(tun->extra, tun->head + end, tun->head_len - end); + tun->extra_len = tun->head_len - end; + tun->extra_off = 0; + free(tun->head); + tun->head = NULL; + tun->head_len = 0; + tun->stage = tun->inner != NULL ? SCR_TUN_INNER : SCR_TUN_UP; + break; + } + } + if (tun->stage == SCR_TUN_INNER) { + int r = scr_tls_handshake(tun->inner); + if (r <= 0) return r; + tun->stage = SCR_TUN_UP; + } + return 1; +} + +static ssize_t scr_tls_tunnel_xread(void *tctx, char *buf, size_t n) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) return scr_tls_xread(tun->inner, buf, n); + if (tun->extra_off < tun->extra_len) { + return scr_tls_tunnel_read(tun, (unsigned char *)buf, n); + } + return scr_tls_xread(tun->outer, buf, n); +} + +static ssize_t scr_tls_tunnel_xwrite(void *tctx, const char *buf, size_t n) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + return scr_tls_xwrite(tun->inner != NULL ? tun->inner : tun->outer, buf, n); +} + +static void scr_tls_tunnel_on_established(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) tun->inner->established = true; +} + +static bool scr_tls_tunnel_pending(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + return tun->extra_off < tun->extra_len || (tun->inner != NULL && scr_tls_pending(tun->inner)) || + scr_tls_pending(tun->outer); +} + +static void scr_tls_tunnel_shutdown_write(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) scr_tls_shutdown_write(tun->inner); + scr_tls_shutdown_write(tun->outer); +} + +static void scr_tls_tunnel_free(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) scr_tls_free(tun->inner); + scr_tls_free(tun->outer); + free(tun->req); + free(tun->head); + free(tun->extra); + free(tun); +} + +static const ScrNetTransportOps SCR_TLS_TUNNEL_OPS = { + .xread = scr_tls_tunnel_xread, + .xwrite = scr_tls_tunnel_xwrite, + .handshake = scr_tls_tunnel_handshake, + .on_established = scr_tls_tunnel_on_established, + .pending = scr_tls_tunnel_pending, + .shutdown_write = scr_tls_tunnel_shutdown_write, + .free = scr_tls_tunnel_free, +}; + +typedef struct { + ScrTlsCli *proxy; /* moves into the transport */ + ScrTlsCli *target; /* NULL for an http:// target */ + char *req; + size_t req_len; +} ScrTlsTunnelCtx; + +void *scr_tls_fetch_tunnel_ctx(ScrStr *proxy_host /*borrowed*/, ScrStr *target_host /*borrowed, nullable*/, + const char *request, size_t len) { + ScrTlsTunnelCtx *ctx = calloc(1, sizeof *ctx); + if (!ctx) scr_tls_oom(); + ctx->proxy = scr_tls_cli_new(proxy_host, true, NULL, 0); + ctx->target = target_host != NULL ? scr_tls_cli_new(target_host, true, NULL, 0) : NULL; + ctx->req = malloc(len); + if (!ctx->req) scr_tls_oom(); + memcpy(ctx->req, request, len); + ctx->req_len = len; + return ctx; +} + +void scr_tls_fetch_tunnel_wrap(ScrNetSocket *sock, void *vctx) { + ScrTlsTunnelCtx *ctx = (ScrTlsTunnelCtx *)vctx; + ScrTlsTunnel *tun = calloc(1, sizeof *tun); + if (!tun) scr_tls_oom(); + tun->outer = scr_tls_cli_sock(sock, ctx->proxy); + mbedtls_ssl_set_bio(&tun->outer->ssl, tun->outer, scr_tls_bio_send, scr_tls_bio_recv, NULL); + if (ctx->target != NULL) { + tun->inner = scr_tls_cli_sock(sock, ctx->target); + mbedtls_ssl_set_bio(&tun->inner->ssl, tun, scr_tls_tunnel_bio_send, scr_tls_tunnel_bio_recv, + NULL); + } + tun->req = ctx->req; + tun->req_len = ctx->req_len; + free(ctx); + scr_net_sock_set_transport(sock, &SCR_TLS_TUNNEL_OPS, tun); +} + ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, @@ -1767,6 +1995,7 @@ static void scr_tls_fire_session(ScrTlsSock *t) { size_t need = 0; (void)mbedtls_ssl_session_save(&sess, NULL, 0, &need); ScrBytes *buf = scr_bytes_new(SCR_BYTES_U8, (double)need); + buf->is_buffer = true; /* the session is a Buffer */ size_t used = 0; int ok = mbedtls_ssl_session_save(&sess, buf->data, need, &used) == 0; mbedtls_ssl_session_free(&sess); diff --git a/packages/runtime/src/scr_url.c b/packages/runtime/src/scr_url.c index b6fdb9cd6..eeadba98f 100644 --- a/packages/runtime/src/scr_url.c +++ b/packages/runtime/src/scr_url.c @@ -1686,9 +1686,10 @@ static ScrDyn *scr_native_sp_step(void *ptr, double *index, unsigned selection) } else value = scr_dyn_new_str(selection == 1 ? key : text); scr_str_release(key); scr_str_release(text); } + /* { value, done }, in the iterator result's own key order. */ ScrDyn *result = scr_dyn_new_obj(); - scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(done)); scr_dyn_obj_set(result, "value", 5, value); + scr_dyn_obj_set(result, "done", 4, scr_dyn_new_bool(done)); return result; } diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c index a005015d6..f5ef488dc 100644 --- a/packages/runtime/src/scr_util_callable.c +++ b/packages/runtime/src/scr_util_callable.c @@ -610,7 +610,7 @@ static ScrStr *uc_format(ScrDyn *const *args, size_t argc, bool colors) { ScrStr *piece = NULL; switch (next) { case 's': piece = uc_format_s(args[++a]); break; - case 'j': piece = scr_dyn_format_j(args[++a]); break; + case 'j': piece = scr_util_format_j(args[++a]); break; case 'd': case 'i': case 'f': piece = uc_format_number(args[++a], next); break; diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index a64c6c6e6..67f4ec084 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -10,6 +10,7 @@ #include #else #include +#include #endif #if defined(__linux__) @@ -60,7 +61,11 @@ struct ScrWorkerThread { ScrMailbox *inbox; ScrMailbox *parent; ScrMessage *data; + /* data is [workerData, entries] when the parent had environment data */ + bool environment_data; ScrContextEnv *environment; + ScrStr *name; /* threadName: immortal copy shared by both threads */ + unsigned capture; /* 1 stdout, 2 stderr: writes go to the parent as events */ int argc; char **argv; bool joined; @@ -75,6 +80,7 @@ struct ScrWorkerThread { static atomic_uint_fast64_t scr_worker_next_id = 1; static SCR_TL ScrWorkerThread *scr_worker_current; static SCR_TL ScrDyn *scr_worker_local_data; +static SCR_TL ScrDyn *scr_worker_local_environment; /* SCRIPTC_WORKER_STATS=1 reports, as each worker thread exits, how long it * took to start, to run its script (runtime and module initialization @@ -115,6 +121,7 @@ void scr_worker_thread_release(ScrWorkerThread *worker) { scr_mailbox_release(worker->parent); scr_message_free(worker->data); scr_context_env_free(worker->environment); + free(worker->name); for (int i = 0; i < worker->argc; i++) free(worker->argv[i]); free(worker->argv); #ifdef SCR_EXC_FLAG @@ -125,23 +132,46 @@ void scr_worker_thread_release(ScrWorkerThread *worker) { static void scr_worker_data_cleanup(void) { ScrDyn *data = scr_worker_local_data; + ScrDyn *environment = scr_worker_local_environment; scr_worker_local_data = NULL; + scr_worker_local_environment = NULL; scr_dyn_release(data); + scr_dyn_release(environment); +} + +/* Decodes the start message once: workerData and, when the parent had + * environment data, its entries. */ +static bool scr_worker_decode_start(void) { + if (scr_worker_local_data) return true; + ScrDyn *data = scr_message_decode(scr_worker_current->data); + if (!data) return false; + if (scr_worker_current->environment_data) { + scr_worker_local_environment = scr_dyn_arr_at(data, 1); + ScrDyn *value = scr_dyn_arr_at(data, 0); + scr_dyn_release(data); + data = value; + } + scr_worker_local_data = data; + if (scr_context_atexit(scr_worker_data_cleanup) != 0) scr_trap("scriptc: out of memory\n"); + scr_message_free(scr_worker_current->data); + scr_worker_current->data = NULL; + return true; } ScrDyn *scr_worker_data(void) { if (!scr_worker_current) return scr_dyn_new_null(); - if (!scr_worker_local_data) { - ScrDyn *data = scr_message_decode(scr_worker_current->data); - if (!data) return NULL; - scr_worker_local_data = data; - if (scr_context_atexit(scr_worker_data_cleanup) != 0) scr_trap("scriptc: out of memory\n"); - scr_message_free(scr_worker_current->data); - scr_worker_current->data = NULL; - } + if (!scr_worker_decode_start()) return NULL; return scr_dyn_retain(scr_worker_local_data); } +ScrDyn *scr_worker_take_environment(void) { + if (!scr_worker_current || !scr_worker_current->environment_data) return NULL; + if (!scr_worker_decode_start()) return NULL; + ScrDyn *entries = scr_worker_local_environment; + scr_worker_local_environment = NULL; + return entries; +} + ScrMailbox *scr_worker_inbox(void) { return scr_worker_current ? scr_worker_current->inbox : NULL; } @@ -152,6 +182,22 @@ char **scr_worker_argv(void) { return scr_worker_current ? scr_worker_current->a uint64_t scr_worker_thread_id(const ScrWorkerThread *worker) { return worker->id; } +const ScrStr *scr_worker_thread_name(const ScrWorkerThread *worker) { return worker->name; } + +ScrDyn *scr_worker_thread_name_current(void) { + if (!scr_worker_current) { + ScrStr *empty = scr_str_new("", 0); + ScrDyn *name = scr_dyn_new_str(empty); + scr_str_release(empty); + return name; + } + /* a copy: the thread record's name is freed with the record */ + ScrStr *copy = scr_str_new(scr_worker_current->name->data, scr_worker_current->name->len); + ScrDyn *name = scr_dyn_new_str(copy); + scr_str_release(copy); + return name; +} + void scr_worker_thread_terminate(ScrWorkerThread *worker) { atomic_store_explicit(&worker->cancel, true, memory_order_relaxed); #ifdef SCR_EXC_FLAG @@ -187,16 +233,59 @@ void scr_worker_port_close(void) { atomic_store_explicit(&scr_worker_current->port_closed, true, memory_order_relaxed); } +static _Atomic(void (*)(void)) scr_worker_stdin_attach; + +void scr_worker_set_stdin_attach(void (*attach)(void)) { + atomic_store_explicit(&scr_worker_stdin_attach, attach, memory_order_relaxed); +} + +bool scr_worker_thread_post_stdin(ScrWorkerThread *worker, const void *data, size_t len, bool end) { + ScrMailEvent *event = scr_worker_event(SCR_WORKER_STDIN, 0, 0, NULL); + if (!end) { + event->data = malloc(len ? len : 1); + if (!event->data) scr_trap("scriptc: out of memory\n"); + memcpy(event->data, data, len); + event->length = len; + } + return scr_mailbox_post(worker->inbox, event); +} + bool scr_worker_post_parent(ScrMessage *message) { if (!scr_worker_current) { scr_message_free(message); return false; } return scr_mailbox_post(scr_worker_current->parent, scr_worker_event(SCR_WORKER_MESSAGE, scr_worker_current->id, 0, message)); } +/* The stdout and stderr options: a captured write reaches the parent's + * worker.stdout or worker.stderr in order with the worker's other events. */ +static bool scr_worker_capture(int fd, const void *data, size_t len) { + unsigned bit = fd == 2 ? 2 : 1; + if (!scr_worker_current || !(scr_worker_current->capture & bit)) return false; + if (!len) return true; + ScrMailEvent *event = scr_worker_event(bit == 2 ? SCR_WORKER_STDERR : SCR_WORKER_STDOUT, + scr_worker_current->id, 0, NULL); + event->data = malloc(len); + if (!event->data) scr_trap("scriptc: out of memory\n"); + memcpy(event->data, data, len); + event->length = len; + scr_mailbox_post(scr_worker_current->parent, event); + return true; +} + static void scr_worker_report_error(void) { ScrCaught *caught = scr_exc_take(); ScrDyn *value = scr_caught_to_dyn(caught); - ScrMessage *message = scr_message_encode(value); + if (scr_context_report_rejection) { + ScrDyn *error = scr_unhandled_rejection_error(value); + scr_dyn_release(value); + value = error; + } + ScrMessage *message = value ? scr_message_encode_uncaught(value) : NULL; + if (!message && value) { + /* Node falls back to the plain clone when a property does not clone. */ + scr_exc_clear(); + message = scr_message_encode(value); + } scr_dyn_release(value); scr_caught_release(caught); if (!message) { @@ -230,10 +319,16 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { worker->environment = NULL; scr_worker_current = worker; scr_context_report_error = scr_worker_report_error; + if (worker->capture & 3) scr_stdio_capture = scr_worker_capture; + if (worker->capture & 4) { + scr_stdin_fed = true; + scr_stdin_fed_attach = atomic_load_explicit(&scr_worker_stdin_attach, memory_order_relaxed); + } scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_ONLINE, worker->id, 0, NULL)); int code = worker->entry(worker->root); scr_context_note_script_end(); /* when the program frees nothing at exit */ scr_mailbox_close(worker->inbox); + scr_stdin_fed_clear(); /* chunks the worker never read */ scr_context_cleanup(); if (stats) { uint64_t now = scr_hrtime_ns(); @@ -241,6 +336,7 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { (unsigned long long)worker->id, (double)(started_ns - worker->spawned_ns) / 1e6, (double)(scr_context_script_end_ns() - started_ns) / 1e6, (double)(now - scr_context_script_end_ns()) / 1e6); } + scr_stdio_capture = NULL; scr_worker_current = NULL; scr_exc_alert_thread_exit(); #ifdef SCR_EXC_FLAG @@ -275,8 +371,10 @@ static void *scr_worker_thread_main(void *data) { #endif ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, - ScrMessage *data, ScrMailbox *parent, - ScrArr *arguments) { + ScrMessage *data, bool environment_data, + ScrMailbox *parent, ScrArr *arguments, + size_t stack_size, const ScrStr *name, + unsigned capture) { ScrWorkerThread *worker = calloc(1, sizeof(*worker)); if (!worker) scr_trap("scriptc: out of memory\n"); atomic_init(&worker->references, 2); /* owner and native thread */ @@ -292,7 +390,13 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, worker->entry = entry; worker->root = root; worker->data = data; + worker->environment_data = environment_data; + worker->capture = capture; worker->environment = scr_context_env_capture(); + worker->name = malloc(sizeof(ScrStr) + name->len + 1); + if (!worker->name) scr_trap("scriptc: out of memory\n"); + memcpy(worker->name, name, sizeof(ScrStr) + name->len + 1); + worker->name->rc = SIZE_MAX; worker->parent = scr_mailbox_retain(parent); if (arguments) { if (arguments->len > INT_MAX) scr_trap("scriptc: too many worker arguments\n"); @@ -311,16 +415,24 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, if (!worker->inbox) goto failed; if (scr_worker_stats()) worker->spawned_ns = scr_hrtime_ns(); #ifdef _WIN32 - worker->thread = (HANDLE)_beginthreadex(NULL, 0, scr_worker_thread_main, worker, 0, NULL); + worker->thread = (HANDLE)_beginthreadex(NULL, stack_size > UINT_MAX ? UINT_MAX : (unsigned)stack_size, + scr_worker_thread_main, worker, 0, NULL); if (!worker->thread) goto failed; #else /* An explicit size keeps recursion depth independent of the C library's * default thread stack (as small as 128 KiB with musl and 512 KiB on - * macOS): workers get the 8 MiB a main thread usually has. Pages are - * committed only as the stack grows. */ + * macOS): workers get the 8 MiB a main thread usually has, or + * resourceLimits.stackSizeMb. Pages are committed only as the stack + * grows. */ pthread_attr_t attr; if (pthread_attr_init(&attr) != 0) goto failed; - (void)pthread_attr_setstacksize(&attr, (size_t)8 << 20); + if (!stack_size) stack_size = (size_t)8 << 20; + long page = sysconf(_SC_PAGESIZE); + if (page > 0 && stack_size % (size_t)page) stack_size += (size_t)page - stack_size % (size_t)page; + if (pthread_attr_setstacksize(&attr, stack_size) != 0) { + pthread_attr_destroy(&attr); + goto failed; + } int started = pthread_create(&worker->thread, &attr, scr_worker_thread_main, worker); pthread_attr_destroy(&attr); if (started != 0) goto failed; @@ -329,6 +441,7 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, failed: for (int i = 0; i < worker->argc; i++) free(worker->argv[i]); free(worker->argv); + free(worker->name); scr_context_env_free(worker->environment); scr_mailbox_release(worker->inbox); scr_mailbox_release(worker->parent); diff --git a/packages/runtime/src/scr_worker.h b/packages/runtime/src/scr_worker.h index 008159fa2..ba27f69af 100644 --- a/packages/runtime/src/scr_worker.h +++ b/packages/runtime/src/scr_worker.h @@ -18,24 +18,47 @@ enum { SCR_WORKER_WAKE, SCR_WORKER_STDOUT, SCR_WORKER_STDERR, + SCR_WORKER_STDIN, /* parent to worker: a worker.stdin chunk, or its end (no data) */ }; -/* Consumes data, including on failure. Borrows the parent's mailbox. */ +/* Consumes data, including on failure. Borrows the parent's mailbox. With + * environment_data, data encodes [workerData, environment entries]. A + * stack_size of 0 selects the default 8 MiB thread stack. capture (1 + * stdout, 2 stderr) posts the worker's writes to those descriptors to the + * parent as SCR_WORKER_STDOUT/STDERR events; 4 (stdin) has the worker's + * process.stdin read what scr_worker_thread_post_stdin sends. */ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, - ScrMessage *data, ScrMailbox *parent, - ScrArr *arguments); + ScrMessage *data, bool environment_data, + ScrMailbox *parent, ScrArr *arguments, + size_t stack_size, const ScrStr *name, + unsigned capture); ScrWorkerThread *scr_worker_thread_retain(ScrWorkerThread *worker); void scr_worker_thread_release(ScrWorkerThread *worker); /* Join is owner-only and idempotent. The caller holds a reference. */ void scr_worker_thread_join(ScrWorkerThread *worker); void scr_worker_thread_terminate(ScrWorkerThread *worker); uint64_t scr_worker_thread_id(const ScrWorkerThread *worker); +/* The Worker's threadName, owned by the thread record and freed with it: + * callers copy it into the strings they return. */ +const ScrStr *scr_worker_thread_name(const ScrWorkerThread *worker); /* Posting consumes the message even after the destination has closed. */ bool scr_worker_thread_post(ScrWorkerThread *worker, ScrMessage *message); +/* A worker.stdin chunk (data copied), or with end the end of the input. + * False once the worker's inbox has closed. */ +bool scr_worker_thread_post_stdin(ScrWorkerThread *worker, const void *data, size_t len, bool end); +/* The worker event unit's function that starts a worker's event dispatch, + * which queues the stdin chunks. The unit stores it before it starts a + * worker with the stdin option; that worker installs it as + * scr_stdin_fed_attach. */ +void scr_worker_set_stdin_attach(void (*attach)(void)); bool scr_worker_post_parent(ScrMessage *message); void scr_worker_port_close(void); /* Current-thread access: inbox is borrowed, data is a retained local value. */ ScrMailbox *scr_worker_inbox(void); +/* The parent's environment data entries ([key, value, ...], +1) on the + * first call in a worker; NULL afterwards, in the main thread, when the + * parent had none, and after a throw. */ +ScrDyn *scr_worker_take_environment(void); void scr_loop_set_workers(bool (*pending)(void), bool (*ready)(void), void (*dispatch)(void), int (*pollfd)(void), void (*wait)(double)); /* After a dispatch: true keeps the loop pass delivering (a MessagePort poll diff --git a/packages/runtime/src/scr_worker.test.c b/packages/runtime/src/scr_worker.test.c index 12065b43d..a50fd2fe2 100644 --- a/packages/runtime/src/scr_worker.test.c +++ b/packages/runtime/src/scr_worker.test.c @@ -95,7 +95,9 @@ int main(void) { ScrMessage *wire = scr_message_encode(input); scr_dyn_release(input); assert(wire && !scr_exc_pending()); - workers[i] = scr_worker_thread_start(entry, i, wire, mailbox, NULL); + ScrStr *name = scr_str_new("WorkerThread", 12); + workers[i] = scr_worker_thread_start(entry, i, wire, false, mailbox, NULL, 0, name, 0); + scr_str_release(name); assert(workers[i] && !scr_exc_pending()); assert(scr_worker_thread_id(workers[i]) != 0); } diff --git a/packages/runtime/src/scr_worker.test.ts b/packages/runtime/src/scr_worker.test.ts index edcca4eaa..2d2cbcae2 100644 --- a/packages/runtime/src/scr_worker.test.ts +++ b/packages/runtime/src/scr_worker.test.ts @@ -28,6 +28,7 @@ test.skipIf(process.platform === "win32")( "scr_worker.c", "scr_mailbox.c", "scr_message.c", + "scr_async_dyn.c", ...EXECUTABLE_RUNTIME_SOURCES, ].map((name) => join(import.meta.dirname, name)), ...(process.platform === "linux" ? ["-lm"] : []), diff --git a/packages/runtime/src/scr_worker_events.c b/packages/runtime/src/scr_worker_events.c index 51fbce59f..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; @@ -25,6 +26,21 @@ typedef struct ScrWorkerView { uint64_t close_gen; /* the poll generation close() ran in */ size_t phase_left; /* messages this poll phase still delivers */ bool phase_close; /* the other end's close, delivered this phase */ + /* A BroadcastChannel: its name and EventTarget core; its channel's end 0 + * queues what the other instances with the name post. */ + bool broadcast; + ScrStr *name; + ScrEventTargetCore *et; + /* A Worker's stdout/stderr streams (the stdout and stderr options) and + * their checked-dynamic boxes, made on first read */ + ScrStream *stdio[2]; + ScrDyn *stdio_box[2]; + bool stdio_captured[2]; + /* worker.stdin (the stdin option): a Writable whose chunks go to the + * worker's process.stdin, and its box */ + ScrStream *stdin_stream; + ScrDyn *stdin_box; + bool stdin_captured; struct ScrWorkerView *next; } ScrWorkerView; @@ -47,16 +63,90 @@ static const char *const scr_worker_method_names[] = { "listenerCount", "close", "start", }; static SCR_TL ScrDyn *scr_worker_methods[sizeof scr_worker_method_names / sizeof scr_worker_method_names[0]]; +static const char *const scr_broadcast_method_names[] = { + "postMessage", "close", "ref", "unref", "addEventListener", "removeEventListener", "dispatchEvent", +}; +static SCR_TL ScrDyn *scr_broadcast_methods[sizeof scr_broadcast_method_names / sizeof scr_broadcast_method_names[0]]; + + +/* The stream unit's half of worker.stdout, worker.stderr and + * worker.stdin, installed by main when the program reads them as streams + * (scr_worker_stdio_install; a box the program has no type for is NULL). */ +static SCR_TL const ScrChildStreamOps *scr_worker_stdio_ops; +static SCR_TL ScrDyn *(*scr_worker_stdio_box)(void *); +static SCR_TL ScrDyn *(*scr_worker_stdin_box)(void *); + +void scr_worker_stdio_install(const ScrChildStreamOps *ops, ScrDyn *(*readable_box)(void *), + ScrDyn *(*writable_box)(void *)) { + scr_worker_stdio_ops = ops; + scr_worker_stdio_box = readable_box; + scr_worker_stdin_box = writable_box; +} + +/* A Worker with the stdout, stderr or stdin option in a program that has + * no typed Readable or Writable box: the streams go through the stream + * unit's live view. */ +void scr_worker_stdio_default(const ScrChildStreamOps *ops, ScrDyn *(*view)(void *)) { + if (!scr_worker_stdio_ops) scr_worker_stdio_ops = ops; + if (!scr_worker_stdio_box) scr_worker_stdio_box = view; + if (!scr_worker_stdin_box) scr_worker_stdin_box = view; +} + +/* worker.stdin: each chunk written goes to the worker as an + * SCR_WORKER_STDIN event, and end() sends the end of the input. The + * callbacks hold the thread record, not the view (the view owns the + * stream). Writes after the worker exited are dropped. */ +static void *scr_worker_thread_retain_v(void *thread) { return scr_worker_thread_retain(thread); } +static void scr_worker_thread_release_v(void *thread) { scr_worker_thread_release(thread); } + +static void scr_worker_stdin_write(ScrClosure *cb, ScrStream *stream, ScrBytes *chunk) { + ScrWorkerThread *thread = scr_box_get_ref(cb->caps[0]); /* +1 */ + /* an empty chunk pushes nothing in Node's process.stdin */ + if (chunk->len) scr_worker_thread_post_stdin(thread, chunk->data, chunk->len, false); + scr_worker_thread_release(thread); + scr_worker_stdio_ops->write_done(stream, NULL); +} + +static void scr_worker_stdin_final(ScrClosure *cb, ScrStream *stream) { + ScrWorkerThread *thread = scr_box_get_ref(cb->caps[0]); /* +1 */ + scr_worker_thread_post_stdin(thread, NULL, 0, true); + scr_worker_thread_release(thread); + scr_worker_stdio_ops->final_done(stream, NULL); +} + +static ScrClosure *scr_worker_stdin_callback(ScrWorkerThread *thread, void *fire) { + ScrClosure *cb = scr_closure_new(fire, 1); + cb->caps[0] = scr_box_new_obj(scr_worker_thread_retain_v, scr_worker_thread_release_v, NULL); + scr_box_set_ref(cb->caps[0], scr_worker_thread_retain(thread)); + return cb; +} + +static ScrStream *scr_worker_stdin_new(ScrWorkerThread *thread) { + return scr_worker_stdio_ops->writable( + scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_write), scr_worker_stdin_write, + scr_worker_stdin_callback(thread, (void *)scr_worker_stdin_final), scr_worker_stdin_final); +} static void scr_worker_view_trace(void *ptr, ScrTraceVisit visit, void *ctx) { - visit(((ScrWorkerView *)ptr)->events, ctx); - visit(((ScrWorkerView *)ptr)->termination, 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); + } + visit(view->stdin_stream, ctx); + visit(view->stdin_box, ctx); } static void scr_worker_view_gcfree(void *ptr) { ScrWorkerView *view = ptr; scr_worker_thread_release(view->thread); + if (view->broadcast && view->channel && !view->closed) scr_broadcast_leave(view->channel); scr_channel_release(view->channel); + if (view->name) scr_str_release(view->name); + if (view->et) scr_et_broadcast_ops->core_free(view->et); while (view->queued_head) { ScrMailEvent *event = view->queued_head; view->queued_head = event->next; @@ -70,6 +160,13 @@ 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]); + } + if (view->stdin_stream) scr_worker_stdio_ops->release(view->stdin_stream); + scr_dyn_release(view->stdin_box); scr_worker_view_gcfree(ptr); } @@ -125,6 +222,70 @@ static bool scr_worker_empty_transfer(const ScrDyn *value) { return value->kind == SCR_DYN_UNDEF || (value->kind == SCR_DYN_ARR && value->v.arr.len == 0); } +/* ── markAsUntransferable ─────────────────────────────────────────────── */ + +/* An ArrayBuffer's mark lives on its storage (Buffer pool slabs carry it + * from creation, as in Node); other marked objects stay listed, retained, + * for this thread's lifetime. A typed array shares its owner's storage with + * its ArrayBuffer, so views are listed by their own identity. */ +static SCR_TL ScrDyn **scr_untransferable; +static SCR_TL size_t scr_untransferable_count; +static SCR_TL size_t scr_untransferable_capacity; + +static void scr_untransferable_cleanup(void) { + for (size_t i = 0; i < scr_untransferable_count; i++) scr_dyn_release(scr_untransferable[i]); + free(scr_untransferable); + scr_untransferable = NULL; + scr_untransferable_count = scr_untransferable_capacity = 0; +} + +static bool scr_untransferable_same(const ScrDyn *a, const ScrDyn *b) { + if (a->kind != b->kind) return false; + switch (a->kind) { + case SCR_DYN_BYTES: return a->v.bytes == b->v.bytes; + case SCR_DYN_HANDLE: return a->v.handle.ptr == b->v.handle.ptr; + case SCR_DYN_TYPED_REF: return a->v.typed_ref.ptr == b->v.typed_ref.ptr; + default: return scr_dyn_strict_eq(a, b); + } +} + +/* typeof value is "object" or "function", and value is not null */ +static bool scr_untransferable_object(const ScrDyn *value) { + switch (value->kind) { + case SCR_DYN_UNDEF: case SCR_DYN_NULL: case SCR_DYN_BOOL: case SCR_DYN_NUM: + case SCR_DYN_STR: case SCR_DYN_BIGINT: case SCR_DYN_SYMBOL: return false; + default: return true; + } +} + +bool scr_worker_is_marked_untransferable(ScrDyn *value) { + if (!scr_untransferable_object(value)) return false; + if (scr_array_buffer_is(value) || scr_shared_array_buffer_is(value)) + return ((ScrBytes *)value->v.handle.ptr)->untransferable; + for (size_t i = 0; i < scr_untransferable_count; i++) + if (scr_untransferable_same(scr_untransferable[i], value)) return true; + return false; +} + +/* markAsUntransferable(value): primitives are left as they are */ +void scr_worker_mark_untransferable(ScrDyn *value) { + if (!scr_untransferable_object(value) || scr_worker_is_marked_untransferable(value)) return; + if (scr_array_buffer_is(value) || scr_shared_array_buffer_is(value)) { + ((ScrBytes *)value->v.handle.ptr)->untransferable = true; + return; + } + if (scr_untransferable_count == scr_untransferable_capacity) { + if (!scr_untransferable_capacity && scr_context_atexit(scr_untransferable_cleanup) != 0) + scr_trap("scriptc: out of memory\n"); + size_t capacity = scr_untransferable_capacity ? scr_untransferable_capacity * 2 : 8; + ScrDyn **values = realloc(scr_untransferable, capacity * sizeof *values); + if (!values) scr_trap("scriptc: out of memory\n"); + scr_untransferable = values; + scr_untransferable_capacity = capacity; + } + scr_untransferable[scr_untransferable_count++] = scr_dyn_retain(value); +} + /* postMessage's transfer argument: an array, or { transfer } (+1, or NULL * when empty) */ static ScrDyn *scr_worker_transfer_list(ScrDyn *arg) { @@ -145,6 +306,14 @@ static ScrDyn *scr_worker_transfer_list(ScrDyn *arg) { return result; } if (value->kind == SCR_DYN_ARR && value->v.arr.len > 0) { + for (size_t i = 0; i < value->v.arr.len; i++) { + ScrDyn *item = value->v.arr.items[i]; + if (item && scr_worker_is_marked_untransferable(item)) { + scr_dyn_release(own); + scr_throw_domex("DataCloneError", "Cannot transfer object of unsupported type."); + return NULL; + } + } ScrDyn *result = scr_dyn_retain(value); scr_dyn_release(own); return result; @@ -291,10 +460,71 @@ 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)) return scr_dyn_new_num(view->closed ? -1 : (double)scr_worker_thread_id(view->thread)); + /* Node reports the running worker's V8 heap configuration; an exited + * worker answers {}. */ + if (!view->port && length == 5 && !memcmp(key, "stdin", 5)) { + if (!view->stdin_stream) return scr_dyn_new_null(); + if (!view->stdin_box) view->stdin_box = scr_worker_stdin_box(view->stdin_stream); + return scr_dyn_retain(view->stdin_box); + } + if (!view->port && length == 6 && (!memcmp(key, "stdout", 6) || !memcmp(key, "stderr", 6))) { + int i = key[3] == 'e' ? 1 : 0; + if (!view->stdio_captured[i]) + return scr_worker_refusal(i ? "worker.stderr without the stderr option is not supported yet" + : "worker.stdout without the stdout option is not supported yet"); + if (!view->stdio[i]) return scr_dyn_new_null(); + if (!view->stdio_box[i]) view->stdio_box[i] = scr_worker_stdio_box(view->stdio[i]); + return scr_dyn_retain(view->stdio_box[i]); + } + if (!view->port && length == 10 && !memcmp(key, "threadName", 10)) { + if (view->closed) return scr_dyn_new_null(); + /* a copy: the thread record's name is freed with the record, which + * a string value can outlive */ + const ScrStr *name = scr_worker_thread_name(view->thread); + ScrStr *copy = scr_str_new(name->data, name->len); + ScrDyn *value = scr_dyn_new_str(copy); + scr_str_release(copy); + return value; + } + if (!view->port && length == 14 && !memcmp(key, "resourceLimits", 14)) + return view->closed ? scr_dyn_new_obj() : scr_worker_refusal("worker.resourceLimits is not supported yet"); for (size_t i = 0; i < sizeof scr_worker_methods / sizeof scr_worker_methods[0]; i++) { const char *name = scr_worker_method_names[i]; if (strlen(name) != length || memcmp(name, key, length)) continue; @@ -312,6 +542,8 @@ static ScrDyn *scr_worker_view_get(void *ptr, const char *key, size_t length) { } static bool scr_workers_pending(void) { + /* a worker's process.stdin waiting for worker.stdin chunks */ + if (scr_stdin_fed && scr_stdin_fed_waiting && scr_stdin_fed_waiting()) return true; for (ScrWorkerView *view = scr_worker_views; view; view = view->next) if (view->referenced) return true; for (ScrWorkerView *view = scr_channel_ports; view; view = view->next) @@ -412,7 +644,13 @@ static bool scr_channel_dispatch(void) { } scr_worker_view_retain(view); ScrDyn *argument = event->message ? scr_message_decode(event->message) : NULL; - if (argument) scr_worker_port_emit(view, "message", argument); + if (argument && view->broadcast) { + ScrDyn *self = scr_dyn_new_handle(view, SCR_DYNH_BROADCAST_CHANNEL); + scr_et_broadcast_ops->dispatch_message(view->et, self, argument); + scr_dyn_release(self); + } else if (argument) { + scr_worker_port_emit(view, "message", argument); + } scr_dyn_release(argument); scr_mail_event_free(event); scr_worker_view_release(view); @@ -483,6 +721,31 @@ static void scr_worker_dispatch(void) { } else event = scr_mailbox_take(scr_worker_events); if (!event) return; if (event->kind == SCR_WORKER_WAKE) { scr_mail_event_free(event); return; } + if (event->kind == SCR_WORKER_STDIN) { + ScrBytes *chunk = NULL; + if (event->data) { + chunk = scr_bytes_new(SCR_BYTES_U8, (double)event->length); + memcpy(chunk->data, event->data, event->length); + chunk->is_buffer = true; + } + scr_stdin_fed_push(chunk); + scr_mail_event_free(event); + return; + } + if (event->kind == SCR_WORKER_STDOUT || event->kind == SCR_WORKER_STDERR) { + ScrWorkerView *source = scr_worker_views; + while (source && scr_worker_thread_id(source->thread) != event->source) source = source->next; + ScrStream *stream = source ? source->stdio[event->kind == SCR_WORKER_STDERR] : NULL; + if (stream) { + ScrBytes *chunk = scr_bytes_new(SCR_BYTES_U8, (double)event->length); + memcpy(chunk->data, event->data, event->length); + chunk->is_buffer = true; + scr_worker_stdio_ops->push(stream, chunk); + scr_bytes_release(chunk); + } + scr_mail_event_free(event); + return; + } ScrWorkerView *view = scr_worker_port; if (event->source) { view = scr_worker_views; @@ -508,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; @@ -522,6 +787,11 @@ static void scr_worker_dispatch(void) { scr_dyn_this_pop(); scr_str_release(name); } + /* Node ends worker.stdout and worker.stderr as the worker exits: their + * 'end' events follow 'exit'. */ + if (event->kind == SCR_WORKER_EXIT) + for (int i = 0; i < 2; i++) + if (view->stdio[i]) scr_worker_stdio_ops->end(view->stdio[i]); scr_dyn_release(argument); scr_worker_view_release(view); scr_mail_event_free(event); @@ -547,17 +817,36 @@ static void scr_worker_events_cleanup(void) { scr_channel_ports = view->next; view->next = NULL; if (view->channel) scr_channel_set_owner(view->channel, view->side, NULL, SCR_WORKER_WAKE); + /* a BroadcastChannel's name and listeners go now: program values + * may still hold the handle past this teardown */ + if (view->broadcast) { + if (!view->closed) { + view->closed = true; + scr_broadcast_leave(view->channel); + } + if (view->name) scr_str_release(view->name); + view->name = NULL; + if (view->et) scr_et_broadcast_ops->core_free(view->et); + view->et = NULL; + } scr_worker_view_release(view); } for (size_t i = 0; i < sizeof scr_worker_methods / sizeof scr_worker_methods[0]; i++) { scr_dyn_release(scr_worker_methods[i]); scr_worker_methods[i] = NULL; } + for (size_t i = 0; i < sizeof scr_broadcast_methods / sizeof scr_broadcast_methods[0]; i++) { + scr_dyn_release(scr_broadcast_methods[i]); + scr_broadcast_methods[i] = NULL; + } scr_mailbox_release(scr_worker_events); scr_worker_events = NULL; } static void scr_port_ops_install(void); +static bool scr_worker_events_install(void); + +static void scr_worker_stdin_attach(void) { (void)scr_worker_events_install(); } static bool scr_worker_events_install(void) { if (scr_worker_events) return true; @@ -568,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, @@ -647,6 +937,151 @@ ScrDyn *scr_worker_message_channel(void) { return object; } +/* ── BroadcastChannel ───────────────────────────────────────────────── */ + + +static ScrDyn *scr_broadcast_invoke(void *ptr, ScrDyn *self, const char *method, + ScrDyn *const *args, size_t argc, const char *what) { + (void)what; + ScrWorkerView *view = ptr; + if (!strcmp(method, "postMessage")) { + if (view->closed) { + scr_throw_domex("InvalidStateError", "BroadcastChannel is closed."); + return NULL; + } + if (!argc) { + static const char message[] = "The \"message\" argument must be specified"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_MISSING_ARGS"); + return NULL; + } + ScrMessage *first = scr_message_encode(args[0]); + if (!first) return NULL; + ScrChannel **peers = NULL; + size_t n = scr_broadcast_peers(view->name->data, view->name->len, view->channel, &peers); + for (size_t i = 0; i < n; i++) { + ScrMessage *message = i == 0 ? first : scr_message_encode(args[0]); + if (i == 0) first = NULL; + if (message) scr_channel_post(peers[i], 1, message, SCR_WORKER_MESSAGE, SCR_WORKER_WAKE); + scr_channel_release(peers[i]); + } + free(peers); + scr_message_free(first); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); + } + if (!strcmp(method, "close")) { + if (!view->closed) { + view->closed = true; + scr_broadcast_leave(view->channel); + scr_channel_drop(view->channel, 0); + } + return scr_dyn_retain(scr_dyn_undefined()); + } + if (!strcmp(method, "ref") || !strcmp(method, "unref")) { + view->referenced = !strcmp(method, "ref"); + return scr_dyn_retain(self); + } + ScrDyn *out = NULL; + if (scr_et_broadcast_ops->method(view->et, self, method, args, argc, &out)) return out; + return scr_worker_refusal("This BroadcastChannel method is not supported yet"); +} + +static ScrDyn *scr_broadcast_method_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + ScrDyn *self = scr_dyn_this_get(); + ScrDyn *result = NULL; + if (self->kind != SCR_DYN_HANDLE || self->v.handle.tag != SCR_DYNH_BROADCAST_CHANNEL) { + static const char message[] = "Illegal invocation"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_THIS"); + } else { + size_t method = (size_t)scr_box_get_f64(closure->caps[0]); + result = scr_broadcast_invoke(self->v.handle.ptr, self, scr_broadcast_method_names[method], args, + argc, "BroadcastChannel method"); + } + scr_dyn_release(self); + return result; +} + +static ScrDyn *scr_broadcast_get(void *ptr, const char *key, size_t length) { + ScrWorkerView *view = ptr; + if (length == 4 && !memcmp(key, "name", 4)) return scr_dyn_new_str(view->name); + if ((length == 9 && !memcmp(key, "onmessage", 9)) || + (length == 14 && !memcmp(key, "onmessageerror", 14))) { + char type[16]; + memcpy(type, key + 2, length - 2); + type[length - 2] = '\0'; + return scr_et_broadcast_ops->handler_get(view->et, type); + } + for (size_t i = 0; i < sizeof scr_broadcast_methods / sizeof scr_broadcast_methods[0]; i++) { + const char *name = scr_broadcast_method_names[i]; + if (strlen(name) != length || memcmp(name, key, length)) continue; + if (!scr_broadcast_methods[i]) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_F64); + scr_box_set_f64(closure->caps[0], (double)i); + scr_broadcast_methods[i] = scr_dyn_new_func(closure, scr_broadcast_method_call, 0, "", name); + } + return scr_dyn_retain(scr_broadcast_methods[i]); + } + return NULL; +} + +static bool scr_broadcast_set(void *ptr, const char *key, size_t length, const ScrDyn *value) { + ScrWorkerView *view = ptr; + if ((length == 9 && !memcmp(key, "onmessage", 9)) || + (length == 14 && !memcmp(key, "onmessageerror", 14))) { + char type[16]; + memcpy(type, key + 2, length - 2); + type[length - 2] = '\0'; + scr_et_broadcast_ops->handler_set(view->et, type, value); + return true; + } + return false; +} + +/* new BroadcastChannel(name): open and referenced until close() or + * unref(); args is the argument vector as one dyn array */ +ScrDyn *scr_worker_broadcast_channel(ScrDyn *args) { + if (!scr_worker_events_install()) return NULL; + if (args->kind != SCR_DYN_ARR || args->v.arr.len == 0) { + static const char message[] = "The \"name\" argument must be specified"; + scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_MISSING_ARGS"); + return NULL; + } + ScrStr *name = scr_dyn_string_coerce_js(args->v.arr.items[0]); + if (!name) return NULL; + static SCR_TL bool installed = false; + if (!installed) { + installed = true; + static const ScrDynHandleOps broadcast_ops = { + .cls = "BroadcastChannel", .retain = scr_worker_view_retain, .release = scr_worker_view_release, + .invoke = scr_broadcast_invoke, .get = scr_broadcast_get, .set = scr_broadcast_set, + }; + scr_dyn_handle_install(SCR_DYNH_BROADCAST_CHANNEL, &broadcast_ops); + } + ScrChannel *channel = scr_channel_new(); + ScrWorkerView *view = scr_channel_port_new(channel, 0); + scr_channel_release(channel); + view->broadcast = true; + view->name = name; + view->et = scr_et_broadcast_ops->core_new("BroadcastChannel"); + view->started = true; + view->referenced = true; + scr_broadcast_join(name->data, name->len, view->channel); + return scr_dyn_new_handle(view, SCR_DYNH_BROADCAST_CHANNEL); +} + +/* A port's next queued message. For parentPort: the messages held until + * start(), then the inbox's next message from the parent (events from this + * thread's own workers stay queued). */ +static ScrMailEvent *scr_port_take(ScrWorkerView *view) { + if (view->channel) return scr_channel_take(view->channel, view->side); + ScrMailEvent *event = view->queued_head; + if (!event) return scr_mailbox_take_kind(scr_worker_events, SCR_WORKER_MESSAGE, 0); + view->queued_head = event->next; + if (!view->queued_head) view->queued_tail = NULL; + event->next = NULL; + return event; +} + /* receiveMessageOnPort(port): { message } of the next queued message, or * undefined */ ScrDyn *scr_worker_receive_message_on_port(ScrDyn *port) { @@ -656,8 +1091,7 @@ ScrDyn *scr_worker_receive_message_on_port(ScrDyn *port) { scr_throw_error_msg_code(SCR_ERR_TYPE, message, sizeof message - 1, "ERR_INVALID_ARG_TYPE"); return NULL; } - if (!view->channel) return scr_worker_refusal("receiveMessageOnPort on parentPort is not supported yet"); - ScrMailEvent *event = view->closed || view->detached ? NULL : scr_channel_take(view->channel, view->side); + ScrMailEvent *event = view->closed || view->detached ? NULL : scr_port_take(view); if (!event) return scr_dyn_retain(scr_dyn_undefined()); ScrDyn *value = event->message ? scr_message_decode(event->message) : NULL; scr_mail_event_free(event); @@ -667,6 +1101,124 @@ ScrDyn *scr_worker_receive_message_on_port(ScrDyn *port) { return object; } +/* ── environment data ─────────────────────────────────────────────────── */ + +/* setEnvironmentData's map for this thread: keys compare with + * SameValueZero, and a deleted key re-added goes last. A worker starts + * from a clone of its parent's entries taken by new Worker(). */ +typedef struct { + ScrDyn *key; + ScrDyn *value; +} ScrEnvironmentEntry; +static SCR_TL ScrEnvironmentEntry *scr_environment; +static SCR_TL size_t scr_environment_count; +static SCR_TL size_t scr_environment_capacity; +static SCR_TL bool scr_environment_ready; + +static void scr_environment_cleanup(void) { + for (size_t i = 0; i < scr_environment_count; i++) { + scr_dyn_release(scr_environment[i].key); + scr_dyn_release(scr_environment[i].value); + } + free(scr_environment); + scr_environment = NULL; + scr_environment_count = scr_environment_capacity = 0; + scr_environment_ready = false; +} + +static void scr_environment_append(ScrDyn *key, ScrDyn *value) { + if (scr_environment_count == scr_environment_capacity) { + size_t capacity = scr_environment_capacity ? scr_environment_capacity * 2 : 4; + ScrEnvironmentEntry *entries = realloc(scr_environment, capacity * sizeof *entries); + if (!entries) scr_trap("scriptc: out of memory\n"); + scr_environment = entries; + scr_environment_capacity = capacity; + } + scr_environment[scr_environment_count++] = (ScrEnvironmentEntry){key, value}; +} + +/* This thread's entries, the inherited ones decoded on first use. */ +static bool scr_environment_load(void) { + if (scr_environment_ready) return true; + ScrDyn *inherited = scr_worker_take_environment(); + if (scr_exc_pending()) return false; + scr_environment_ready = true; + if (scr_context_atexit(scr_environment_cleanup) != 0) scr_trap("scriptc: out of memory\n"); + for (size_t i = 0; inherited && i + 1 < inherited->v.arr.len; i += 2) + scr_environment_append(scr_dyn_arr_at(inherited, (double)i), scr_dyn_arr_at(inherited, (double)i + 1)); + scr_dyn_release(inherited); + return true; +} + +static bool scr_environment_key_eq(const ScrDyn *a, const ScrDyn *b) { + if (a->kind == SCR_DYN_NUM && b->kind == SCR_DYN_NUM) + return a->v.num == b->v.num || (a->v.num != a->v.num && b->v.num != b->v.num); + if (a->kind == SCR_DYN_TYPED_REF && b->kind == SCR_DYN_TYPED_REF) + return a->v.typed_ref.ptr == b->v.typed_ref.ptr; + return scr_dyn_strict_eq(a, b); +} + +static size_t scr_environment_find(const ScrDyn *key) { + for (size_t i = 0; i < scr_environment_count; i++) + if (scr_environment_key_eq(scr_environment[i].key, key)) return i; + return SIZE_MAX; +} + +/* setEnvironmentData(key, value): undefined deletes the key. */ +void scr_worker_set_environment_data(ScrDyn *key, ScrDyn *value) { + if (!scr_environment_load()) return; + size_t index = scr_environment_find(key); + if (value->kind == SCR_DYN_UNDEF) { + if (index == SIZE_MAX) return; + scr_dyn_release(scr_environment[index].key); + scr_dyn_release(scr_environment[index].value); + memmove(&scr_environment[index], &scr_environment[index + 1], + (scr_environment_count - index - 1) * sizeof *scr_environment); + scr_environment_count--; + return; + } + if (index != SIZE_MAX) { + scr_dyn_release(scr_environment[index].value); + scr_environment[index].value = scr_dyn_retain(value); + return; + } + scr_environment_append(scr_dyn_retain(key), scr_dyn_retain(value)); +} + +ScrDyn *scr_worker_get_environment_data(ScrDyn *key) { + if (!scr_environment_load()) return NULL; + size_t index = scr_environment_find(key); + return scr_dyn_retain(index == SIZE_MAX ? scr_dyn_undefined() : scr_environment[index].value); +} + +/* new Worker()'s start message: workerData alone, or [workerData, + * [key, value, ...]] when this thread has environment data, cloned in one + * message as Node clones both in one postMessage. */ +static ScrMessage *scr_worker_start_message(ScrDyn *data, ScrDyn *transfer, bool *environment_data) { + *environment_data = false; + if (!scr_environment_load()) return NULL; + if (!scr_environment_count) return scr_message_encode_transfer(data, transfer); + ScrDyn *entries = scr_dyn_new_arr(); + for (size_t i = 0; i < scr_environment_count; i++) { + scr_dyn_arr_push(entries, scr_dyn_retain(scr_environment[i].key)); + scr_dyn_arr_push(entries, scr_dyn_retain(scr_environment[i].value)); + } + ScrDyn *start = scr_dyn_new_arr(); + scr_dyn_arr_push(start, scr_dyn_retain(data)); + scr_dyn_arr_push(start, entries); + ScrMessage *message = scr_message_encode_transfer(start, transfer); + scr_dyn_release(start); + *environment_data = message != NULL; + return message; +} + +/* worker_threads.resourceLimits: {} in the main thread, as Node; a worker's + * values are its V8 heap configuration. */ +ScrDyn *scr_worker_resource_limits(void) { + if (scr_context_is_main()) return scr_dyn_new_obj(); + return scr_worker_refusal("resourceLimits in a worker is not supported yet"); +} + ScrDyn *scr_worker_parent_port(void) { if (scr_context_is_main()) return scr_dyn_new_null(); if (!scr_worker_events_install()) return NULL; @@ -709,11 +1261,42 @@ static ScrArr *scr_worker_arguments(ScrStr *filename, ScrDyn *argv) { return arguments; } +/* resourceLimits: Node reads the four numbers when the option is an object + * and ignores other values. stackSizeMb sizes the thread's stack (at least + * 192 KiB, as Node); the V8 heap limits are refused. */ +static bool scr_worker_stack_size(ScrDyn *limits, size_t *stack_size) { + if (limits->kind == SCR_DYN_TYPED_REF) { + ScrDyn *view = scr_dyn_typed_ref_materialize(limits); + bool ok = view && !scr_exc_pending() && scr_worker_stack_size(view, stack_size); + scr_dyn_release(view); + return ok; + } + if (limits->kind != SCR_DYN_OBJ) return true; + static const char *const heap[] = {"maxOldGenerationSizeMb", "maxYoungGenerationSizeMb", "codeRangeSizeMb"}; + for (size_t i = 0; i < sizeof heap / sizeof heap[0]; i++) { + const ScrDyn *value = scr_dyn_obj_get(limits, heap[i], strlen(heap[i])); + if (value && value->kind == SCR_DYN_NUM) { + scr_worker_refusal("Worker resourceLimits heap limits are not supported yet"); + return false; + } + } + const ScrDyn *megabytes = scr_dyn_obj_get(limits, "stackSizeMb", 11); + if (megabytes && megabytes->kind == SCR_DYN_NUM && megabytes->v.num > 0) { + double bytes = megabytes->v.num * 1048576.0; + if (bytes < 192.0 * 1024) bytes = 192.0 * 1024; + *stack_size = bytes >= (double)(SIZE_MAX / 2) ? SIZE_MAX / 2 : (size_t)bytes; + } + return true; +} + static ScrDyn *scr_worker_new_options(double root, ScrStr *filename, ScrDyn *options) { if (!scr_worker_events_install()) return NULL; ScrDyn *data = scr_dyn_undefined(); ScrDyn *argv = scr_dyn_undefined(); ScrDyn *transfer_list = scr_dyn_undefined(); + ScrDyn *limits = scr_dyn_undefined(); + ScrDyn *name = scr_dyn_undefined(); + unsigned capture = 0; if (options->kind != SCR_DYN_UNDEF) { if (options->kind != SCR_DYN_OBJ) { scr_dyn_arg_type_fail("options", "object", options); @@ -725,21 +1308,52 @@ static ScrDyn *scr_worker_new_options(double root, ScrStr *filename, ScrDyn *opt if (entry->key_len == 10 && !memcmp(entry->key, "workerData", 10)) data = entry->value; else if (entry->key_len == 4 && !memcmp(entry->key, "argv", 4)) argv = entry->value; else if (entry->key_len == 12 && !memcmp(entry->key, "transferList", 12)) transfer_list = entry->value; + else if (entry->key_len == 14 && !memcmp(entry->key, "resourceLimits", 14)) limits = entry->value; + else if (entry->key_len == 4 && !memcmp(entry->key, "name", 4)) name = entry->value; + else if (entry->key_len == 6 && !memcmp(entry->key, "stdout", 6)) capture |= scr_dyn_truthy(entry->value) ? 1 : 0; + else if (entry->key_len == 6 && !memcmp(entry->key, "stderr", 6)) capture |= scr_dyn_truthy(entry->value) ? 2 : 0; + else if (entry->key_len == 5 && !memcmp(entry->key, "stdin", 5)) capture |= scr_dyn_truthy(entry->value) ? 4 : 0; else return scr_worker_refusal("This Worker constructor option is not supported yet"); } } + size_t stack_size = 0; + if (!scr_worker_stack_size(limits, &stack_size)) return NULL; ScrArr *arguments = scr_worker_arguments(filename, argv); if (!arguments) return NULL; + /* Node: a truthy name must be a string, trimmed; otherwise WorkerThread */ + ScrStr *thread_name; + if (scr_dyn_truthy(name)) { + if (name->kind != SCR_DYN_STR) { + scr_arr_release(arguments); + scr_dyn_prop_type_fail("options.name", "of type string", name); + return NULL; + } + thread_name = scr_str_trim(name->v.str); + } else { + thread_name = scr_str_new("WorkerThread", 12); + } ScrDyn *transfer = scr_worker_transfer_list(transfer_list); - if (scr_exc_pending()) { scr_arr_release(arguments); return NULL; } - ScrMessage *wire = scr_message_encode_transfer(data, transfer); + if (scr_exc_pending()) { scr_arr_release(arguments); scr_str_release(thread_name); return NULL; } + bool environment_data; + ScrMessage *wire = scr_worker_start_message(data, transfer, &environment_data); + if (capture & 4) scr_worker_set_stdin_attach(scr_worker_stdin_attach); scr_dyn_release(transfer); - if (!wire) { scr_arr_release(arguments); return NULL; } - ScrWorkerThread *thread = scr_worker_thread_start(sc_worker_entry, (uint32_t)root, wire, scr_worker_events, arguments); + if (!wire) { scr_arr_release(arguments); scr_str_release(thread_name); return NULL; } + ScrWorkerThread *thread = scr_worker_thread_start(sc_worker_entry, (uint32_t)root, wire, environment_data, + scr_worker_events, arguments, stack_size, thread_name, + capture); + scr_str_release(thread_name); scr_arr_release(arguments); if (!thread) return NULL; ScrWorkerView *view = scr_worker_view_new(false); view->thread = thread; + for (int i = 0; i < 2; i++) { + view->stdio_captured[i] = (capture >> i) & 1; + /* without the stream unit nothing can read the stream: the output is dropped */ + if (view->stdio_captured[i] && scr_worker_stdio_ops) view->stdio[i] = scr_worker_stdio_ops->readable(); + } + view->stdin_captured = capture & 4; + if (view->stdin_captured && scr_worker_stdio_ops) view->stdin_stream = scr_worker_stdin_new(thread); view->next = scr_worker_views; scr_worker_views = view; return scr_dyn_new_handle(view, SCR_DYNH_WORKER); diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 9777a858c..1f86be80e 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -1884,6 +1884,136 @@ static void test_dense_slots(void) { scr_arr_release(nums); } +/* shift and splice(0, n) advance dense storage into head room instead of + * moving the tail; unshift reuses that room, growth gives it back, and a + * drained array starts over at the front of its allocation. */ +static void test_head_room(void) { +#ifdef SCR_RC_AUDIT + long strings0 = scr_str_live_count(); + long arrays0 = scr_arr_live_count(); +#endif + ScrArr *q = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 64; i++) scr_arr_push_f64(q, i); + uint64_t *block = q->data; + size_t cap = q->cap; + check_f64(scr_arr_shift_f64(q), 0, "shift answers the first element"); + check(q->data == block + 1 && q->cap == cap - 1 && q->len == 63, + "shift advances the storage instead of moving the tail"); + check(q->present == (uint8_t *)(block + cap) + 1, "states keep following the whole value area"); + check(q->filled == 63, "shift keeps the hole-free prefix"); + for (int i = 1; i < 40; i++) scr_arr_shift_f64(q); + check(q->data == block + 40 && q->len == 24 && scr_arr_get_f64(q, 0) == 40 && + scr_arr_get_f64(q, 23) == 63, + "repeated shifts index from the new front"); + check_f64(scr_arr_unshift_f64(q, -1), 25, "unshift into head room"); + check(q->data == block + 39 && scr_arr_get_f64(q, 0) == -1 && scr_arr_get_f64(q, 1) == 40, + "unshift reuses the room shift left"); + /* Appends past the window move the elements back to the front of the + * same allocation when the reclaimed room is large. */ + while (q->len < q->cap) scr_arr_push_f64(q, 100); + scr_arr_push_f64(q, 101); + check(q->data == block && q->cap == cap && q->len == 26, + "growth reuses head room in place"); + check(scr_arr_get_f64(q, 0) == -1 && scr_arr_get_f64(q, 1) == 40 && + scr_arr_get_f64(q, q->len - 1) == 101 && !scr_arr_has(q, (double)q->len), + "elements keep their order across the move"); + /* A drained array starts over at the front of its allocation. */ + while (q->len > 0) scr_arr_shift_f64(q); + check(q->data == block && q->cap == cap && q->filled == 0, "draining resets the head room"); + scr_arr_push_f64(q, 5); + check(q->data == block && scr_arr_get_f64(q, 0) == 5, "a drained array refills from the front"); + scr_arr_release(q); + + /* Small head room relative to the elements grows the allocation. */ + ScrArr *full = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 64; i++) scr_arr_push_f64(full, i); + while (full->len < full->cap) scr_arr_push_f64(full, 0); + size_t full_cap = full->cap; + scr_arr_shift_f64(full); + scr_arr_push_f64(full, 1); + scr_arr_push_f64(full, 2); + check(full->cap > full_cap && scr_arr_get_f64(full, 0) == 1 && + scr_arr_get_f64(full, full->len - 1) == 2, + "growth past a small head room is geometric"); + scr_arr_release(full); + + /* splice(0, n) as a value and as a statement. */ + ScrArr *s = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 20; i++) scr_arr_push_f64(s, i); + uint64_t *sblock = s->data; + ScrArr *removed = scr_arr_splice(s, 0, 3); + check(removed->len == 3 && scr_arr_get_f64(removed, 2) == 2 && s->data == sblock + 3 && + scr_arr_get_f64(s, 0) == 3, + "splice(0, n) leaves head room"); + scr_arr_release(removed); + scr_arr_splice_drop(s, 0, 2, 0, NULL); + check(s->data == sblock + 5 && s->len == 15 && scr_arr_get_f64(s, 0) == 5, + "statement splice(0, n) leaves head room"); + /* Sparse writes far past the end, then growth absorbs them. */ + scr_arr_set_f64(s, 2000000, 7); + check(arr_sparse_len(s) == 1 && scr_arr_get_f64(s, 2000000) == 7, "sparse write after head room"); + check_f64(scr_arr_shift_f64(s), 5, "shift of a sparse array"); + check(scr_arr_get_f64(s, 1999999) == 7 && !scr_arr_has(s, 2000000), "shift renumbers sparse entries"); + scr_arr_release(s); + + /* Strings: shifted references move out; the rest are released once. */ + ScrArr *names = scr_arr_new(SCR_ELEM_STR, 0); + for (int i = 0; i < 30; i++) scr_arr_push_ref(names, scr_str_new("name", 4)); + for (int i = 0; i < 10; i++) scr_str_release((ScrStr *)scr_arr_shift_ref(names)); + scr_arr_release(scr_arr_splice(names, 0, 5)); + scr_arr_splice_drop(names, 0, 5, 0, NULL); + scr_arr_unshift_ref(names, scr_str_new("front", 5)); + ScrStr *front = (ScrStr *)scr_arr_get_ref(names, 0); + check(strcmp(front->data, "front") == 0 && names->len == 11, "string queue keeps its order"); + scr_str_release(front); + scr_arr_release(names); + + /* A run of unshifts opens head room once it has moved the elements. */ + ScrArr *rev = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 1000; i++) scr_arr_unshift_f64(rev, i); + bool ordered = rev->len == 1000; + for (int i = 0; i < 1000 && ordered; i++) ordered = scr_arr_get_f64(rev, i) == 999 - i; + check(ordered, "unshift run keeps reverse order"); + size_t room = (size_t)(rev->present - (uint8_t *)(rev->data + rev->cap)); + check(room >= 4 && rev->cap >= rev->len, "an unshift run reserves head room"); + uint64_t *rblock = rev->data; + scr_arr_unshift_f64(rev, -1); + check(rev->data == rblock - 1, "unshift into reserved head room needs no move"); + uint64_t slots[3] = {0}; + double v = 7; + for (int i = 0; i < 3; i++) memcpy(&slots[i], &v, sizeof v); + check_f64(scr_arr_unshift_many(rev, 3, slots), 1004, "unshift many into head room"); + check(rev->data == rblock - 4 && scr_arr_get_f64(rev, 3) == -1, "unshift many order"); + /* Storage swaps (typed views) first drop the head room. */ + scr_arr_outline(rev); + check(rev->present == (uint8_t *)(rev->data + rev->cap) && scr_arr_get_f64(rev, 0) == 7 && + scr_arr_get_f64(rev, 1003) == 0, + "outline moves the elements to the front of their allocation"); + scr_arr_release(rev); + + /* A one-off unshift and small arrays keep moving their elements. */ + ScrArr *once = scr_arr_new(SCR_ELEM_F64, 0); + for (int i = 0; i < 40; i++) scr_arr_push_f64(once, i); + size_t once_cap = once->cap; + scr_arr_unshift_f64(once, -1); + check(once->cap == once_cap && once->present == (uint8_t *)(once->data + once->cap), + "a one-off unshift keeps the allocation"); + scr_arr_release(once); + + /* Booleans move their elements (one-byte slots have no head room). */ + ScrArr *flags = scr_arr_new(SCR_ELEM_BOOL, 0); + for (int i = 0; i < 10; i++) scr_arr_push_bool(flags, i % 3 == 0); + uint64_t *fblock = flags->data; + check(scr_arr_shift_bool(flags) && !scr_arr_shift_bool(flags) && flags->data == fblock && + flags->len == 8 && !scr_arr_get_bool(flags, 0) && scr_arr_get_bool(flags, 1), + "boolean shift moves the tail"); + scr_arr_release(flags); +#ifdef SCR_RC_AUDIT + check(scr_str_live_count() == strings0, "head room: no strings leaked"); + check(scr_arr_live_count() == arrays0, "head room: no arrays leaked"); +#endif +} + int main(int argc, char **argv) { if (argc > 1) { ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); @@ -1934,6 +2064,7 @@ int main(int argc, char **argv) { test_filled_prefix(); test_bool_bytes(); test_inline_slots(); + test_head_room(); fprintf(stderr, "%ld/%ld cases passed\n", total - failed, total); return failed == 0 ? 0 : 1; 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(); diff --git a/packages/runtime/vendor/quickjs-ng/libregexp.c b/packages/runtime/vendor/quickjs-ng/libregexp.c index 2191c6879..09720139d 100644 --- a/packages/runtime/vendor/quickjs-ng/libregexp.c +++ b/packages/runtime/vendor/quickjs-ng/libregexp.c @@ -1020,7 +1020,7 @@ static int parse_class_string_disjunction(REParseState *s, REStringList *cr, for(;;) { str.size = 0; while (*p != '}' && *p != '|') { - c = get_class_atom(s, NULL, &p, false); + c = get_class_atom(s, NULL, &p, true); if (c < 0) goto fail; if (dbuf_put_u32(&str, c)) { @@ -1193,7 +1193,7 @@ static int get_class_atom(REParseState *s, REStringList *cr, case '^': case '`': case '~': - if (s->unicode_sets && p[1] == c) { + if (s->unicode_sets && inclass && p[1] == c) { /* forbidden double characters */ return re_parse_error(s, "invalid class set operation in regular expression"); } @@ -1208,7 +1208,7 @@ static int get_class_atom(REParseState *s, REStringList *cr, case '/': case '-': case '|': - if (s->unicode_sets) { + if (s->unicode_sets && inclass) { /* invalid characters in unicode sets */ return re_parse_error(s, "invalid character in class in regular expression"); } diff --git a/packages/threads/package.json b/packages/threads/package.json index 2ef26c660..e3b5c91ff 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.3.5", + "version": "0.3.6", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/types/README.md b/packages/types/README.md index 6761bd940..e5d409860 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -20,6 +20,7 @@ function isLetter(ch: number): NoThrow { - `NoThrow` on a function's return type: the function never throws. Callers need no exception checks. - `Int32` on a parameter, variable, class instance field or function result: every stored value is a signed 32-bit integer by construction (an integer literal in range, a bitwise result such as `x | 0`, `Math.imul`, another `Int32`, or arithmetic scriptc can bound). The slot is stored as a 32-bit integer. +- `Int32[]` (also `readonly Int32[]`, `Array`) on a parameter, variable, class instance field or function result of type `number[]`: every element stored into the array, through any reference, is an int32 by construction. Element reads that find a value are int32s, so integer arithmetic over them runs in 32-bit operations; holes and missing indices still read as `undefined`. - `Immutable` on a parameter, variable, class instance field or function result: a deep read-only view, and no code writes to the value through any reference. A module constant built from literals becomes immortal data. - `Acyclic` on a class (`class C implements Acyclic`), interface (`interface I extends Acyclic`) or object type alias (`type T = Acyclic<{ ... }>`): no chain of references can lead from such an object back to itself. Its objects carry no cycle-collector header. - `ProgramLifetime` in a class's `implements` clause: instances of the class and its subclasses are never freed, so scriptc skips reference counting and cycle collection for them. Memory only: discarded instances are not reclaimed. diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 88612471f..890868e8b 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -15,7 +15,13 @@ * `--`, arguments and returns) must be an int32 by construction: an integer * literal in range, a bitwise result such as `x | 0`, another `Int32` value, * a string or array length, or arithmetic whose range scriptc can bound. - * scriptc then stores the slot as a 32-bit integer. */ + * scriptc then stores the slot as a 32-bit integer. + * + * As an array element type (`Int32[]`, `readonly Int32[]`, `Array`) + * on a declaration of type `number[]`: every element stored into the array, + * through any reference to it, must be an int32 by construction, and element + * reads that find a value are int32s. Holes, missing indices and `length` + * changes behave as in JavaScript. */ export type Int32 = number; /** Return-type wrapper: the function never throws. scriptc proves that no diff --git a/packages/types/package.json b/packages/types/package.json index 83e03e0d2..b694abb1a 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/types", - "version": "0.3.5", + "version": "0.3.6", "description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/scripts/bench-runtime.mjs b/scripts/bench-runtime.mjs index ff9c9d690..4e4f2c245 100644 --- a/scripts/bench-runtime.mjs +++ b/scripts/bench-runtime.mjs @@ -56,9 +56,10 @@ Each checkout must have a built CLI (packages/cli/dist/bootstrap.js) and native artifacts for this host. Without --baseline, reports absolute times only. Node is timed in the same interleaved loop as the reference to beat (--no-node skips it). ---layouts=N (macOS/Linux) links every executable N times, shifting the program -object by 0, 16, 32, ... bytes of padding, and spreads the timed runs evenly over -those layouts, so verdicts cover code-placement luck instead of one placement. +--layouts=N (macOS/Linux) links every executable N times, shifting the runtime +and program code by 0, 16, 32, ... bytes of padding, and spreads the timed runs +evenly over those layouts, so verdicts cover code-placement luck instead of one +placement. Use it for small codegen changes (e.g. --layouts=4 --runs=16). Workload names are listed in benchmarks/runtime/workloads.json; for example @@ -103,10 +104,11 @@ const work = mkdtempSync( ); /* ── code-layout perturbation ──────────────────────────────────────────── */ -// Layout k > 0 links a retained k*16-byte padding object directly before the -// program object, through a SCRIPTC_LINKER wrapper that forwards to clang (the -// default linker driver on macOS and Linux). Code after that point moves as if -// earlier code had grown; any checkout's compiler can be perturbed this way. +// Layout k > 0 links a retained k*16-byte padding object before every other +// input, through a SCRIPTC_LINKER wrapper that forwards to clang (the default +// linker driver on macOS and Linux). The runtime is linked before the program +// object, so both move as if earlier code had grown; any checkout's compiler can +// be perturbed this way. function layoutTooling() { if (layouts === 1) return null; const root = join(work, "layouts"); @@ -137,12 +139,11 @@ const args = process.argv.slice(2); const pad = process.env.SCRIPTC_BENCH_LAYOUT_PAD; let out = args; if (pad && !args.some((a) => a.startsWith("-print-prog-name"))) { - const at = args.findIndex((a) => basename(a).startsWith(".scriptc-native-program-object-")); - if (at < 0) { + if (!args.some((a) => basename(a).startsWith(".scriptc-native-program-object-"))) { process.stderr.write("bench layout wrapper: no program object on the link line\\n"); process.exit(2); } - out = ["-Wl,-u,${symbol}", ...args.slice(0, at), pad, ...args.slice(at)]; + out = ["-Wl,-u,${symbol}", pad, ...args]; } const result = spawnSync("clang", out, { stdio: "inherit" }); process.exit(result.status ?? 1); diff --git a/tests/corpus/4642-acyclic-contract.ts b/tests/corpus/4642-acyclic-contract.ts index dffbba761..bbd256fe1 100644 --- a/tests/corpus/4642-acyclic-contract.ts +++ b/tests/corpus/4642-acyclic-contract.ts @@ -50,6 +50,16 @@ interface Point extends Acyclic { type Segment = Acyclic<{ readonly from: Point; readonly to: Point; readonly label: string }>; +// A generic class is acyclic like any other class. +class Chain implements Acyclic { + readonly value: T; + readonly next: Chain | undefined; + constructor(value: T, next: Chain | undefined) { + this.value = value; + this.next = next; + } +} + function build(depth: number): Tree { return depth === 0 ? new Tree(null, null, 1) : new Tree(build(depth - 1), build(depth - 1), depth); } @@ -74,3 +84,15 @@ const slots: Record = { a: new Slot() }; for (let i = 0; i < 3; i++) slots["a"]!.index = (slots["a"]!.index + 2) | 0; slots["b"] = new Slot(); console.log(Object.keys(slots).join(","), slots["a"]!.index, slots["b"]!.index); + +let numbers: Chain | undefined = undefined; +let words: Chain | undefined = undefined; +for (let i = 0; i < 1000; i++) { + numbers = new Chain(i, numbers); + words = new Chain(`w${i % 7}`, words); +} +let chained = 0; +for (let c = numbers; c !== undefined; c = c.next) chained += c.value; +let longest = ""; +for (let c = words; c !== undefined; c = c.next) if (c.value > longest) longest = c.value; +console.log(chained, longest); diff --git a/tests/corpus/5659-net-blocklist-socketaddress.ts b/tests/corpus/5659-net-blocklist-socketaddress.ts new file mode 100644 index 000000000..181f533d4 --- /dev/null +++ b/tests/corpus/5659-net-blocklist-socketaddress.ts @@ -0,0 +1,97 @@ +// net.SocketAddress (options, defaults, parse(), toJSON() and Node's +// validation errors) and net.BlockList (address, range, subnet and CIDR +// rules, removal, the rule listing order, check() across IPv4 and IPv6, +// toJSON()/fromJSON() and PRIVATE_RANGES). Node lists the address rules +// first, in the iteration order of a C++ unordered_map (which differs between +// platforms), then the subnets and the ranges, each newest first; the program +// sorts the address rules before printing. +import * as net from "node:net"; +import { inspect } from "node:util"; + +function ordered(rules: readonly string[]): string[] { + const addresses = rules.filter((rule) => rule.startsWith("Address: ")); + return [...addresses.sort(), ...rules.slice(addresses.length)]; +} + +function show(label: string, f: () => unknown): void { + try { + const v = f(); + console.log(label, typeof v === "string" ? JSON.stringify(v) : inspect(v)); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, "throws", err.name, err.code, err.message); + } +} + +show("default", () => new net.SocketAddress()); +show("ipv4", () => new net.SocketAddress({ address: "10.1.2.3", port: 80 })); +show("ipv6", () => new net.SocketAddress({ address: "::0001", family: "ipv6", port: 443, flowlabel: 7 })); +show("ipv6 without family", () => new net.SocketAddress({ address: "::1" })); +show("bad family", () => new net.SocketAddress({ family: "ipv5" as unknown as net.IPVersion })); +show("bad port", () => new net.SocketAddress({ port: 70000 })); +show("string port", () => new net.SocketAddress({ port: "80" as unknown as number })); +show("bad flowlabel", () => new net.SocketAddress({ flowlabel: -1 })); +show("options string", () => new net.SocketAddress("x" as unknown as net.SocketAddressInitOptions)); +show("parse ipv4", () => net.SocketAddress.parse("1.2.3.4:5")); +show("parse ipv6", () => net.SocketAddress.parse("[::1]:8080")); +show("parse default port", () => net.SocketAddress.parse("[::1]:80")); +show("parse invalid", () => net.SocketAddress.parse("example.com:1")); +const sa = new net.SocketAddress({ address: "1.1.1.1", port: 1 }); +show("getters", () => [sa.address, sa.port, sa.family, sa.flowlabel]); +show("json", () => JSON.stringify(sa)); +show("is", () => [net.SocketAddress.isSocketAddress(sa), net.SocketAddress.isSocketAddress({}), sa instanceof net.SocketAddress]); + +const list = new net.BlockList(); +show("empty", () => list); +list.addAddress("1.2.3.4"); +list.addAddress("::1", "ipv6"); +list.addAddress("2.2.2.2"); +list.addAddress("1.2.3.4"); +list.addRange("10.0.0.1", "10.0.0.5"); +list.addSubnet("192.168.0.0", 16); +list.addCIDR("fe80::/10"); +list.addAddresses(["7.7.7.7", sa]); +show("rules", () => ordered(list.rules)); +show("size", () => list.size); +const single = new net.BlockList(); +single.addAddress("3.3.3.3"); +single.addRange("10.0.0.1", "10.0.0.5"); +single.addSubnet("192.168.0.0", 16); +show("inspect", () => single); +show("check", () => [ + list.check("1.2.3.4"), + list.check("10.0.0.3"), + list.check("10.0.0.6"), + list.check("192.168.9.9"), + list.check("::1", "ipv6"), + list.check("::ffff:1.2.3.4", "ipv6"), + list.check("fe80::abcd", "ipv6"), + list.check(new net.SocketAddress({ address: "10.0.0.2" })), + list.check("1.2.3.4", "ipv6"), + list.check("nope"), +]); +show("check number", () => list.check(5 as unknown as string)); +show("bad range", () => list.addRange("10.0.0.9", "10.0.0.1")); +show("bad prefix", () => list.addSubnet("10.0.0.0", 33)); +show("bad address", () => list.addAddress("x")); +show("bad cidr", () => list.addCIDR("10.0.0.0")); +list.removeAddress("2.2.2.2"); +list.removeRange("10.0.0.1", "10.0.0.5"); +list.removeCIDR("fe80::/10"); +show("after removal", () => ordered(list.rules)); + +const mapped = new net.BlockList(); +mapped.addSubnet("::", 64, "ipv6"); +show("mapped", () => [mapped.check("9.9.9.9"), mapped.check("::1:0:0:0:1", "ipv6")]); + +const copy = new net.BlockList(); +copy.fromJSON(list.toJSON()); +show("fromJSON", () => ordered(copy.rules)); +const parsed = new net.BlockList(); +parsed.fromJSON(JSON.stringify(["Subnet: IPv6 ::/64", "x Address: IPv4 9.9.9.9 y", "Range: IPv4 1.1.1.1-1.1.1.2"])); +show("fromJSON string", () => parsed.rules); +show("fromJSON number", () => parsed.fromJSON(5 as unknown as string)); +parsed.clear(); +show("cleared", () => [parsed.rules, parsed.size]); +show("private", () => [net.BlockList.PRIVATE_RANGES, Object.isFrozen(net.BlockList.PRIVATE_RANGES)]); +show("isBlockList", () => [net.BlockList.isBlockList(list), net.BlockList.isBlockList(null), list instanceof net.BlockList]); 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/corpus/5776-power-of-two-remainder-integer-path.ts b/tests/corpus/5776-power-of-two-remainder-integer-path.ts new file mode 100644 index 000000000..b67bba638 --- /dev/null +++ b/tests/corpus/5776-power-of-two-remainder-integer-path.ts @@ -0,0 +1,100 @@ +// `%` by a constant power of two around the bounds of its integer path: +// dividends just inside and outside 2^62 in magnitude, large fractions, +// subnormals, divisors up to and past 2^52, computed dividends below and +// above twice the divisor, and loops whose dividends switch between +// integers, fractions, negatives, NaN and the infinities at the same +// remainder. Every zero result takes the dividend's sign. + +function show(label: string, v: number): void { + console.log(label, Object.is(v, -0) ? "-0" : String(v)); +} + +const dividends: number[] = [ + 2 ** 62 - 1024, -(2 ** 62 - 1024), 2 ** 62, -(2 ** 62), 2 ** 62 + 1024, -(2 ** 62 + 2048), + 2 ** 61 + 512, -(2 ** 61 + 512), 2 ** 52 - 0.5, -(2 ** 52 - 0.5), 2 ** 51 + 0.25, + -(2 ** 51 + 0.75), 4503599627370495.5, 0.9999999999999999, -0.9999999999999999, 1e-320, -1e-320, + -0.5, 0.5, -65536, 65536, -65536.5, 131071.75, -131071.75, -0, 0, +]; +const divided: Array<[string, (x: number) => number]> = [ + ["1", (x) => x % 1], + ["2", (x) => x % 2], + ["65536", (x) => x % 65536], + ["-65536", (x) => x % -65536], + ["2 ** 31", (x) => x % 2 ** 31], + ["2 ** 51", (x) => x % 2 ** 51], + ["2 ** 52", (x) => x % 2 ** 52], + ["-(2 ** 52)", (x) => x % -(2 ** 52)], + ["2 ** 53", (x) => x % 2 ** 53], +]; +for (const x of dividends) { + for (const [name, f] of divided) show(`${x} % ${name}`, f(x)); +} + +// One remainder site sees every kind of dividend in turn. +const stream: number[] = []; +for (let i = 0; i < 200; i++) { + const k = i % 8; + stream.push( + k === 0 + ? i * 977 + : k === 1 + ? -i * 1013 + : k === 2 + ? i + 0.375 + : k === 3 + ? -i - 0.625 + : k === 4 + ? (i * 2 ** 40) / 3 + : k === 5 + ? -(2 ** 62) - i * 4096 + : k === 6 + ? 2 ** 62 - 1024 * (i + 1) + : -0, + ); +} +stream.push(NaN, 5, Infinity, -7, -Infinity, 9); +let s = 0; +let t = 0; +for (let i = 0; i < stream.length; i++) { + s = (s + stream[i]!) % 65536; + t += stream[i]! % 1024; + if (i % 25 === 0 || i >= stream.length - 6) { + show(`s[${i}]`, s); + show(`t[${i}]`, t); + } + if (Number.isNaN(s)) s = 0; + if (Number.isNaN(t)) t = 0; +} + +// Accumulators that stay integers, with negative and positive steps. +let up = 0; +let down = 0; +for (let i = 0; i < 100000; i++) { + up = (up + i * 7919) % 65536; + down = (down * 3 - i) % 2 ** 16; +} +show("up", up); +show("down", down); + +// Fractional accumulators, which take the floating remainder. +let phase = 0; +let drift = 0; +for (let i = 0; i < 1000; i++) { + phase = (phase + 0.1234567) % 1; + drift = (drift - 2.75) % 256; +} +show("phase", phase); +show("drift", drift); + +// Computed dividends around one and two divisors, of both signs. +const half = [0.25][0]!; +const near: number[] = [ + 0.25, 255.5, 255.75 - 2 ** -44, 256, 511.5, 511.75 - 2 ** -44, 512, 767.25, 1023.5, 2 ** 40 + 0.5, +]; +for (const v of near) { + for (const x of [v, -v]) show(`(${x} + 0.25) % 256`, (x + half) % 256); +} + +// Proven integer dividends with a `2 ** k` divisor; negative multiples are -0. +for (let i = -6; i <= 6; i++) show(`(${i} * 4) % 2 ** 4`, (i * 4) % 2 ** 4); +for (let i = -6; i <= 6; i++) show(`(${i} * 3) % 2 ** 3`, (i * 3) % 2 ** 3); diff --git a/tests/corpus/5800-broadcast-channel.ts b/tests/corpus/5800-broadcast-channel.ts new file mode 100644 index 000000000..648d2caed --- /dev/null +++ b/tests/corpus/5800-broadcast-channel.ts @@ -0,0 +1,35 @@ +import { BroadcastChannel } from "node:worker_threads"; + +// BroadcastChannel within one thread: delivery to every other channel with +// the name (not the sender), per channel in creation order before +// setImmediate, MessageEvent fields, listeners, and the errors. +const a = new BroadcastChannel("o"); +const b = new BroadcastChannel("o"); +const c = new BroadcastChannel("o"); +const other = new BroadcastChannel("p"); +const log = (n: string) => (e: MessageEvent) => + console.log(n, JSON.stringify(e.data), e.type, e.origin === "", e.lastEventId === "", e.source, e.isTrusted, e.target === (n === "a" ? a : n === "b" ? b : c)); +a.onmessage = log("a"); +b.onmessage = log("b"); +c.onmessage = log("c"); +c.addEventListener("message", (e: Event) => console.log("c listener", JSON.stringify((e as MessageEvent).data))); +other.onmessage = () => console.log("other got (should not)"); +a.postMessage("from a"); +b.postMessage({ from: "b" }); +console.log("name", a.name, typeof b.onmessage, b.onmessageerror); +const named = new BroadcastChannel(String(42)); +console.log("coerced", named.name); +named.close(); +try { + named.postMessage(1); +} catch (err) { + const e = err as Error; + console.log(e.name, e.message); +} +setImmediate(() => { + console.log("immediate"); + a.close(); + b.close(); + c.close(); + other.close(); +}); diff --git a/tests/corpus/5801-broadcast-channel.cjs b/tests/corpus/5801-broadcast-channel.cjs new file mode 100644 index 000000000..9fd8c759c --- /dev/null +++ b/tests/corpus/5801-broadcast-channel.cjs @@ -0,0 +1,30 @@ +'use strict'; +// BroadcastChannel in JavaScript: error codes, the MessageEvent's ports, +// once and removed listeners, ref()/unref() returning the channel, and an +// unref'd channel that does not keep the process alive. +const { BroadcastChannel } = require('node:worker_threads'); + +for (const f of [ + () => new BroadcastChannel(), + () => { const c = new BroadcastChannel('x'); try { c.postMessage(); } finally { c.close(); } }, + () => { const c = new BroadcastChannel('x'); c.close(); c.close(); c.postMessage(1); }, +]) { + try { + f(); + console.log('ok'); + } catch (e) { + console.log(e.name, e.code, e.message); + } +} +const a = new BroadcastChannel('n'); +const b = new BroadcastChannel('n'); +const removed = () => console.log('removed listener ran'); +b.addEventListener('message', (e) => console.log('once', e.data, Array.isArray(e.ports), e.ports.length), { once: true }); +b.addEventListener('message', removed); +b.removeEventListener('message', removed); +b.onmessage = (e) => console.log('onmessage', e.data, e.target === b, this === undefined); +console.log('ref returns', a.ref() === a, a.unref() === a, b.unref() === b); +a.postMessage(1); +a.postMessage(2); +setTimeout(() => console.log('never: unref channels do not hold the loop'), 10000).unref(); +setImmediate(() => console.log('immediate')); diff --git a/tests/corpus/5802-broadcast-channel-workers.ts b/tests/corpus/5802-broadcast-channel-workers.ts new file mode 100644 index 000000000..2d6673537 --- /dev/null +++ b/tests/corpus/5802-broadcast-channel-workers.ts @@ -0,0 +1,25 @@ +import { BroadcastChannel, Worker, isMainThread } from "node:worker_threads"; + +// BroadcastChannel across threads: the main thread's channel and a +// worker's channel with the same name hear each other, a sender never +// hears its own message, and closing both lets the worker exit. +if (isMainThread) { + const bc = new BroadcastChannel("jobs"); + bc.onmessage = (e: MessageEvent) => { + console.log("main got", JSON.stringify(e.data)); + if (e.data === "ready") bc.postMessage("work"); + if (e.data === "done:work") { + bc.postMessage("bye"); + bc.close(); + } + }; + const worker = new Worker(new URL(import.meta.url)); + worker.on("exit", (code: number) => console.log("exit", code)); +} else { + const bc = new BroadcastChannel("jobs"); + bc.onmessage = (e: MessageEvent) => { + if (e.data === "work") bc.postMessage("done:" + e.data); + if (e.data === "bye") bc.close(); + }; + bc.postMessage("ready"); +} diff --git a/tests/corpus/5803-worker-environment-data.ts b/tests/corpus/5803-worker-environment-data.ts new file mode 100644 index 000000000..4debbbe3e --- /dev/null +++ b/tests/corpus/5803-worker-environment-data.ts @@ -0,0 +1,53 @@ +import { Worker, getEnvironmentData, isMainThread, parentPort, setEnvironmentData, workerData } from "node:worker_threads"; + +// setEnvironmentData/getEnvironmentData: keys compare with SameValueZero, +// undefined deletes, and each new Worker starts from a clone of its +// parent's entries taken at construction, which it passes on to its own +// workers. +if (isMainThread) { + console.log("missing", getEnvironmentData("missing")); + setEnvironmentData("a", 1); + setEnvironmentData("config", { list: [1, 2], name: "x" }); + setEnvironmentData(42, "number key"); + setEnvironmentData(NaN, "nan key"); + setEnvironmentData(-0, "zero key"); + const key = { k: 1 }; + setEnvironmentData(key, "object key"); + const shared = { v: 1 }; + setEnvironmentData("shared", shared); + console.log(getEnvironmentData("a"), getEnvironmentData(42), getEnvironmentData(NaN), getEnvironmentData(0)); + console.log("object key", getEnvironmentData(key), getEnvironmentData({ k: 1 })); + console.log("same value", getEnvironmentData("shared") === shared); + setEnvironmentData("gone", 5); + setEnvironmentData("gone", undefined); + setEnvironmentData("gone too", 5); + setEnvironmentData("gone too"); + console.log("deleted", getEnvironmentData("gone"), getEnvironmentData("gone too")); + + const first = new Worker(new URL(import.meta.url), { workerData: "first" }); + setEnvironmentData("a", 2); + first.on("message", (m: unknown) => console.log("first", JSON.stringify(m))); + first.on("exit", (code: number) => { + console.log("first exit", code, getEnvironmentData("a"), getEnvironmentData("set by worker")); + const second = new Worker(new URL(import.meta.url), { workerData: "second" }); + second.on("message", (m: unknown) => console.log("second", JSON.stringify(m))); + second.on("exit", (c: number) => console.log("second exit", c)); + }); +} else if (workerData === "first" || workerData === "second") { + parentPort!.postMessage([ + getEnvironmentData("a"), + getEnvironmentData("config"), + getEnvironmentData(42), + getEnvironmentData(NaN), + getEnvironmentData(0), + getEnvironmentData("gone"), + ]); + setEnvironmentData("set by worker", true); + setEnvironmentData("a", 10); + if (workerData === "second") { + const nested = new Worker(new URL(import.meta.url), { workerData: "nested" }); + nested.on("message", (m: unknown) => parentPort!.postMessage({ nested: m })); + } +} else { + parentPort!.postMessage([getEnvironmentData("set by worker"), getEnvironmentData("a"), getEnvironmentData("shared")]); +} diff --git a/tests/corpus/5804-worker-environment-data.cjs b/tests/corpus/5804-worker-environment-data.cjs new file mode 100644 index 000000000..e16fe248d --- /dev/null +++ b/tests/corpus/5804-worker-environment-data.cjs @@ -0,0 +1,26 @@ +'use strict'; +// Environment data in CommonJS: a worker sees its parent's entries, a +// value that cannot be cloned makes new Worker() throw DataCloneError, +// and the thread keeps its own entries after that failure. +const { Worker, getEnvironmentData, isMainThread, parentPort, setEnvironmentData } = require('node:worker_threads'); + +if (isMainThread) { + setEnvironmentData('greeting', 'hello'); + setEnvironmentData('list', [1, [2, 3]]); + const worker = new Worker(__filename); + worker.on('message', (m) => console.log('worker saw', JSON.stringify(m))); + worker.on('exit', (code) => { + console.log('exit', code); + setEnvironmentData('callback', () => 1); + try { + new Worker(__filename); + console.log('constructed'); + } catch (err) { + console.log('caught', err.name); + } + setEnvironmentData('callback'); + console.log('kept', getEnvironmentData('greeting'), typeof getEnvironmentData('callback')); + }); +} else { + parentPort.postMessage({ greeting: getEnvironmentData('greeting'), list: getEnvironmentData('list') }); +} diff --git a/tests/corpus/5805-worker-receive-message-parent-port.ts b/tests/corpus/5805-worker-receive-message-parent-port.ts new file mode 100644 index 000000000..5cda1574d --- /dev/null +++ b/tests/corpus/5805-worker-receive-message-parent-port.ts @@ -0,0 +1,48 @@ +import { Worker, isMainThread, parentPort, receiveMessageOnPort, workerData } from "node:worker_threads"; + +// receiveMessageOnPort(parentPort) in a worker takes the parent's messages +// one at a time, before and between 'message' listener deliveries, and +// answers undefined once the queue is empty. The worker starts reading +// once the parent has posted its first three messages (flag[1]), so the +// second read does not depend on thread timing. +if (isMainThread) { + const flag = new Int32Array(new SharedArrayBuffer(8)); + const worker = new Worker(new URL(import.meta.url), { workerData: flag }); + worker.postMessage("a"); + worker.postMessage({ b: 2 }); + worker.postMessage("c"); + Atomics.store(flag, 1, 1); + Atomics.notify(flag, 1); + worker.on("message", (m: unknown) => { + console.log("main got", JSON.stringify(m)); + if (m === "ready") { + worker.postMessage("d"); + worker.postMessage("e"); + Atomics.store(flag, 0, 1); + Atomics.notify(flag, 0); + } + }); + worker.on("exit", (code: number) => console.log("exit", code)); +} else { + const flag = workerData as Int32Array; + const port = parentPort!; + Atomics.wait(flag, 1, 0); + let first = receiveMessageOnPort(port); + while (first === undefined) first = receiveMessageOnPort(port); + port.postMessage(["first", first]); + port.postMessage(["second", receiveMessageOnPort(port)]); + port.on("message", (v: unknown) => { + port.postMessage(["listener", v]); + if (v === "c") { + port.postMessage("ready"); + Atomics.wait(flag, 0, 0); + let next = receiveMessageOnPort(port); + while (next === undefined) next = receiveMessageOnPort(port); + port.postMessage(["sync", next]); + } + if (v === "e") { + port.postMessage(["empty", receiveMessageOnPort(port) === undefined]); + port.close(); + } + }); +} diff --git a/tests/corpus/5806-worker-resource-limits.ts b/tests/corpus/5806-worker-resource-limits.ts new file mode 100644 index 000000000..a4f5f7390 --- /dev/null +++ b/tests/corpus/5806-worker-resource-limits.ts @@ -0,0 +1,38 @@ +import { Worker, isMainThread, parentPort, resourceLimits, workerData } from "node:worker_threads"; + +// resourceLimits is {} in the main thread and after a worker exits. A +// worker's resourceLimits.stackSizeMb sets its thread's stack size: deep +// recursion still ends in a catchable RangeError. A stackSizeMb of 0 keeps +// the default stack. +function depth(n: number, path: number[]): number { + path.push(n); + const result = n === 0 ? 0 : 1 + depth(n - 1, path); + path.pop(); + return result; +} + +if (isMainThread) { + console.log("main", JSON.stringify(resourceLimits), Object.keys(resourceLimits).length); + const worker = new Worker(new URL(import.meta.url), { + workerData: "small", + resourceLimits: { stackSizeMb: 1 }, + }); + worker.on("message", (m: unknown) => console.log("small stack", JSON.stringify(m))); + worker.on("exit", (code: number) => { + console.log("exit", code, JSON.stringify(worker.resourceLimits)); + const other = new Worker(new URL(import.meta.url), { + workerData: "default", + resourceLimits: { stackSizeMb: 0 }, + }); + other.on("message", (m: unknown) => console.log("default stack", JSON.stringify(m))); + other.on("exit", (c: number) => console.log("exit", c)); + }); +} else { + let overflow = "none"; + try { + depth(1e7, []); + } catch (error) { + if (error instanceof RangeError) overflow = error.name; + } + parentPort!.postMessage({ data: workerData, overflow, shallow: depth(1000, []) }); +} diff --git a/tests/corpus/5807-worker-error-values.ts b/tests/corpus/5807-worker-error-values.ts new file mode 100644 index 000000000..e2edabfee --- /dev/null +++ b/tests/corpus/5807-worker-error-values.ts @@ -0,0 +1,68 @@ +import { Worker, isMainThread, workerData } from "node:worker_threads"; + +// A worker's uncaught exception reaches the 'error' listener as Node's +// error serializer sends it: an Error keeps the nearest built-in +// constructor, its name (own or inherited), message, cause and other data +// properties with their enumerability; other values clone as messages do. +class ConfigError extends RangeError { + setting: string; + constructor(message: string, setting: string) { + super(message); + this.name = "ConfigError"; + this.setting = setting; + } +} + +const cases = ["props", "subclass", "aggregate", "cause", "string", "null", "number", "object", "runtime"]; + +if (isMainThread) { + let next = 0; + const run = (): void => { + const name = cases[next++]; + if (name === undefined) return; + const worker = new Worker(new URL(import.meta.url), { workerData: name }); + worker.on("error", (e: any) => { + if (typeof e !== "object" || e === null) { + console.log(name, typeof e, e); + return; + } + console.log(name, e instanceof Error, e instanceof RangeError, e instanceof TypeError, e.name, e.message); + console.log(" ", JSON.stringify(e), e.code, e.setting, e.errors?.length, e.cause?.message, e.cause?.detail); + }); + worker.on("exit", (code: number) => { + console.log(" ", "exit", code); + run(); + }); + }; + run(); +} else { + switch (workerData) { + case "props": { + const e: any = new Error("with props"); + e.code = "E_PROPS"; + e.extra = { y: 2 }; + throw e; + } + case "subclass": + throw new ConfigError("bad setting", "port"); + case "aggregate": + throw new AggregateError([new Error("a"), new TypeError("b")], "several"); + case "cause": { + const inner: any = new Error("inner"); + inner.detail = 7; + throw new Error("outer", { cause: inner }); + } + case "string": + throw "plain string"; + case "null": + throw null; + case "number": + throw 42; + case "object": + throw { a: 1, b: "x" }; + case "runtime": { + const value: any = JSON.parse("null"); + console.log(value.missing); + } + } +} diff --git a/tests/corpus/5808-worker-unhandled-rejections.ts b/tests/corpus/5808-worker-unhandled-rejections.ts new file mode 100644 index 000000000..d13369671 --- /dev/null +++ b/tests/corpus/5808-worker-unhandled-rejections.ts @@ -0,0 +1,39 @@ +import { Worker, isMainThread, workerData } from "node:worker_threads"; + +// An unhandled rejection ends a worker with an 'error' event: an +// error-like reason as itself, any other reason as Node's +// UnhandledPromiseRejection Error quoting the reason. +const reasons = ["string", "number", "undefined", "null", "object", "array", "symbol", "bigint", "errorLike", "error"]; + +if (isMainThread) { + let next = 0; + const run = (): void => { + const reason = reasons[next++]; + if (reason === undefined) return; + const worker = new Worker(new URL(import.meta.url), { workerData: reason }); + worker.on("error", (e: any) => { + const at = typeof e.message === "string" ? e.message.indexOf("The promise") : -1; + const message = at < 0 ? e.message : e.message.slice(at); + console.log(reason, e instanceof Error, e.name, e.code, message, JSON.stringify(e)); + }); + worker.on("exit", (code: number) => { + console.log(" ", "exit", code); + run(); + }); + }; + run(); +} else { + const values: Record = { + string: "rejected", + number: 1.5, + undefined: undefined, + null: null, + object: { a: 1 }, + array: [1, [2, 3]], + symbol: Symbol("why"), + bigint: 10n, + errorLike: { stack: "custom stack", message: "like an error" }, + error: new RangeError("real error"), + }; + void Promise.reject(values[workerData as string]); +} diff --git a/tests/corpus/5809-process-exit-and-rejections.cjs b/tests/corpus/5809-process-exit-and-rejections.cjs new file mode 100644 index 000000000..77b2a3609 --- /dev/null +++ b/tests/corpus/5809-process-exit-and-rejections.cjs @@ -0,0 +1,46 @@ +// @exit: 3 +'use strict'; +// process.exit(code) assigns process.exitCode first: a code that is not a +// safe integer throws a RangeError and execution continues. Valid codes +// convert to int32, which a worker's 'exit' event reports in full. An +// 'uncaughtException' listener receives an unhandled rejection with origin +// 'unhandledRejection': an Error reason as itself, any other reason as +// Node's UnhandledPromiseRejection Error quoting the reason. +const { Worker, isMainThread, workerData } = require('node:worker_threads'); + +const codes = [1.5, -1, 261, 2 ** 32 + 5]; + +if (isMainThread) { + process.on('uncaughtException', (err, origin) => { + const at = typeof err.message === 'string' ? err.message.indexOf('The promise') : -1; + console.log(origin, err instanceof Error, err.name, err.code, at < 0 ? err.message : err.message.slice(at)); + console.log(' ', JSON.stringify(err), typeof err === 'object' ? Object.keys(err).join(',') : typeof err); + }); + for (const code of [3.7, 2 ** 53, NaN]) { + try { + process.exit(code); + } catch (error) { + if (error instanceof RangeError) console.log(error.name, error.message); + } + } + void Promise.reject('main reason'); + setTimeout(() => void Promise.reject({ a: 1 }), 1); + setTimeout(() => void Promise.reject(new TypeError('typed reason')), 2); + setTimeout(() => { + throw 'thrown string'; + }, 3); + let next = 0; + const run = () => { + const code = codes[next++]; + if (code === undefined) process.exit(256 + 3); + const worker = new Worker(__filename, { workerData: code }); + worker.on('error', (e) => console.log(code, 'error', e.name, e.code)); + worker.on('exit', (exitCode) => { + console.log(code, 'exit', exitCode); + run(); + }); + }; + setTimeout(run, 20); +} else { + process.exit(workerData); +} 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/corpus/5830-timers-promises-signal.ts b/tests/corpus/5830-timers-promises-signal.ts new file mode 100644 index 000000000..29cc658b7 --- /dev/null +++ b/tests/corpus/5830-timers-promises-signal.ts @@ -0,0 +1,95 @@ +// node:timers/promises setTimeout(delay, value, { signal }) and +// setImmediate(value, { signal }): an aborted signal rejects at once with an +// AbortError whose cause is the signal's reason; an abort while waiting +// clears the timer and rejects; an abort after the timer fired changes +// nothing; a value that is not an AbortSignal rejects with +// ERR_INVALID_ARG_TYPE. The ordering case pins when the rejection reaches +// its handlers relative to a chain of microtasks started with the abort. +import { setTimeout as sleep, setImmediate as immediate } from "node:timers/promises"; + +function describe(e: unknown): string { + const err = e as Error; + const code = (e as { code?: string }).code; + const reason = err.cause; + const cause = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason); + return `${err.name} ${code} "${err.message}" cause=${cause}`; +} + +async function main(): Promise { + try { + await sleep(1000, "never", { signal: AbortSignal.abort() }); + } catch (e) { + console.log("pre-aborted:", describe(e)); + } + + try { + await sleep(1000, "never", { signal: AbortSignal.abort("stopped") }); + } catch (e) { + console.log("pre-aborted reason:", describe(e)); + } + + const ac = new AbortController(); + const started = Date.now(); + const pending = sleep(5000, "late", { signal: ac.signal }); + setTimeout(() => ac.abort(new Error("cancelled")), 5); + try { + await pending; + } catch (e) { + console.log("aborted while waiting:", describe(e), Date.now() - started < 2000); + } + + const later = new AbortController(); + const value = await sleep(1, { id: 7 }, { signal: later.signal }); + later.abort(); + console.log("fired first:", value.id, later.signal.aborted); + + console.log("immediate:", await immediate("now", { signal: new AbortController().signal })); + try { + await immediate("never", { signal: AbortSignal.abort() }); + } catch (e) { + console.log("immediate pre-aborted:", describe(e)); + } + + // ref: false with a signal: another timer keeps the process alive. + const keepAlive = setTimeout(() => {}, 1000); + const loose = new AbortController(); + console.log("unref:", await sleep(1, "still fires", { signal: loose.signal, ref: false })); + clearTimeout(keepAlive); + + try { + await sleep(1, "x", { signal: {} as unknown as AbortSignal }); + } catch (e) { + console.log("not a signal:", (e as Error).name, (e as { code?: string }).code, (e as Error).message); + } + + const order: string[] = []; + const race = new AbortController(); + const raced = sleep(1000, "x", { signal: race.signal }); + const done = raced.catch(() => order.push("rejected")); + race.abort(); + void Promise.resolve() + .then(() => order.push("m1")) + .then(() => order.push("m2")) + .then(() => order.push("m3")) + .then(() => order.push("m4")) + .then(() => order.push("m5")); + await done; + await sleep(1); + console.log("order:", order.join(" ")); + + const fired: string[] = []; + const quick = new AbortController(); + const settled = sleep(1, "v", { signal: quick.signal }).then((v) => fired.push(v)); + setTimeout(() => { + void Promise.resolve() + .then(() => fired.push("n1")) + .then(() => fired.push("n2")) + .then(() => fired.push("n3")) + .then(() => fired.push("n4")); + }, 1); + await settled; + await sleep(5); + console.log("fire order:", fired.join(" ")); +} + +void main(); diff --git a/tests/corpus/5831-util-types-brands.ts b/tests/corpus/5831-util-types-brands.ts new file mode 100644 index 000000000..d38200914 --- /dev/null +++ b/tests/corpus/5831-util-types-brands.ts @@ -0,0 +1,73 @@ +// util.types brand checks over typed values: isDate, isSharedArrayBuffer, +// isMapIterator, isSetIterator and isKeyObject answer from the value's +// internal brand, and isBoxedPrimitive (with the per-kind boxed checks), +// isBigInt64Array, isBigUint64Array, isCryptoKey and isExternal answer +// false for every value a program here can create. A Date-typed argument +// answers from its static type. +import { types } from "node:util"; +import * as direct from "node:util/types"; +import { createSecretKey } from "node:crypto"; + +const map = new Map([["a", 1]]); +const set = new Set([1, 2]); +const values: [string, unknown][] = [ + ["shared buffer", new SharedArrayBuffer(8)], + ["array buffer", new ArrayBuffer(8)], + ["map keys", map.keys()], + ["map entries", map.entries()], + ["map values", map.values()], + ["set values", set.values()], + ["set entries", set.entries()], + ["array values", [1, 2].values()], + ["map", map], + ["set", set], + ["key object", createSecretKey(Buffer.from("secret"))], + ["number", 1], + ["string", "s"], + ["boolean", true], + ["bigint", 1n], + ["symbol", Symbol("s")], + ["object", { getTime: () => 0 }], + ["uint8", new Uint8Array(2)], + ["null", null], + ["undefined", undefined], +]; + +function brands(value: unknown): string { + const hits: string[] = []; + if (types.isDate(value)) hits.push("isDate"); + if (types.isSharedArrayBuffer(value)) hits.push("isSharedArrayBuffer"); + if (types.isAnyArrayBuffer(value)) hits.push("isAnyArrayBuffer"); + if (types.isMapIterator(value)) hits.push("isMapIterator"); + if (types.isSetIterator(value)) hits.push("isSetIterator"); + if (types.isKeyObject(value)) hits.push("isKeyObject"); + if (types.isBoxedPrimitive(value)) hits.push("isBoxedPrimitive"); + if (types.isNumberObject(value)) hits.push("isNumberObject"); + if (types.isStringObject(value)) hits.push("isStringObject"); + if (types.isBooleanObject(value)) hits.push("isBooleanObject"); + if (types.isBigIntObject(value)) hits.push("isBigIntObject"); + if (types.isSymbolObject(value)) hits.push("isSymbolObject"); + if (types.isBigInt64Array(value)) hits.push("isBigInt64Array"); + if (types.isBigUint64Array(value)) hits.push("isBigUint64Array"); + if (types.isFloat16Array(value)) hits.push("isFloat16Array"); + if (types.isCryptoKey(value)) hits.push("isCryptoKey"); + if (types.isExternal(value)) hits.push("isExternal"); + return hits.join(",") || "-"; +} + +for (const [label, value] of values) console.log(label, brands(value)); + +let reads = 0; +function date(ms: number): Date { + reads++; + return new Date(ms); +} +console.log("date", types.isDate(date(0)), types.isDate(date(Number.NaN)), direct.isDate(date(1))); +console.log("date others", types.isMap(date(0)), types.isRegExp(date(0)), types.isBoxedPrimitive(date(0)), types.isNativeError(date(0)), reads); + +// The node:util/types spellings and a consumed iterator. +const it = map.keys(); +for (const _ of it) { + // drain +} +console.log("drained", direct.isMapIterator(it), direct.isSetIterator(set.keys())); diff --git a/tests/corpus/5832-util-types-brands-dynamic.js b/tests/corpus/5832-util-types-brands-dynamic.js new file mode 100644 index 000000000..cc8688f5d --- /dev/null +++ b/tests/corpus/5832-util-types-brands-dynamic.js @@ -0,0 +1,31 @@ +// util.types brand checks over checked-dynamic values: Dates, Map and Set +// iterators (before and after they are consumed), SharedArrayBuffer and +// KeyObject carry their brands; arrays, plain objects and objects that +// imitate a Date or an iterator do not. +import { types } from "node:util"; +import { createSecretKey } from "node:crypto"; + +const map = new Map([["a", 1]]); +const set = new Set([1, 2]); +const drained = set.values(); +for (const _ of drained) { + // drain +} +const values = [ + ["date", new Date(0)], + ["map keys", map.keys()], + ["map iterator", map[Symbol.iterator]()], + ["set values", set.values()], + ["drained set iterator", drained], + ["array iterator", [1].values()], + ["shared buffer", new SharedArrayBuffer(4)], + ["key object", createSecretKey(Buffer.from("k"))], + ["date-like", { getTime() { return 0; }, [Symbol.toStringTag]: "Date" }], + ["iterator-like", { next() { return { done: true }; }, [Symbol.toStringTag]: "Map Iterator" }], + ["array", [1, 2]], + ["number", 1], +]; +const probes = ["isDate", "isMapIterator", "isSetIterator", "isSharedArrayBuffer", "isKeyObject", "isBoxedPrimitive", "isNumberObject", "isBigInt64Array", "isExternal"]; +for (const [label, value] of values) { + console.log(label, probes.filter((name) => types[name](value)).join(",") || "-"); +} diff --git a/tests/corpus/5833-url-search-params-iterators.ts b/tests/corpus/5833-url-search-params-iterators.ts new file mode 100644 index 000000000..eb0b99d33 --- /dev/null +++ b/tests/corpus/5833-url-search-params-iterators.ts @@ -0,0 +1,40 @@ +// URLSearchParams keys(), values() and entries() outside a for-of head: +// a stored iterator steps with next() ({ value, done }), spreads from its +// current position, and walks the live pair list by index, so pairs +// appended after it was created are visited. Spreads, Array.from, Object.fromEntries and +// new Map over the params or their iterators read the same pairs. +const sp = new URLSearchParams("b=2&a=1&a=3&c=%20x"); + +const keys = sp.keys(); +console.log(JSON.stringify(keys.next()), JSON.stringify(keys.next())); +sp.append("d", "4"); +console.log(JSON.stringify([...keys])); +console.log(JSON.stringify(keys.next())); + +console.log(JSON.stringify([...sp])); +console.log(JSON.stringify([...sp.keys()])); +console.log(JSON.stringify([...sp.values()])); +console.log(JSON.stringify([...sp.entries()])); +console.log(JSON.stringify(Array.from(sp))); +console.log(JSON.stringify(Array.from(sp.entries()))); +console.log(JSON.stringify(Array.from(sp.values()))); +console.log(JSON.stringify(Object.fromEntries(sp))); +console.log(JSON.stringify(Object.fromEntries(sp.entries()))); +console.log(JSON.stringify([...new Map(sp)])); +console.log(JSON.stringify([...new Map(sp.entries())])); + +const values = sp.values(); +const seen: string[] = []; +for (const v of values) { + seen.push(v); + if (v === "1") sp.delete("b"); +} +console.log(JSON.stringify(seen), sp.toString()); + +const entries = new URL("https://h.example/p?x=1&y=2&x=3").searchParams.entries(); +const pairs: string[] = []; +for (const [k, v] of entries) pairs.push(`${k}:${v}`); +console.log(pairs.join(" ")); + +const empty = new URLSearchParams().entries(); +console.log(JSON.stringify(empty.next()), JSON.stringify([...empty])); diff --git a/tests/corpus/5834-url-search-params-iterators-dynamic.mjs b/tests/corpus/5834-url-search-params-iterators-dynamic.mjs new file mode 100644 index 000000000..82b0c6d28 --- /dev/null +++ b/tests/corpus/5834-url-search-params-iterators-dynamic.mjs @@ -0,0 +1,22 @@ +// URLSearchParams iterators in a checked-dynamic program: next() results, +// live iteration after append and delete, and the consumers (spread, +// Array.from with and without a mapper, Object.fromEntries, new Map) over +// the params and each projection. +const sp = new URLSearchParams("b=2&a=1&a=3"); +const it = sp.keys(); +console.log(it.next()); +sp.append("c", "4"); +console.log(JSON.stringify([...it]), it.next()); +console.log(JSON.stringify([...sp.values()]), JSON.stringify(Array.from(sp.entries()))); +console.log(JSON.stringify(Array.from(sp.keys(), (k) => k.toUpperCase()))); +console.log(JSON.stringify(Object.fromEntries(sp.entries())), JSON.stringify(Object.fromEntries(sp))); +console.log(JSON.stringify([...new Map(sp.entries())]), new Map(sp).size); +const values = sp.values(); +const seen = []; +let step = values.next(); +while (!step.done) { + seen.push(step.value); + if (step.value === "2") sp.delete("a"); + step = values.next(); +} +console.log(JSON.stringify(seen), sp.toString()); diff --git a/tests/corpus/5835-util-format-integer-specifiers.ts b/tests/corpus/5835-util-format-integer-specifiers.ts new file mode 100644 index 000000000..ed5648761 --- /dev/null +++ b/tests/corpus/5835-util-format-integer-specifiers.ts @@ -0,0 +1,29 @@ +// util.format and console.log %d / %i over arguments Node formats without a +// number conversion: a bigint prints with its n suffix, a symbol as NaN, +// null as 0 under %d and NaN under %i, and undefined as NaN. Arguments still +// evaluate in order. +import { format } from "node:util"; + +const calls: string[] = []; +function big(n: bigint): bigint { + calls.push(`big ${n}`); + return n; +} +function sym(): symbol { + calls.push("sym"); + return Symbol("s"); +} +function nothing(): undefined { + calls.push("nothing"); + return undefined; +} + +console.log(format("%d %i", 10n, -5n)); +console.log(format("%d", 2n ** 70n), format("%i", -(2n ** 65n))); +console.log(format("%d|%i", 0n, 0n)); +console.log(format("%d %i", Symbol("x"), Symbol.iterator)); +console.log(format("%d %i", null, null)); +console.log(format("%d %i", undefined, undefined)); +console.log(format("%d-%i-%d", big(3n), sym(), nothing()), calls.join(", ")); +console.log("count %d of %i", 42n, 7n); +console.log("missing %d", null, "extra"); diff --git a/tests/corpus/5836-url-search-params-tuple-pairs.ts b/tests/corpus/5836-url-search-params-tuple-pairs.ts new file mode 100644 index 000000000..d6fe1a86a --- /dev/null +++ b/tests/corpus/5836-url-search-params-tuple-pairs.ts @@ -0,0 +1,34 @@ +// new URLSearchParams(pairs) over [string, string] tuple rows, such as an +// Object.entries result: each pair appends its name and value in array +// order, so repeated names keep every value and an empty array gives empty +// params. The params are then as mutable as any other. +const record: Record = { a: "1", "b c": "x&y", é: "ü" }; +const fromEntries = new URLSearchParams(Object.entries(record)); +console.log(fromEntries.toString(), fromEntries.size); + +const pairs: [string, string][] = [ + ["k", "v"], + ["k", "w"], + ["", ""], + ["plus", "a+b c"], +]; +const repeated = new URLSearchParams(pairs); +console.log(repeated.toString(), JSON.stringify(repeated.getAll("k"))); + +const none: [string, string][] = []; +const empty = new URLSearchParams(none); +console.log(JSON.stringify(empty.toString()), empty.size); + +const edited = new URLSearchParams(pairs); +edited.append("y", "2"); +edited.delete("k"); +edited.sort(); +console.log(edited.toString(), pairs.length); + +const lone: [string, string][] = [["z", "\uD800x"]]; +console.log(new URLSearchParams(lone).toString()); + +const url = new URL("https://example.test/?q=1"); +for (const [name, value] of new URLSearchParams(Object.entries({ q: "2", r: "3" }))) + url.searchParams.set(name, value); +console.log(url.href); diff --git a/tests/corpus/5837-util-format-json-circular.ts b/tests/corpus/5837-util-format-json-circular.ts new file mode 100644 index 000000000..5e79c7ce2 --- /dev/null +++ b/tests/corpus/5837-util-format-json-circular.ts @@ -0,0 +1,67 @@ +// util.format, formatWithOptions and console.log %j over circular values: +// Node's tryStringify prints "[Circular]" when JSON.stringify throws the +// circular-structure TypeError, and rethrows every other error. A checked- +// dynamic argument stringifies as JSON.stringify does (toJSON hooks, +// dropped members, shared references); a recursive typed value prints +// "[Circular]" where JSON.stringify throws. +import { format, formatWithOptions } from "node:util"; + +const parsed = JSON.parse('{"name":"root","children":[{"name":"leaf"}]}'); +parsed.children[0].parent = parsed; +console.log(format("%j", parsed)); +console.log(format("%j|%j", parsed.children, parsed.children[0].name)); +console.log("%j", parsed); + +const bag: Record = { id: 1 }; +bag.self = bag; +console.log(format("%j", bag), formatWithOptions({}, "[%j]", bag)); + +const list: unknown[] = [1, 2]; +list.push(list); +console.log(format("%j", list), format("%j", [list])); + +const shared = { s: 1 }; +const twice: unknown = { a: shared, b: shared }; +console.log(format("%j", twice)); + +const hooked: unknown = { toJSON: (key: string) => `toJSON(${JSON.stringify(key)})` }; +const dropped: unknown = { skip: undefined, n: NaN, f: () => 1 }; +console.log(format("%j %j", hooked, dropped)); + +const big: unknown = { n: 1n }; +try { + format("%j", big); +} catch (error) { + console.log("rethrown", (error as Error).name, (error as Error).message); +} +const ranged: unknown = { + toJSON: () => { + throw new RangeError("Converting circular structure to JSON"); + }, +}; +try { + format("%j", ranged); +} catch (error) { + console.log("rethrown", (error as Error).name); +} +const firstLine: unknown = { + toJSON: () => { + throw new TypeError("Converting circular structure to JSON\n --> elsewhere"); + }, +}; +console.log(format("%j", firstLine)); + +interface Item { + name: string; + next: Item | null; +} +const item: Item = { name: "a", next: null }; +console.log(format("%j", item)); +item.next = { name: "b", next: item }; +console.log(format("%j", item), format("%j", [item])); +console.log("%j and %j", { item }, 5); +try { + JSON.stringify(item); +} catch (error) { + console.log((error as Error).name, (error as Error).message.split("\n")[0]); +} diff --git a/tests/corpus/5838-qs-stringify-bigint.ts b/tests/corpus/5838-qs-stringify-bigint.ts new file mode 100644 index 000000000..cdc668a55 --- /dev/null +++ b/tests/corpus/5838-qs-stringify-bigint.ts @@ -0,0 +1,19 @@ +// querystring.stringify writes a bigint value as its decimal digits, bare +// like a boolean (Node's encodeStringified), in plain and array-valued +// fields and under custom separators. url.format's query object follows +// the same rule. +import { encode, stringify } from "node:querystring"; +import { format } from "node:url"; + +console.log(stringify({ a: 1n, b: -2n, c: 0n })); +console.log(stringify({ big: 12345678901234567890n, neg: -(2n ** 70n) })); +console.log(stringify({ list: [1n, "two", 3, true, -4n] })); +console.log(stringify({ a: 5n, b: [6n, 7n] }, ";", ":")); +console.log(encode({ id: 42n, name: "x y" })); + +const counts: Record = { first: 10n, second: 20n }; +console.log(stringify(counts)); +const mixed: Record = { n: 1n, s: "v", z: null }; +console.log(stringify(mixed)); + +console.log(format({ protocol: "https", host: "example.test", pathname: "/p", query: { id: 99n, tag: ["a", 2n] } })); diff --git a/tests/corpus/5839-timers-promises-scheduler.ts b/tests/corpus/5839-timers-promises-scheduler.ts new file mode 100644 index 000000000..349674744 --- /dev/null +++ b/tests/corpus/5839-timers-promises-scheduler.ts @@ -0,0 +1,39 @@ +// timers/promises scheduler.wait(delay[, options]) and scheduler.yield(): +// Node defines them as setTimeout(delay, undefined, options) and +// setImmediate(), so both resolve undefined, an omitted delay waits the +// 1 ms floor, and they order against timers, immediates and microtasks +// like those calls. The namespace and named-import spellings compile the +// same. `ref: false` lets the process exit before the wait settles. +import { scheduler, setTimeout as sleep } from "node:timers/promises"; +import * as timersPromises from "node:timers/promises"; + +const events: string[] = []; + +async function main(): Promise { + const waited = scheduler.wait(1).then((value) => typeof value); + const yielded = scheduler.yield().then((value) => typeof value); + console.log(await waited, await yielded); + await scheduler.wait(); + await timersPromises.scheduler.wait(2, { ref: true }); + await timersPromises.scheduler.yield(); + console.log("namespace forms resolved"); + + const done = Promise.all([ + scheduler.wait(40).then(() => events.push("wait 40")), + sleep(5).then(() => events.push("sleep 5")), + scheduler.yield().then(() => events.push("yield")), + Promise.resolve().then(() => events.push("microtask")), + ]); + setImmediate(() => events.push("setImmediate")); + await done; + console.log(events.join(", ")); + + const start = Date.now(); + await scheduler.wait(30); + console.log("waited at least 25 ms:", Date.now() - start >= 25); + + scheduler.wait(60_000, { ref: false }).then(() => console.log("never printed")); + console.log("main done"); +} + +main(); 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/corpus/5842-qs-parse-null-prototype.ts b/tests/corpus/5842-qs-parse-null-prototype.ts new file mode 100644 index 000000000..02d5cc134 --- /dev/null +++ b/tests/corpus/5842-qs-parse-null-prototype.ts @@ -0,0 +1,34 @@ +// querystring.parse and querystring.decode return a null-prototype +// object: inspection prints the "[Object: null prototype]" base, +// getPrototypeOf answers null, and ToPrimitive and Object.prototype +// methods throw TypeErrors. Spread copies keep Object.prototype. +import * as qs from "node:querystring"; +import { inspect } from "node:util"; + +const parsed = qs.parse("a=1&b=2&b=3&c"); +console.log(parsed); +console.log(inspect(parsed), inspect({ nested: parsed }), inspect([qs.parse("")])); +console.log(qs.decode("x=1;y=2", ";")); +console.log(Object.getPrototypeOf(parsed) === null, Object.keys(parsed), JSON.stringify(parsed)); + +const attempt = (label: string, f: () => unknown): void => { + try { + console.log(label, f()); + } catch (e) { + console.log(label, (e as Error).name, (e as Error).message); + } +}; +attempt("String", () => String(parsed)); +attempt("template", () => `${parsed}`); +attempt("concat", () => "q=" + parsed); +attempt("hasOwnProperty", () => parsed.hasOwnProperty("a")); +attempt("toString", () => parsed.toString()); +console.log(Object.prototype.toString.call(parsed), Object.hasOwn(parsed, "b")); +console.log(Object.prototype.hasOwnProperty.call(parsed, "c")); + +const copy = { ...parsed }; +console.log(copy, Object.getPrototypeOf(copy) === Object.prototype, String(copy)); + +parsed.added = "yes"; +delete parsed.a; +console.log(parsed, parsed.b, parsed.added); diff --git a/tests/corpus/5843-qs-parse-null-prototype-js.mjs b/tests/corpus/5843-qs-parse-null-prototype-js.mjs new file mode 100644 index 000000000..676d188c4 --- /dev/null +++ b/tests/corpus/5843-qs-parse-null-prototype-js.mjs @@ -0,0 +1,12 @@ +// querystring.parse results keep their null prototype when they cross into +// checked-dynamic values in a JavaScript module: inspection, getPrototypeOf, +// ToPrimitive and the `in` operator. +import qs from "node:querystring"; +const parsed = qs.parse("a=1&b=2&b=3"); +const holder = { parsed, list: [parsed] }; +console.log(holder); +const dyn = JSON.parse("{}"); +dyn.p = parsed; +console.log(dyn, Object.getPrototypeOf(dyn.p) === null); +try { console.log(String(dyn.p)); } catch (e) { console.log(e.name, e.message); } +console.log("toString" in parsed, "a" in parsed); 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/corpus/5856-error-is-error.ts b/tests/corpus/5856-error-is-error.ts new file mode 100644 index 000000000..b354173cd --- /dev/null +++ b/tests/corpus/5856-error-is-error.ts @@ -0,0 +1,77 @@ +// Error.isError over class instances, unknown values, catch bindings, unions +// and computed arguments: errors and subclasses of Error answer true, objects +// that only look like errors answer false. Node.js prints the same. +import { readFileSync } from "node:fs"; + +class Plain { + message = "m"; + name = "Error"; +} +class AppError extends Error {} +class DeepError extends AppError {} + +function check(label: string, v: unknown): void { + console.log(label, Error.isError(v)); +} +check("Error", new Error("a")); +check("TypeError", new TypeError("t")); +check("subclass", new DeepError("d")); +check("AggregateError", new AggregateError([new Error("x")], "agg")); +check("DOMException", new DOMException("d", "AbortError")); +check("SuppressedError", new SuppressedError(new Error("a"), new Error("b"), "s")); +check("plain class", new Plain()); +check("record", { message: "x", name: "Error" }); +check("string", "Error"); +check("undefined", undefined); +check("array of errors", [new Error("in array")]); +check("parsed", JSON.parse('{"message":"x","name":"Error"}')); + +console.log("direct", Error.isError(new AppError("x")), Error.isError(null), Error.isError(42)); +console.log("map", Error.isError(new Map()), Error.isError(/x/), Error.isError(Promise.resolve(1))); + +try { + readFileSync("/no/such/file"); +} catch (e) { + console.log("caught fs", Error.isError(e)); + if (Error.isError(e)) console.log("narrowed", e.name, e.message.split(":")[0]); +} +try { + JSON.parse("{"); +} catch (e) { + console.log("caught syntax", Error.isError(e), e instanceof SyntaxError); +} +try { + throw "text"; +} catch (e) { + console.log("caught string", Error.isError(e)); +} +try { + throw new Plain(); +} catch (e) { + console.log("caught plain", Error.isError(e)); +} + +const values: (AppError | Plain | string)[] = [new AppError("x"), new Plain(), "s"]; +for (const v of values) console.log("union", Error.isError(v)); +const maybe: Error | undefined = values.length > 5 ? new Error("never") : undefined; +console.log("optional", Error.isError(maybe)); +const errors: Error[] = [new RangeError("r")]; +console.log("element", Error.isError(errors[0]), Error.isError(errors)); +console.log("mapped", [new Error("e"), 1, "s"].map((v) => Error.isError(v)).join(",")); + +let calls = 0; +function make(): Plain { + calls++; + return new Plain(); +} +console.log("computed", Error.isError(make()), calls); + +function describe(x: unknown): string { + return Error.isError(x) ? `${x.name}: ${x.message}` : `not an error: ${String(x)}`; +} +console.log(describe(new RangeError("out of range"))); +console.log(describe(5)); + +Promise.reject(new TypeError("rejected")).catch((r: unknown) => { + console.log("rejection", Error.isError(r)); +}); diff --git a/tests/corpus/5857-regexp-unicode-sets.ts b/tests/corpus/5857-regexp-unicode-sets.ts new file mode 100644 index 000000000..3bfdaf692 --- /dev/null +++ b/tests/corpus/5857-regexp-unicode-sets.ts @@ -0,0 +1,48 @@ +// The regex v flag (unicode sets): class subtraction and intersection, nested +// classes, \q{...} string literals, properties of strings, case folding, +// code-point iteration, and the constructor with literal and computed flags. +// Regex literals report their flags in getter order. Node.js prints the same. +const letters = /[\p{L}--[a-z]]/gv; +console.log(letters.flags, letters.source, String(letters), letters); +console.log("aBcD-É".match(letters)); +console.log(/[[a-z]&&[aeiou]]/v.test("e"), /[[a-z]&&[aeiou]]/v.test("b")); +console.log("vowels: education".replace(/[[a-z]&&[^aeiou]]/gv, "_")); +console.log("x😀y".match(/[\q{😀|xy}]/gv), "xy😀".match(/[\q{😀|xy}]/gv)); +console.log("abcab".match(/[\q{abc}a]/gv)); +console.log(/^\p{RGI_Emoji}$/v.test("👍🏽"), /^\p{RGI_Emoji}$/v.test("a")); +console.log("hi 🇫🇷 1️⃣".match(/\p{RGI_Emoji_Flag_Sequence}|\p{Emoji_Keycap_Sequence}/gv)); +console.log("family \u{1F468}\u200D\u{1F469}\u200D\u{1F467} ok".replace(/\p{RGI_Emoji_ZWJ_Sequence}/v, "[zwj]")); +console.log(/[^\d--[5]]/v.test("5"), /[^\d--[5]]/v.test("7"), /[^\d--[5]]/v.test("x")); +console.log(/[[0-9]--[5-9]]+/v.exec("98123")?.[0]); +console.log(/[\p{Lu}--[A-C]]/vi.test("a"), /[\p{Lu}--[A-C]]/vi.test("d"), /[^\p{Lu}]/vi.test("A")); +console.log("😀😀".replace(/(?:)/gv, "-"), "😀😀".split(/(?:)/v)); +console.log("a😀b".replace(/./gv, (c) => `<${c}>`)); +console.log("A1b2C3".replace(/[\w--\d]/gv, (c) => c.toLowerCase())); +console.log("one, two;three".split(/[[\p{P}\s]--[\-]]+/v)); +const m = /(?[\p{L}--\p{Lu}]+)-(?[\d--[0]]+)/v.exec("ABcd-102"); +if (m) console.log(m[0], m.index, m.groups?.word, m.groups?.num); +console.log("2024-07".replace(/(?[\d]{4})-(?\d\d)/v, "$/$")); + +const spelled = /x/vgimsy; +console.log(spelled.flags, String(spelled), /x/yg.flags, /x/mi.flags); +console.log(letters.global, letters.unicodeSets, letters.unicode, letters.ignoreCase, letters.hasIndices); +console.log(spelled.multiline, spelled.dotAll, spelled.sticky, /x/u.unicode, /x/u.unicodeSets); + +console.log(new RegExp("[\\w--\\d]", "v").test("5"), new RegExp("[\\w--\\d]", "vg").flags); +const computed = ["v", "gv", "iv"]; +for (const flags of computed) { + const re = new RegExp("[[0-9a-f]--[5]]", flags); + console.log(flags, re.flags, re.test("5"), re.test("F")); +} +for (const flags of ["uv", "vv"]) { + try { + new RegExp("a", flags); + } catch (e) { + console.log((e as Error).name, (e as Error).message); + } +} +const u: unknown = /[\q{abc}]/v; +console.log(u instanceof RegExp, String(u)); +// Punctuation that only class contents reserve stays literal outside them. +console.log(/x-y/v.test("x-y"), /a,,b/v.test("a,,b"), /a&&b|!!/v.exec("!!")?.[0], /a--b/v.test("a--b")); +console.log("a-b".match(/[\q{\-}]/gv), "key-value".split(/-/v)); diff --git a/tests/corpus/5858-disposable-stack.ts b/tests/corpus/5858-disposable-stack.ts new file mode 100644 index 000000000..749730997 --- /dev/null +++ b/tests/corpus/5858-disposable-stack.ts @@ -0,0 +1,157 @@ +// DisposableStack: use, adopt, defer, move and dispose with class instances, +// object literals and callbacks; LIFO disposal; SuppressedError chains; +// Node's ReferenceError and TypeError texts; using over a stack; inspection; +// and stacks whose callbacks capture the stack. Node.js prints the same. +const log: string[] = []; +class Resource { + readonly name: string; + constructor(name: string) { + this.name = name; + } + [Symbol.dispose](): void { + log.push("dispose " + this.name); + } +} +class Failing { + readonly name: string; + constructor(name: string) { + this.name = name; + } + [Symbol.dispose](): void { + throw new Error("fail " + this.name); + } +} +function scope(): void { + using stack = new DisposableStack(); + const a = stack.use(new Resource("a")); + log.push("got " + a.name); + stack.defer(() => log.push("deferred")); + const n = stack.adopt(42, (v) => log.push("adopted " + v)); + const r = stack.adopt(new Resource("adopted-res"), (v) => log.push("adopt " + v.name)); + stack.use({ [Symbol.dispose]() { log.push("literal"); } }); + stack.use(null); + stack.use(undefined); + log.push("n=" + n + " r=" + r.name + " disposed=" + stack.disposed); +} +scope(); +console.log(log.join("\n")); +log.length = 0; + +const outer = new DisposableStack(); +outer.use(new Resource("x")); +const moved = outer.move(); +console.log(outer.disposed, moved.disposed, outer instanceof DisposableStack, moved instanceof DisposableStack); +moved.dispose(); +moved.dispose(); +console.log(log.join(","), moved.disposed); +log.length = 0; + +function attempt(label: string, f: () => void): void { + try { + f(); + console.log(label, "ok"); + } catch (e) { + if (e instanceof SuppressedError) { + const chain: string[] = []; + let cur: unknown = e; + while (cur instanceof SuppressedError) { + chain.push((cur.error as Error).message); + cur = cur.suppressed; + } + chain.push(cur instanceof Error ? cur.message : String(cur)); + console.log(label, "SuppressedError", e.message, chain.join(" <- ")); + } else if (e instanceof Error) { + console.log(label, e.name, e.message); + } else { + console.log(label, "thrown", e); + } + } +} +attempt("three failures", () => { + const s = new DisposableStack(); + s.use(new Failing("one")); + s.defer(() => { throw new Error("two"); }); + s.use(new Failing("three")); + s.dispose(); +}); +attempt("single", () => { + const s = new DisposableStack(); + s.use(new Resource("before")); + s.defer(() => { throw new RangeError("only"); }); + s.dispose(); +}); +console.log(log.join(",")); +attempt("thrown value", () => { + const s = new DisposableStack(); + s.defer(() => { throw 7; }); + s.dispose(); +}); +attempt("using body error", () => { + using s = new DisposableStack(); + s.defer(() => { throw new Error("disposal"); }); + throw new Error("body"); +}); +const done = new DisposableStack(); +done.dispose(); +attempt("use after dispose", () => { done.use(new Resource("late")); }); +attempt("defer after dispose", () => done.defer(() => {})); +attempt("adopt after dispose", () => { done.adopt(1, () => {}); }); +attempt("move after dispose", () => { done.move(); }); +attempt("dispose twice", () => done.dispose()); +attempt("use non-object", () => { new DisposableStack().use(5 as unknown as Disposable); }); +attempt("use missing method", () => { new DisposableStack().use({} as unknown as Disposable); }); +attempt("defer non-function", () => new DisposableStack().defer(3 as unknown as () => void)); +attempt("adopt non-function", () => { new DisposableStack().adopt(1, "x" as unknown as () => void); }); +const reentrant = new DisposableStack(); +reentrant.defer(() => { + console.log("inside disposed", reentrant.disposed); + attempt("defer during dispose", () => reentrant.defer(() => {})); +}); +reentrant.dispose(); +const shown = new DisposableStack(); +console.log(shown, String(shown), Object.prototype.toString.call(shown), typeof shown, JSON.stringify(shown)); +const self = new DisposableStack(); +self.defer(() => console.log("self disposed", self.disposed)); +self[Symbol.dispose](); +const nested = new DisposableStack(); +const inner = nested.use(new DisposableStack()); +inner.defer(() => console.log("inner via outer")); +nested.dispose(); +console.log(inner.disposed); +class Base { + readonly name: string; + constructor(name: string) { + this.name = name; + } + [Symbol.dispose](): void { + console.log("base dispose", this.name); + } +} +class Derived extends Base { + override [Symbol.dispose](): void { + console.log("derived dispose", this.name); + } +} +class Plain extends Base {} +const inherit = new DisposableStack(); +const items: Base[] = [new Derived("d"), new Plain("p"), new Base("b")]; +for (const item of items) inherit.use(item); +const back = inherit.use(new Derived("returned")); +console.log(back.name, back instanceof Derived); +inherit.dispose(); +// A stack whose callback captures the stack is collected without disposal. +for (let i = 0; i < 3; i++) { + const cyc = new DisposableStack(); + cyc.defer(() => console.log("never", cyc.disposed)); +} +const holder = { stack: new DisposableStack() }; +console.log(holder, [holder.stack]); +function make(): DisposableStack { + const st = new DisposableStack(); + st.defer(() => console.log("made stack disposed")); + return st; +} +{ + using made = make(); + console.log("in block", made.disposed); +} diff --git a/tests/corpus/5859-disposable-stack-js.js b/tests/corpus/5859-disposable-stack-js.js new file mode 100644 index 000000000..491beee16 --- /dev/null +++ b/tests/corpus/5859-disposable-stack-js.js @@ -0,0 +1,77 @@ +// DisposableStack from JavaScript: untyped resources and callbacks, methods +// detached from the stack, and Node's TypeError and ReferenceError texts for +// invalid receivers, resources and callbacks. Node.js prints the same. +const attempt = (label, f) => { + try { + console.log(label, f()); + } catch (e) { + console.log(label, e.name + ": " + e.message); + } +}; +const s = new DisposableStack(); +attempt("use number", () => s.use(5)); +attempt("use empty object", () => s.use({})); +attempt("use null", () => s.use(null)); +attempt("use undefined", () => s.use(undefined)); +for (const v of ["x", 5, {}, [], null, undefined, true, Symbol("q"), 1n, new Map()]) { + attempt("defer", () => s.defer(v)); +} +attempt("adopt", () => s.adopt(1, 2)); +const { defer, use, dispose, move } = s; +attempt("detached defer", () => defer(() => {})); +attempt("use on object", () => use.call({}, null)); +attempt("dispose on number", () => dispose.call(5)); +attempt("move on string", () => move.call("x")); +console.log(s.defer.length, s.use.length, s.adopt.length, s.move.length, s.dispose.length); +console.log(s.defer.name, s[Symbol.dispose].name, s[Symbol.dispose] === s.dispose); +console.log(typeof s.disposed, s.disposed, JSON.stringify(s), String(s), s); + +const order = []; +const resource = (name) => ({ + [Symbol.dispose]() { + order.push("dispose " + name); + }, +}); +const stack = new DisposableStack(); +console.log(stack.use(resource("a")) !== null, stack.adopt(7, (v) => order.push("adopt " + v))); +stack.defer(() => order.push("defer")); +stack.use(resource("b")); +const moved = stack.move(); +moved.defer(() => order.push("after move")); +console.log(stack.disposed, moved.disposed, moved instanceof DisposableStack); +moved[Symbol.dispose](); +console.log(order.join(", ")); +console.log(moved.dispose(), moved.disposed); +attempt("use after dispose", () => moved.use(null)); +attempt("adopt after dispose", () => moved.adopt(1, () => {})); + +const failing = new DisposableStack(); +failing.defer(() => { + throw new Error("one"); +}); +failing.defer(() => { + throw new TypeError("two"); +}); +failing.defer(() => { + throw "three"; +}); +try { + failing.dispose(); +} catch (e) { + console.log(e.name, e.message, e.error.name, e.suppressed.error.message, e.suppressed.suppressed); +} +const getterResource = { + get [Symbol.dispose]() { + console.log("getter read"); + return () => console.log("getter resource disposed"); + }, +}; +const g = new DisposableStack(); +g.use(getterResource); +console.log("after use"); +g.dispose(); +{ + using scoped = new DisposableStack(); + scoped.defer(() => console.log("scope exit")); + console.log("in scope"); +} diff --git a/tests/corpus/5870-worker-thread-names.ts b/tests/corpus/5870-worker-thread-names.ts new file mode 100644 index 000000000..ce5d98cca --- /dev/null +++ b/tests/corpus/5870-worker-thread-names.ts @@ -0,0 +1,35 @@ +import { Worker, isMainThread, parentPort, threadId, threadName, workerData } from "node:worker_threads"; + +// The Worker name option: a truthy name must be a string and is trimmed, +// any other value selects "WorkerThread"; threadName reports it inside the +// worker and on the Worker until exit (null afterwards), and is "" in the +// main thread. +if (isMainThread) { + console.log("main", threadId, JSON.stringify(threadName)); + for (const name of [5, true, {}] as unknown[]) { + try { + const options: Record = { name }; + new Worker(new URL(import.meta.url), options); + } catch (e) { + const err = e as Error & { code?: string }; + console.log(JSON.stringify(name), err.name, err.code, err.message); + } + } + const names: unknown[] = [" reader one ", "", " ", 0, null, undefined, "a\tb "]; + let next = 0; + const run = (): void => { + if (next >= names.length) return; + const name = names[next++]; + const options: Record = { name, workerData: next }; + const worker = new Worker(new URL(import.meta.url), options); + console.log(JSON.stringify(name), "threadName", JSON.stringify(worker.threadName)); + worker.on("message", (m: unknown) => console.log(" worker saw", JSON.stringify(m))); + worker.on("exit", (code: number) => { + console.log(" exit", code, JSON.stringify(worker.threadName)); + run(); + }); + }; + run(); +} else { + parentPort!.postMessage([workerData, threadName, threadId > 0]); +} diff --git a/tests/corpus/5871-worker-untransferable.cjs b/tests/corpus/5871-worker-untransferable.cjs new file mode 100644 index 000000000..b79482dde --- /dev/null +++ b/tests/corpus/5871-worker-untransferable.cjs @@ -0,0 +1,56 @@ +'use strict'; +// markAsUntransferable and isMarkedAsUntransferable: marks on objects, +// ArrayBuffers (a typed array and its ArrayBuffer are marked separately), +// functions and MessagePorts; primitives are never marked. A marked +// ArrayBuffer or port in a transfer list throws DataCloneError, and so does +// the ArrayBuffer of a pooled Buffer, which Node marks. Cloning a marked +// value is unaffected. +const { MessageChannel, isMarkedAsUntransferable, markAsUntransferable } = require('node:worker_threads'); + +const ab = new ArrayBuffer(8); +const before = isMarkedAsUntransferable(ab); +markAsUntransferable(ab); +console.log('arraybuffer', before, isMarkedAsUntransferable(ab)); +const obj = { a: 1 }; +const objectBefore = isMarkedAsUntransferable(obj); +markAsUntransferable(obj); +console.log('object', objectBefore, isMarkedAsUntransferable(obj), isMarkedAsUntransferable({ a: 1 })); +const fn = () => 1; +markAsUntransferable(fn); +console.log('function', isMarkedAsUntransferable(fn)); +markAsUntransferable(5); +console.log('primitives', isMarkedAsUntransferable(5), isMarkedAsUntransferable('s'), isMarkedAsUntransferable(null), isMarkedAsUntransferable(undefined), isMarkedAsUntransferable()); +const view = new Uint8Array(4); +markAsUntransferable(view.buffer); +console.log('view buffer', isMarkedAsUntransferable(view), isMarkedAsUntransferable(view.buffer)); +const other = new Uint8Array(4); +markAsUntransferable(other); +console.log('view itself', isMarkedAsUntransferable(other), isMarkedAsUntransferable(other.buffer)); +console.log('pooled buffer', isMarkedAsUntransferable(Buffer.from('x').buffer), isMarkedAsUntransferable(Buffer.alloc(10).buffer), isMarkedAsUntransferable(Buffer.from('x'))); +const sab = new SharedArrayBuffer(4); +markAsUntransferable(sab); +console.log('shared', isMarkedAsUntransferable(sab)); + +const { port1, port2 } = new MessageChannel(); +const spare = new MessageChannel(); +markAsUntransferable(spare.port1); +console.log('port', isMarkedAsUntransferable(spare.port1), isMarkedAsUntransferable(spare.port2)); +for (const [label, value] of [['marked arraybuffer', ab], ['pooled arraybuffer', Buffer.from('ab').buffer], ['marked port', spare.port1]]) { + try { + port1.postMessage(value, [value]); + console.log(label, 'posted'); + } catch (e) { + console.log(label, e.name, e.message); + } +} +try { + port1.postMessage('options form', { transfer: [spare.port1] }); +} catch (e) { + console.log('options form', e.name, e.message); +} +port1.postMessage({ ab, obj }); +port2.on('message', (m) => { + console.log('cloned', m.ab.byteLength, JSON.stringify(m.obj), isMarkedAsUntransferable(m.ab), isMarkedAsUntransferable(m.obj)); + port1.close(); + spare.port1.close(); +}); diff --git a/tests/corpus/5874-worker-stdio-streams.ts b/tests/corpus/5874-worker-stdio-streams.ts new file mode 100644 index 000000000..28d2ff4a2 --- /dev/null +++ b/tests/corpus/5874-worker-stdio-streams.ts @@ -0,0 +1,28 @@ +import { Worker, isMainThread } from "node:worker_threads"; + +// The stdout and stderr options: the worker's console and process.stdout/ +// process.stderr writes reach worker.stdout and worker.stderr instead of +// the process output, as Readable streams that end after the worker's +// 'exit' event. worker.stdin is null without the stdin option. +if (isMainThread) { + const worker = new Worker(new URL(import.meta.url), { stdout: true, stderr: true }); + let out = ""; + let err = ""; + worker.stdout.setEncoding("utf8"); + worker.stdout.on("data", (chunk: string) => { + out += chunk; + }); + worker.stdout.on("end", () => console.log("stdout end", JSON.stringify(out))); + worker.stderr.on("data", (chunk: Buffer) => { + err += chunk.toString(); + }); + worker.stderr.on("end", () => console.log("stderr end", JSON.stringify(err))); + worker.on("exit", (code: number) => console.log("exit", code)); + console.log("stdin", worker.stdin); +} else { + console.log("hello from the worker"); + process.stdout.write("raw write\n"); + process.stdout.write(Buffer.from("bytes\n")); + console.error("an error line"); + process.stderr.write("err bytes\n"); +} diff --git a/tests/corpus/5875-worker-stdout-pipe.cjs b/tests/corpus/5875-worker-stdout-pipe.cjs new file mode 100644 index 000000000..8a9551a12 --- /dev/null +++ b/tests/corpus/5875-worker-stdout-pipe.cjs @@ -0,0 +1,24 @@ +'use strict'; +// worker.stdout as a pipe source: with only the stdout option, the +// worker's stdout writes reach the parent's Writable while its stderr +// output goes to the process's stderr as before. +const { Worker, isMainThread } = require('node:worker_threads'); +const { Writable } = require('node:stream'); + +if (isMainThread) { + const worker = new Worker(__filename, { stdout: true }); + const chunks = []; + const sink = new Writable({ + write(chunk, encoding, callback) { + chunks.push(chunk.toString().toUpperCase()); + callback(); + }, + }); + sink.on('finish', () => console.log('piped', JSON.stringify(chunks.join('')))); + worker.stdout.pipe(sink); + worker.on('exit', (code) => console.log('exit', code)); +} else { + console.log('first line'); + console.error('to stderr'); + console.log('second line'); +} diff --git a/tests/corpus/5879-worker-stdin.ts b/tests/corpus/5879-worker-stdin.ts new file mode 100644 index 000000000..197ee719c --- /dev/null +++ b/tests/corpus/5879-worker-stdin.ts @@ -0,0 +1,42 @@ +// The Worker stdin option: worker.stdin is a Writable whose chunks the +// worker reads from process.stdin, which ends when worker.stdin ends. +// Without the option worker.stdin is null and the worker's process.stdin +// ends without data. +import { Worker, isMainThread, parentPort } from "node:worker_threads"; +import { Writable } from "node:stream"; +import * as readline from "node:readline"; + +function run(mode: string, stdin: boolean, feed: (sink: Writable) => void, next: () => void): void { + const worker = new Worker(new URL(import.meta.url), { argv: [mode], stdin }); + const messages: string[] = []; + worker.on("message", (m: string) => messages.push(m)); + worker.on("exit", (code: number) => { + console.log(mode, "exit", code, JSON.stringify(messages)); + next(); + }); + if (worker.stdin === null) console.log(mode, "stdin", null); + else feed(worker.stdin); +} + +if (isMainThread) { + run("plain", false, () => {}, () => + run("chunks", true, (sink) => { + sink.write("first line\n"); + sink.write(Buffer.from("second ")); + sink.end("line\n", () => console.log("chunks end callback")); + }, () => + run("lines", true, (sink) => { + sink.write("alpha\nbeta"); + sink.end("\ngamma\n"); + }, () => console.log("done")))); +} else if (process.argv[2] === "lines") { + const rl = readline.createInterface({ input: process.stdin }); + rl.on("line", (line) => parentPort!.postMessage(line.toUpperCase())); + rl.on("close", () => parentPort!.postMessage("closed")); +} else { + const chunks: string[] = []; + process.stdin.on("data", (d: Uint8Array) => { + chunks.push(Buffer.from(d).toString()); + }); + process.stdin.on("end", () => parentPort!.postMessage(`${chunks.length} ${JSON.stringify(chunks.join(""))}`)); +} diff --git a/tests/corpus/5880-emitter-leak-warning-delivery.ts b/tests/corpus/5880-emitter-leak-warning-delivery.ts new file mode 100644 index 000000000..52ba5d257 --- /dev/null +++ b/tests/corpus/5880-emitter-leak-warning-delivery.ts @@ -0,0 +1,62 @@ +// The EventEmitter leak warning goes through process.emitWarning: it is +// delivered on the next tick, after the turn's synchronous output, to the +// process 'warning' listeners and to Node's stderr report, once per +// (emitter, event name). The "(Use `node --trace-warnings ...`" hint +// follows only the first report of the process, whichever API emitted it. +// The program re-runs itself and prints the child's output with process +// ids normalized. +import { spawnSync } from "node:child_process"; +import { EventEmitter } from "node:events"; + +class Bus extends EventEmitter {} + +function noop(): void {} + +function listening(): void { + process.on("warning", (warning) => console.log("listener:", warning.name, "|", warning.message)); + const bus = new Bus(); + bus.setMaxListeners(2); + for (let i = 0; i < 4; i++) bus.on("data", noop); + console.error("stderr during the turn"); + const plain = new EventEmitter(); + plain.setMaxListeners(1); + plain.once("ready", noop); + plain.prependListener("ready", noop); + process.nextTick(() => console.log("tick after the warnings")); + Promise.resolve().then(() => console.log("microtask")); + setImmediate(() => { + process.emitWarning("later", "CustomWarning"); + const late = new EventEmitter(); + late.setMaxListeners(1); + late.on("x", noop); + late.on("x", noop); + }); + console.log("end of turn", bus.listenerCount("data"), plain.listenerCount("ready")); +} + +function silent(): void { + const first = new EventEmitter(); + first.setMaxListeners(1); + first.on("a", noop); + first.on("a", noop); + setTimeout(() => { + const second = new EventEmitter(); + second.setMaxListeners(1); + second.on("b", noop); + second.on("b", noop); + process.emitWarning("emitted after the leak warning", "CustomWarning"); + }, 1); + console.log("silent end of turn"); +} + +const mode = process.argv[2]; +if (mode === "listening") listening(); +else if (mode === "silent") silent(); +else { + for (const child of ["listening", "silent"]) { + const result = spawnSync(process.execPath, [process.argv[1]!, child], { encoding: "utf8" }); + console.log(`${child} status: ${result.status}`); + console.log(result.stdout); + console.log(result.stderr.replace(/\(node:\d+\)/g, "(node:)")); + } +} diff --git a/tests/corpus/5881-inspect-weak-collections.ts b/tests/corpus/5881-inspect-weak-collections.ts new file mode 100644 index 000000000..b281dcb12 --- /dev/null +++ b/tests/corpus/5881-inspect-weak-collections.ts @@ -0,0 +1,24 @@ +// util.inspect, console.log and util.format's %s and %O print a WeakMap or +// WeakSet as Node's formatWeakCollection does: the entries are not +// listed, so the body is "", and past the depth limit the +// value prints as [WeakMap] / [WeakSet]. Nested positions follow the +// usual line breaking. +import { format, inspect } from "node:util"; + +const cache = new WeakMap(); +const seen = new WeakSet(); +const key = {}; +cache.set(key, 1); +seen.add(key); + +console.log(cache, seen); +console.log(inspect(cache), inspect(new WeakSet())); +console.log({ cache, seen }); +console.log([cache, [seen, [cache]]]); +console.log(inspect({ a: { b: { c: cache } } }), inspect({ a: { b: { c: { d: seen } } } })); +console.log(inspect({ cache }, { depth: 0 }), inspect([seen], { depth: 0 })); +console.log(inspect({ cache }, { depth: null })); +console.log(new Map>([["k", cache]])); +console.log(new Set>([seen])); +console.log(format("%s|%O", cache, seen), inspect(cache).length); +console.log(cache.has(key), seen.has(key), cache.get(key)); diff --git a/tests/corpus/5882-string-decoder-runtime-encoding.ts b/tests/corpus/5882-string-decoder-runtime-encoding.ts new file mode 100644 index 000000000..059d2144f --- /dev/null +++ b/tests/corpus/5882-string-decoder-runtime-encoding.ts @@ -0,0 +1,39 @@ +// new StringDecoder(encoding) with an encoding known only at run time +// normalizes it as Node does: any ASCII case of a Buffer encoding name +// selects that decoder and `.encoding` answers the canonical name +// (ucs2 and utf-16le are utf16le, binary is latin1), undefined and "" +// mean utf8, and an unknown name throws a TypeError with code +// ERR_UNKNOWN_ENCODING. Literal names keep their compile-time form. +import { StringDecoder } from "node:string_decoder"; + +const bytes = Buffer.from([0xe2, 0x82, 0xac, 0x41, 0xc3, 0xa9, 0xff, 0x00]); + +function decodeAll(encoding: string | undefined): string { + const decoder = new StringDecoder(encoding as BufferEncoding | undefined); + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += 3) parts.push(decoder.write(bytes.subarray(i, i + 3))); + parts.push(decoder.end()); + return `${decoder.encoding} ${JSON.stringify(parts)}`; +} + +const names = [ + "utf8", "UTF-8", "Utf8", "hex", "HEX", "base64", "Base64Url", "latin1", "BINARY", + "ascii", "utf16le", "UTF-16LE", "ucs2", "Ucs-2", "", undefined, +]; +for (const name of names) console.log(JSON.stringify(name), decodeAll(name)); + +for (const bad of ["utf-16", "buffer", "utf 8", "latin-1"]) { + try { + new StringDecoder(bad as BufferEncoding); + console.log("no throw", bad); + } catch (error) { + const e = error as Error & { code?: string }; + console.log(e.name, e.code, e.message); + } +} + +const fromOptions: { encoding?: BufferEncoding } = { encoding: "hex" }; +const viaOption = new StringDecoder(fromOptions.encoding); +console.log(viaOption.encoding, viaOption.write(Buffer.from("hi")), viaOption.end(Buffer.from("!"))); +const literal = new StringDecoder("ucs2"); +console.log(literal.encoding, JSON.stringify(literal.write(Buffer.from([0x3d, 0xd8, 0x00, 0xde])))); diff --git a/tests/corpus/5883-os-set-priority.ts b/tests/corpus/5883-os-set-priority.ts new file mode 100644 index 000000000..d87100183 --- /dev/null +++ b/tests/corpus/5883-os-set-priority.ts @@ -0,0 +1,33 @@ +// os.setPriority([pid, ]priority): the one-argument form sets the current +// process, both arguments go through validateInt32 (the priority within +// -20..19, a RangeError with code ERR_OUT_OF_RANGE otherwise), and a +// refused change is a SystemError with code ERR_SYSTEM_ERROR naming +// uv_os_setpriority. Raising the nice value is always allowed; lowering it +// again depends on the privileges of the user running the program. +import * as os from "node:os"; +import { getPriority, setPriority } from "node:os"; + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "ok", getPriority()); + } catch (error) { + const e = error as Error & { code?: string }; + console.log(label, e.name, e.code, e.message); + } +} + +const start = getPriority(); +attempt("same value", () => setPriority(start)); +attempt("same value with pid 0", () => os.setPriority(0, start)); +attempt("same value with own pid", () => setPriority(process.pid, start)); +attempt("raise", () => setPriority(Math.min(start + 5, 19))); +console.log("raised by 5 or to 19:", getPriority() === Math.min(start + 5, 19)); +attempt("lower again", () => setPriority(start)); +attempt("priority 20", () => setPriority(20)); +attempt("priority -21", () => setPriority(0, -21)); +attempt("priority 1.5", () => setPriority(1.5)); +attempt("priority NaN", () => setPriority(Number.NaN)); +attempt("pid 2 ** 31", () => setPriority(2 ** 31, 0)); +attempt("pid -1.5", () => setPriority(-1.5, 0)); +attempt("missing process", () => setPriority(2147483646, 19)); diff --git a/tests/corpus/5884-inspect-layout-options.ts b/tests/corpus/5884-inspect-layout-options.ts new file mode 100644 index 000000000..f75528b9b --- /dev/null +++ b/tests/corpus/5884-inspect-layout-options.ts @@ -0,0 +1,51 @@ +// util.inspect and console.dir with the breakLength and compact options: +// breakLength (a number or Infinity) is the line width the single-line +// form and the array grid must fit, and compact sets how many inner +// levels may join one line and the grid's column limit (compact * 4). +// compact: false (or a number below 1) puts every entry of every +// composite on its own line. A render with these options leaves later +// renders at the defaults. +import { inspect } from "node:util"; + +class Point { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } +} + +const config = { + name: "service", + ports: [80, 443], + limits: { cpu: 2, memory: { soft: "1G", hard: "2G" } }, + tags: new Set(["a", "b"]), + routes: new Map([["/", { handler: "index", methods: ["GET", "HEAD"] }]]), +}; +const numbers = Array.from({ length: 30 }, (_, i) => i * 37); +const words = Array.from({ length: 26 }, (_, i) => String.fromCharCode(97 + i).repeat((i % 5) + 1)); +const points = Array.from({ length: 8 }, (_, i) => new Point(i, i * i)); +const text = { text: "line one\nline two\nline three is a longer one" }; + +console.log(inspect(config, { compact: false })); +console.log(inspect(config, { compact: 1 })); +console.log(inspect(config, { compact: 5, depth: Infinity })); +console.log(inspect(config, { breakLength: 40 })); +console.log(inspect(config, { breakLength: Infinity })); +console.log(inspect(config, { breakLength: 120, compact: 2 })); +console.log(inspect(numbers, { compact: false })); +console.log(inspect(numbers, { compact: 1 })); +console.log(inspect(numbers, { breakLength: 40 }), inspect(numbers, { breakLength: 150 })); +console.log(inspect(words, { compact: 6 }), inspect(words, { breakLength: 60 })); +console.log(inspect(points, { compact: 2, breakLength: 120 })); +console.log(inspect(points, { compact: false })); +console.log(inspect(text, { breakLength: Infinity }), inspect(text, { compact: false })); +console.log(inspect(text.text, { breakLength: 20 })); +console.log(inspect([], { compact: false }), inspect({}, { compact: false }), inspect(new Map([[1, 2]]), { compact: false })); +console.log(inspect({ a: 1 }, { compact: 0 }), inspect({ a: [1, 2] }, { breakLength: 0 })); +const parsed: unknown = JSON.parse('{"x":[1,2,{"y":"z"}],"w":{"v":null}}'); +console.log(inspect(parsed, { compact: false }), inspect(parsed, { breakLength: 10 })); +console.dir(config, { compact: false, depth: 0 }); +console.dir(parsed, { breakLength: 20 }); +console.log(config); diff --git a/tests/corpus/5885-events-max-listener-helpers.ts b/tests/corpus/5885-events-max-listener-helpers.ts new file mode 100644 index 000000000..88a450348 --- /dev/null +++ b/tests/corpus/5885-events-max-listener-helpers.ts @@ -0,0 +1,75 @@ +// The events module's emitter helpers, also EventEmitter statics: +// listenerCount(emitter, name), getMaxListeners(emitter), +// setMaxListeners(n[, ...emitters]) and getEventListeners(emitter, name) +// answer through the emitter's own methods. setMaxListeners with no +// emitters sets the process default, which EventEmitter.defaultMaxListeners +// reads and writes. Limits go through Node's validateNumber (RangeError +// ERR_OUT_OF_RANGE). EventEmitter.once is events.once. +import EE, { + EventEmitter, + errorMonitor, + getEventListeners, + getMaxListeners, + listenerCount, + setMaxListeners, +} from "node:events"; +import * as events from "node:events"; + +console.log(EE.defaultMaxListeners, EventEmitter.defaultMaxListeners); +const first = new EventEmitter(); +const named = (): void => {}; +first.on("x", named); +first.on("x", () => {}); +first.on(errorMonitor, () => {}); +console.log( + listenerCount(first, "x"), + EventEmitter.listenerCount(first, "x"), + events.listenerCount(first, "y"), + listenerCount(first, errorMonitor), +); +console.log(getMaxListeners(first), EventEmitter.getMaxListeners(first)); + +setMaxListeners(3, first); +console.log(first.getMaxListeners()); +const second = new EventEmitter(); +EventEmitter.setMaxListeners(4, first, second); +console.log(first.getMaxListeners(), second.getMaxListeners(), getMaxListeners(second)); + +setMaxListeners(20); +console.log(EventEmitter.defaultMaxListeners, new EventEmitter().getMaxListeners(), second.getMaxListeners()); +EventEmitter.defaultMaxListeners = 7; +console.log(EE.defaultMaxListeners, new EventEmitter().getMaxListeners()); +setMaxListeners(); +console.log(EventEmitter.defaultMaxListeners); + +const listeners = getEventListeners(first, "x"); +console.log(listeners.length, listeners[0] === named, EventEmitter.getEventListeners(first, "x").length); + +for (const run of [ + () => setMaxListeners(-1), + () => setMaxListeners(-2, first), + () => setMaxListeners(Number.NaN), + () => { + EventEmitter.defaultMaxListeners = -3; + }, +]) { + try { + run(); + console.log("no throw"); + } catch (error) { + const e = error as Error & { code?: string }; + console.log(e.name, e.code, e.message); + } +} +console.log(first.getMaxListeners(), EventEmitter.defaultMaxListeners); + +class Bus extends EventEmitter {} +const bus = new Bus(); +bus.once("ready", named); +console.log(listenerCount(bus, "ready"), getEventListeners(bus, "ready")[0] === named, getMaxListeners(bus)); + +async function main(): Promise { + setTimeout(() => bus.emit("go"), 1); + console.log(await EventEmitter.once(bus, "go")); +} +main(); diff --git a/tests/corpus/5886-inspect-dates.ts b/tests/corpus/5886-inspect-dates.ts new file mode 100644 index 000000000..37338135d --- /dev/null +++ b/tests/corpus/5886-inspect-dates.ts @@ -0,0 +1,32 @@ +// util.inspect, console.log, console.dir and util.format's %s, %O and %o +// print a Date as Node does: its toISOString() at every depth (including +// six-digit signed years), and "Invalid Date" for a NaN time value. Dates +// in record fields and class instances follow the usual layout. +import { format, inspect } from "node:util"; + +const epoch = new Date(0); +console.log(epoch); +console.log(inspect(epoch), inspect(epoch, { depth: 0 }), inspect(new Date("garbage"))); +console.log(inspect({ a: { b: { c: { epoch } } } }), inspect({ a: { b: { epoch } } })); +console.log(format("%s|%O|%o", epoch, epoch, { epoch })); +console.log("at %s", new Date(1e12)); +console.log({ + epoch, + invalid: new Date(Number.NaN), + latest: new Date(8.64e15), + earliest: new Date(-8.64e15), +}); +console.log({ + leap: new Date(Date.UTC(2024, 1, 29, 12, 30, 45, 123)), + negativeYear: new Date(Date.UTC(-1, 0, 1)), + farFuture: new Date(Date.UTC(10000, 0, 1)), + parsed: new Date("2020-06-15T08:09:10.011+02:00"), +}); + +class Entry { + at = new Date(5); + name = "entry"; +} +console.log(new Entry(), [new Entry()]); +console.error(epoch); +console.dir({ epoch }, { depth: 0 }); diff --git a/tests/corpus/5887-inspect-typed-arrays.ts b/tests/corpus/5887-inspect-typed-arrays.ts new file mode 100644 index 000000000..eb6fb6750 --- /dev/null +++ b/tests/corpus/5887-inspect-typed-arrays.ts @@ -0,0 +1,30 @@ +// util.inspect, console.log and console.dir print a numeric typed array +// that is not a Buffer as Node does: `Float64Array(n) [ ... ]` with the +// number formatting, the 100-element maxArrayLength tail, the array grid +// and `[Int16Array]` past the depth limit. Typed arrays other than +// Uint8Array also print inside records and arrays, and under the +// breakLength, compact and sorted options; Buffers keep their +// form. +import { inspect } from "node:util"; + +console.log(new Uint8Array([1, 2, 3]), new Uint8Array(0)); +console.log(new Float64Array([1.5, -0, Number.NaN, Infinity, -1e21])); +console.log(new Float32Array([0.1, 16777217])); +console.log(new Int32Array(30)); +console.log(new Uint8ClampedArray([300, -5, 1.5]), new Int8Array([-1, 127, 128])); +console.log(inspect(new Uint16Array(150))); +console.log({ samples: new Uint16Array([7, 8]), deltas: new Int8Array([-1, 2]) }); +console.log([new Float32Array([1, 2]), new Float32Array(10)]); +console.log(inspect({ x: { y: { z: new Uint32Array([1]) } } }), inspect(new Int16Array([5]), { depth: 0 })); +console.log(inspect({ x: { y: new Int16Array([5]) } }, { depth: 1 })); +console.dir(new Uint8Array([4, 5])); +console.log(new Uint8Array([1, 2, 3, 4]).subarray(1, 3), new Uint8Array(Buffer.from("hi")), Buffer.from("hi")); +const grid = new Float64Array(12); +for (let i = 0; i < grid.length; i++) grid[i] = i * 1.25; +console.log(grid); +const wide = new Uint8Array(40); +for (let i = 0; i < wide.length; i++) wide[i] = i * 7; +console.log(inspect(wide, { breakLength: 40 })); +console.log(inspect(new Int16Array([1, -2, 300, -4000, 5]), { compact: false })); +console.log(inspect(new Uint32Array(120), { depth: 0, breakLength: 60 })); +console.log(inspect(new Int8Array([3, 1, 2]), { sorted: true, breakLength: Infinity })); diff --git a/tests/corpus/5888-inspect-sorted.ts b/tests/corpus/5888-inspect-sorted.ts new file mode 100644 index 000000000..97a6ffb30 --- /dev/null +++ b/tests/corpus/5888-inspect-sorted.ts @@ -0,0 +1,34 @@ +// util.inspect and console.dir with sorted: true order the entries of +// objects, class instances, Maps and Sets by their rendered text, in +// UTF-16 code-unit order (quoted keys sort by their quote, the "... more +// items" tail sorts with the rest), at every nesting level. Arrays keep +// their element order. sorted: false is the default. +import { inspect } from "node:util"; + +const record = { + zeta: 1, + alpha: { c: 3, b: 2, a: 1 }, + "quoted-key": true, + Beta: [3, 1, 2], + _under: null, + 10: "ten", + 2: "two", +}; +console.log(inspect(record, { sorted: true })); +console.log(inspect(record, { sorted: false })); +console.log(inspect(new Map([["b", 1], ["a", 2], ["c", { z: 1, y: 2 }]]), { sorted: true })); +console.log(inspect(new Set(["pear", "apple", "fig"]), { sorted: true })); +console.log(inspect({ é: 1, e: 2, "😀": 3, "￿": 4 }, { sorted: true })); +console.log(inspect([{ b: 1, a: 2 }, { d: 1, c: 2 }], { sorted: true, compact: false })); + +class Point { + y = 1; + x = 2; +} +console.log(inspect(new Point(), { sorted: true }), inspect([3, 1, 2], { sorted: true })); +const many = new Set(Array.from({ length: 105 }, (_, i) => `k${i}`)); +console.log(inspect(many, { sorted: true, breakLength: Infinity })); +const parsed: unknown = JSON.parse('{"b":1,"a":{"d":1,"c":2}}'); +console.log(inspect(parsed, { sorted: true })); +console.dir({ b: 1, a: 2 }, { sorted: true }); +console.log(record); diff --git a/tests/corpus/5889-timers-scheduler-signal.ts b/tests/corpus/5889-timers-scheduler-signal.ts new file mode 100644 index 000000000..81fe14386 --- /dev/null +++ b/tests/corpus/5889-timers-scheduler-signal.ts @@ -0,0 +1,30 @@ +// timers/promises scheduler.wait(delay, { signal }) passes its options to +// setTimeout, so an AbortSignal rejects the wait with an AbortError: when +// it aborts during the wait, and at once when it has already aborted. A +// signal that never aborts leaves the wait to resolve undefined. +import { scheduler } from "node:timers/promises"; + +async function main(): Promise { + const ac = new AbortController(); + const pending = scheduler.wait(10_000, { signal: ac.signal }).then( + () => "resolved", + (e: Error & { code?: string }) => `rejected ${e.name} ${e.code}`, + ); + setTimeout(() => ac.abort(), 5); + console.log(await pending); + + const quiet = new AbortController(); + const settled = await scheduler.wait(2, { signal: quiet.signal, ref: true }); + console.log("settled with", settled); + + const pre = new AbortController(); + pre.abort(); + try { + await scheduler.wait(1, { signal: pre.signal }); + console.log("not rejected"); + } catch (e) { + console.log("already aborted:", (e as Error).name); + } +} + +main(); diff --git a/tests/corpus/5892-buffer-write-utf16le-runs.ts b/tests/corpus/5892-buffer-write-utf16le-runs.ts new file mode 100644 index 000000000..a69c0e8d5 --- /dev/null +++ b/tests/corpus/5892-buffer-write-utf16le-runs.ts @@ -0,0 +1,60 @@ +// Buffer.write(text, "utf16le") over ASCII runs of every length around the +// sixteen-byte widening steps (4732 covers the eight-byte ones): odd and even +// offsets, length limits that cut a run, and non-ASCII, astral and +// lone-surrogate units at every position of a 41-unit run. + +function hex(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i++) out += (bytes[i]! < 16 ? "0" : "") + bytes[i]!.toString(16); + return out; +} + +function digest(bytes: Uint8Array): number { + let h = 0x811c9dc5 | 0; + for (let i = 0; i < bytes.length; i++) h = Math.imul(h ^ bytes[i]!, 0x01000193); + return h >>> 0; +} + +const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$"; +function run(n: number): string { + let s = ""; + for (let i = 0; i < n; i++) s += alphabet[i % alphabet.length]; + return s; +} + +let summary = 0; +for (let n = 0; n <= 70; n++) { + const text = run(n); + for (const offset of [0, 1, 3, 16]) { + const buf = Buffer.alloc(offset + 2 * n + 5, 0xee); + const written = buf.write(text, offset, "utf16le"); + summary = (Math.imul(summary, 31) + written + digest(buf)) | 0; + for (const limit of [0, 1, 15, 16, 17, 31, 32, 33]) { + if (limit > 2 * n + 5) continue; + const cut = Buffer.alloc(offset + 2 * n + 5, 0xee); + const w = cut.write(text, offset, limit, "utf16le"); + summary = (Math.imul(summary, 31) + w + digest(cut)) | 0; + } + } +} +console.log("runs", summary); + +for (const insert of ["é", "€", "😀", "\ud800", "\udc00", "\u0080", "ÿ"]) { + let local = 0; + for (let at = 0; at <= 40; at++) { + const text = run(at) + insert + run(40 - at); + const buf = Buffer.alloc(2 * text.length + 3, 0xee); + const written = buf.write(text, 1, "utf16le"); + local = (Math.imul(local, 31) + written + digest(buf)) | 0; + if (at === 17) console.log(JSON.stringify(insert), written, hex(buf.subarray(33, 41))); + } + console.log("insert", JSON.stringify(insert), local); +} + +// A code-unit array the way a scanner builds one, then read back. +const source = run(1000) + "\n" + "const x = 1;\r\n".repeat(50); +const units = new Uint16Array(source.length + 4); +const n = Buffer.from(units.buffer).write(source, "utf16le"); +let same = n === 2 * source.length; +for (let i = 0; i < source.length; i++) if (units[i] !== source.charCodeAt(i)) same = false; +console.log("units", n, same, units[source.length], units[source.length + 3]); diff --git a/tests/corpus/5900-float16-array.ts b/tests/corpus/5900-float16-array.ts new file mode 100644 index 000000000..bcf57ea8d --- /dev/null +++ b/tests/corpus/5900-float16-array.ts @@ -0,0 +1,94 @@ +// Float16Array: construction from lengths, arrays, typed arrays and ArrayBuffer +// views; round-to-nearest-even stores with overflow, NaN, -0 and subnormals; +// fill, set, subarray, slice and both sort orders; DataView getFloat16 and +// setFloat16 in both byte orders; Math.f16round; util.types and instanceof; +// structuredClone, assert and Buffer views. Node.js prints the same. +import { types } from "node:util"; +import assert from "node:assert"; +const show = (label: string, a: Float16Array): void => console.log(label, a.length, Array.from(a).join(",")); +const a = new Float16Array(6); +a[0] = 1.5; +a[1] = 65519; +a[2] = 65520; +a[3] = 0.1; +a[4] = -0; +a[5] = 5.960464477539063e-8 / 2; +show("rounding", a); +const b = new Float16Array([NaN, Infinity, -Infinity, 1e-8, 2049, 2051, 4097, -1e6]); +show("specials", b); +console.log(Object.is(a[4], -0), a[6], a.length, a.byteLength, Number.isNaN(b[0])); +const c = Float16Array.from([3.14159, 2.71828, 1.41421]); +show("from", c); +const e = new Float16Array(c); +e[0] = 100; +show("copy", e); +show("source untouched", c); +const f32 = new Float32Array([1 / 3, 2 / 3]); +show("from f32", new Float16Array(f32)); +const buf = new ArrayBuffer(8); +const view = new Float16Array(buf, 2, 2); +view[0] = 1; +view[1] = -2; +console.log("view bytes", Array.from(new Uint8Array(buf)).join(","), new Uint16Array(buf)[1]); +const g = new Float16Array(5); +g.fill(0.3); +show("fill", g); +g.set([1.1, 2.2], 3); +show("set", g); +show("subarray", g.subarray(1, 4)); +show("slice", g.slice(-2)); +const h = new Float16Array([3, -0, 0, NaN, -1, 2.5, -Infinity, 0.1]); +show("sort", h.slice().sort()); +show("sort desc", h.slice().sort((x, y) => y - x)); +show("toSorted", h.toSorted()); +let sum = 0; +for (const v of c) sum += v; +console.log("for-of", sum, h.join("|"), String(c), Object.prototype.toString.call(c)); +const dv = new DataView(new ArrayBuffer(8)); +dv.setFloat16(0, 3.14159); +dv.setFloat16(2, 3.14159, true); +dv.setFloat16(4, -65504); +dv.setFloat16(6, NaN, true); +console.log("dataview", dv.getFloat16(0), dv.getFloat16(2, true), dv.getFloat16(4), dv.getFloat16(6, true), dv.getUint16(0), dv.getUint16(2)); +try { + dv.getFloat16(7); +} catch (err) { + console.log((err as Error).name, (err as Error).message); +} +console.log("f16round", Math.f16round(1.337), Math.f16round(65520), Math.f16round(-0), Math.f16round(5e-8), Math.f16round(2 ** -25), Math.f16round(1.0009765625 + 2 ** -12)); +console.log("types", types.isFloat16Array(a), types.isFloat32Array(a), types.isTypedArray(a), a instanceof Float16Array); +const u: unknown = a; +console.log("unknown", u instanceof Float16Array, u instanceof Float32Array); +const big = new Float16Array(1000); +for (let i = 0; i < big.length; i++) big[i] = Math.sin(i) * 1000; +let acc = 0; +for (let i = 0; i < big.length; i++) acc += big[i]!; +console.log("loop", acc, big[999]); +const f = new Float16Array([1.5, NaN, -0, 65504]); +const clone = structuredClone(f); +clone[0] = 2; +console.log(clone[0], f[0], clone.length); +assert.deepStrictEqual(new Float16Array([NaN, 1]), new Float16Array([NaN, 1])); +try { + assert.deepStrictEqual(new Float16Array([0]), new Float16Array([-0])); +} catch (e) { + console.log("deepStrictEqual -0", (e as Error).name); +} +assert.deepEqual(new Float16Array([0]), new Float16Array([-0])); +const raw = Buffer.from(f.buffer); +console.log(raw.length, raw.readUInt16LE(0), raw.readUInt16LE(6)); +const fromBuf = new Float16Array(f.buffer, 2, 1); +console.log(Number.isNaN(fromBuf[0])); +const list: Float16Array[] = [f, new Float16Array(2)]; +const m = new Map([["a", f]]); +console.log(list.length, m.get("a")![3], list[1]!.length); +const Ctor = Float16Array; +const x = new Ctor(3); +x[1] = 0.2; +console.log(x.length, x[1]); +const y = new Float16Array([1, 2]); +const D = y.constructor as Float16ArrayConstructor; +const z = new D(2); +console.log(z.length, z instanceof Float16Array); +const unknownView: unknown = new Float16Array([7]); +console.log(unknownView instanceof Float16Array); diff --git a/tests/corpus/5901-float16-array-js.js b/tests/corpus/5901-float16-array-js.js new file mode 100644 index 000000000..8c0f6f7bc --- /dev/null +++ b/tests/corpus/5901-float16-array-js.js @@ -0,0 +1,95 @@ +// Float16Array from JavaScript: construction from lengths, arrays, typed +// arrays and ArrayBuffer views; round-to-nearest-even stores with overflow, +// NaN, -0 and subnormals; fill, set, subarray, slice and both sort orders; +// DataView getFloat16 and setFloat16 in both byte orders; Math.f16round; +// util.types and instanceof; structuredClone, assert and Buffer views. +// Node.js prints the same. +import { types } from "node:util"; +import assert from "node:assert"; +const show = (label , a ) => console.log(label, a.length, Array.from(a).join(",")); +const a = new Float16Array(6); +a[0] = 1.5; +a[1] = 65519; +a[2] = 65520; +a[3] = 0.1; +a[4] = -0; +a[5] = 5.960464477539063e-8 / 2; +show("rounding", a); +const b = new Float16Array([NaN, Infinity, -Infinity, 1e-8, 2049, 2051, 4097, -1e6]); +show("specials", b); +console.log(Object.is(a[4], -0), a[6], a.length, a.byteLength, Number.isNaN(b[0])); +const c = Float16Array.from([3.14159, 2.71828, 1.41421]); +show("from", c); +const e = new Float16Array(c); +e[0] = 100; +show("copy", e); +show("source untouched", c); +const f32 = new Float32Array([1 / 3, 2 / 3]); +show("from f32", new Float16Array(f32)); +const buf = new ArrayBuffer(8); +const view = new Float16Array(buf, 2, 2); +view[0] = 1; +view[1] = -2; +console.log("view bytes", Array.from(new Uint8Array(buf)).join(","), new Uint16Array(buf)[1]); +const g = new Float16Array(5); +g.fill(0.3); +show("fill", g); +g.set([1.1, 2.2], 3); +show("set", g); +show("subarray", g.subarray(1, 4)); +show("slice", g.slice(-2)); +const h = new Float16Array([3, -0, 0, NaN, -1, 2.5, -Infinity, 0.1]); +show("sort", h.slice().sort()); +show("sort desc", h.slice().sort((x, y) => y - x)); +show("toSorted", h.toSorted()); +let sum = 0; +for (const v of c) sum += v; +console.log("for-of", sum, h.join("|"), String(c), Object.prototype.toString.call(c)); +const dv = new DataView(new ArrayBuffer(8)); +dv.setFloat16(0, 3.14159); +dv.setFloat16(2, 3.14159, true); +dv.setFloat16(4, -65504); +dv.setFloat16(6, NaN, true); +console.log("dataview", dv.getFloat16(0), dv.getFloat16(2, true), dv.getFloat16(4), dv.getFloat16(6, true), dv.getUint16(0), dv.getUint16(2)); +try { + dv.getFloat16(7); +} catch (err) { + console.log((err ).name, (err ).message); +} +console.log("f16round", Math.f16round(1.337), Math.f16round(65520), Math.f16round(-0), Math.f16round(5e-8), Math.f16round(2 ** -25), Math.f16round(1.0009765625 + 2 ** -12)); +console.log("types", types.isFloat16Array(a), types.isFloat32Array(a), types.isTypedArray(a), a instanceof Float16Array); +const u = a; +console.log("unknown", u instanceof Float16Array, u instanceof Float32Array); +const big = new Float16Array(1000); +for (let i = 0; i < big.length; i++) big[i] = Math.sin(i) * 1000; +let acc = 0; +for (let i = 0; i < big.length; i++) acc += big[i] ; +console.log("loop", acc, big[999]); +const f = new Float16Array([1.5, NaN, -0, 65504]); +const clone = structuredClone(f); +clone[0] = 2; +console.log(clone[0], f[0], clone.length); +assert.deepStrictEqual(new Float16Array([NaN, 1]), new Float16Array([NaN, 1])); +try { + assert.deepStrictEqual(new Float16Array([0]), new Float16Array([-0])); +} catch (e) { + console.log("deepStrictEqual -0", (e ).name); +} +assert.deepEqual(new Float16Array([0]), new Float16Array([-0])); +const raw = Buffer.from(f.buffer); +console.log(raw.length, raw.readUInt16LE(0), raw.readUInt16LE(6)); +const fromBuf = new Float16Array(f.buffer, 2, 1); +console.log(Number.isNaN(fromBuf[0])); +const list = [f, new Float16Array(2)]; +const m = new Map ([["a", f]]); +console.log(list.length, m.get("a") [3], list[1] .length); +const Ctor = Float16Array; +const x = new Ctor(3); +x[1] = 0.2; +console.log(x.length, x[1]); +const y = new Float16Array([1, 2]); +const D = y.constructor ; +const z = new D(2); +console.log(z.length, z instanceof Float16Array); +const unknownView = new Float16Array([7]); +console.log(unknownView instanceof Float16Array); diff --git a/tests/corpus/5904-async-generator-completion.ts b/tests/corpus/5904-async-generator-completion.ts new file mode 100644 index 000000000..478aae6e9 --- /dev/null +++ b/tests/corpus/5904-async-generator-completion.ts @@ -0,0 +1,188 @@ +// Async generator completion against a microtask ticker: falling off the end +// and bare returns settle as the body completes, `return x` takes a turn to +// await x, return() at a yield awaits its value before finally blocks run, +// and for await loops end on the turn Node ends them. Node.js prints the +// same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function run(name: string, body: () => Promise): Promise { + log.length = 0; + const tick = ticker(14); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +class Source { + readonly items: number[]; + constructor(items: number[]) { + this.items = items; + } + async *values(): AsyncGenerator { + for (const i of this.items) yield i; + } +} +async function main(): Promise { + await run("fall off", async () => { + async function* g(): AsyncGenerator { yield 1; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("return value", async () => { + async function* g(): AsyncGenerator { yield 1; return 5; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("bare return", async () => { + async function* g(): AsyncGenerator { yield 1; return; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("empty body", async () => { + async function* g(): AsyncGenerator {} + await g().next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + }); + await run("after done", async () => { + async function* g(): AsyncGenerator {} + const it = g(); + await it.next(); + log.push("mark"); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("return() at yield", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("finally"); } return 0; } + const it = g(); + await it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.return(7).then((r) => log.push(`r:${String(r.value)}/${r.done}`)); + }); + await run("return() before start", async () => { + async function* g(): AsyncGenerator { yield 1; return 0; } + await g().return(3).then((r) => log.push(`r:${String(r.value)}/${r.done}`)); + }); + await run("throw at end", async () => { + async function* g(): AsyncGenerator { yield 1; throw new Error("x"); } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`), () => log.push("rejected")); + }); + await run("await before end", async () => { + async function* g(): AsyncGenerator { yield 1; await null; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + }); + await run("for await", async () => { + async function* g(): AsyncGenerator { yield 1; yield 2; } + for await (const v of g()) log.push("v" + v); + log.push("loop done"); + }); + await run("for await break", async () => { + async function* g(): AsyncGenerator { try { yield 1; yield 2; } finally { log.push("cleanup"); } } + for await (const v of g()) { log.push("v" + v); break; } + log.push("loop done"); + }); + await run("queued", async () => { + async function* g(): AsyncGenerator { yield 1; } + const it = g(); + void it.next().then((r) => log.push(`a:${String(r.value)}/${r.done}`)); + void it.next().then((r) => log.push(`b:${String(r.value)}/${r.done}`)); + await it.next().then((r) => log.push(`c:${String(r.value)}/${r.done}`)); + }); + + await run("yield in finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { yield 9; log.push("after"); } return 0; } + const it = g(); + await it.next(); + void it.return(7).then((r) => log.push(`r:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`n:${r.value}/${r.done}`)); + }); + await run("await in finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { await null; log.push("cleaned"); } return 0; } + const it = g(); + await it.next(); + await it.return(4).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("throw at yield", async () => { + async function* g(): AsyncGenerator { try { yield 1; } catch { log.push("caught"); } } + const it = g(); + await it.next(); + await it.throw(new Error("x")).then((r) => log.push(`t:${r.value}/${r.done}`)); + }); + await run("return through finally", async () => { + async function* g(): AsyncGenerator { try { yield 1; return 5; } finally { log.push("fin"); } } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`b:${r.value}/${r.done}`)); + }); + await run("return promise", async () => { + async function* g(): AsyncGenerator { yield 1; return Promise.resolve(3); } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then((r) => log.push(`b:${r.value}/${r.done}`)); + }); + await run("two returns", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("fin"); } return 0; } + const it = g(); + await it.next(); + void it.return(1).then((r) => log.push(`r1:${r.value}/${r.done}`)); + await it.return(2).then((r) => log.push(`r2:${r.value}/${r.done}`)); + }); + await run("return after done", async () => { + async function* g(): AsyncGenerator { return 1; } + const it = g(); + await it.next(); + log.push("mark"); + await it.return(8).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("nested loops break", async () => { + async function* g(n: number): AsyncGenerator { try { for (let i = 0; i < n; i++) yield i; } finally { log.push("close" + n); } } + for await (const a of g(2)) { + for await (const b of g(3)) { log.push(`${a}${b}`); if (b === 1) break; } + } + log.push("done"); + }); + await run("strings", async () => { + async function* g(): AsyncGenerator { yield "a"; yield "b"; } + const parts: string[] = []; + for await (const s of g()) parts.push(s); + log.push(parts.join("")); + }); + + await run("finally return override", async () => { + async function* g(): AsyncGenerator { try { yield 1; } finally { log.push("fin"); return 42; } } + const it = g(); + await it.next(); + await it.return(7).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("class method", async () => { + const parts: number[] = []; + for await (const v of new Source([1, 2, 3]).values()) parts.push(v); + log.push(parts.join(",")); + }); + await run("return while awaiting", async () => { + async function* g(): AsyncGenerator { try { await null; await null; yield 1; } finally { log.push("fin"); } return 0; } + const it = g(); + void it.next().then((r) => log.push(`n:${r.value}/${r.done}`)); + await it.return(5).then((r) => log.push(`r:${r.value}/${r.done}`)); + }); + await run("throw after done", async () => { + async function* g(): AsyncGenerator {} + const it = g(); + await it.next(); + await it.throw(new Error("late")).then(() => log.push("ok"), (e: unknown) => log.push("rejected " + (e as Error).message)); + }); + await run("rejection inside", async () => { + async function* g(): AsyncGenerator { yield 1; await Promise.reject(new Error("inner")); } + const it = g(); + void it.next().then((r) => log.push(`a:${r.value}/${r.done}`)); + await it.next().then(() => log.push("ok"), (e: unknown) => log.push("rejected " + (e as Error).message)); + }); +} +void main(); diff --git a/tests/corpus/5905-array-from-async-generator.ts b/tests/corpus/5905-array-from-async-generator.ts new file mode 100644 index 000000000..f95fd5327 --- /dev/null +++ b/tests/corpus/5905-array-from-async-generator.ts @@ -0,0 +1,61 @@ +// Array.fromAsync over async generators: plain, mapped, async-mapped, a +// throwing or rejecting mapping function (which closes the generator), a +// generator that throws, an empty generator, and two calls at once, +// interleaved with a microtask ticker. Node.js prints the same. +const log: string[] = []; +async function ticker(n: number): Promise { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function* nums(n: number): AsyncGenerator { + try { + for (let i = 0; i < n; i++) { + log.push("yield " + i); + yield i; + } + } finally { + log.push("gen finally"); + } +} +async function* failing(): AsyncGenerator { + yield 1; + throw new Error("gen failure"); +} +async function report(label: string, job: Promise): Promise { + await job.then( + (a) => log.push(label + " [" + a.join(",") + "]"), + (e: unknown) => log.push(label + " rejected " + (e as Error).message), + ); +} +async function run(name: string, body: () => Promise): Promise { + log.length = 0; + const tick = ticker(20); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +async function main(): Promise { + await run("plain", () => report("plain", Array.fromAsync(nums(3)))); + await run("map", () => report("map", Array.fromAsync(nums(3), (x: number, i: number) => x * 10 + i))); + await run("async map", () => report("async map", Array.fromAsync(nums(2), async (x: number) => x + 1))); + await run("map throws", () => + report("map throws", Array.fromAsync(nums(3), (x: number) => { + if (x === 1) throw new Error("map failure"); + return x; + })), + ); + await run("map rejects", () => + report("map rejects", Array.fromAsync(nums(3), async (x: number) => { + if (x === 1) throw new Error("async map failure"); + return x; + })), + ); + await run("generator throws", () => report("generator throws", Array.fromAsync(failing()))); + await run("empty", () => report("empty", Array.fromAsync(nums(0)))); + await run("two at once", async () => { + await Promise.all([report("a", Array.fromAsync(nums(2))), report("b", Array.fromAsync(nums(1)))]); + }); +} +void main(); diff --git a/tests/corpus/5906-array-from-async-generator-js.js b/tests/corpus/5906-array-from-async-generator-js.js new file mode 100644 index 000000000..fa4d22147 --- /dev/null +++ b/tests/corpus/5906-array-from-async-generator-js.js @@ -0,0 +1,60 @@ +// Array.fromAsync over async generators from JavaScript, with and without a +// mapping function, interleaved with a microtask ticker. Node.js prints the +// same. +const log = []; +async function ticker(n ) { + for (let i = 0; i < n; i++) { + log.push("t" + i); + await null; + } +} +async function* nums(n ) { + try { + for (let i = 0; i < n; i++) { + log.push("yield " + i); + yield i; + } + } finally { + log.push("gen finally"); + } +} +async function* failing() { + yield 1; + throw new Error("gen failure"); +} +async function report(label , job ) { + await job.then( + (a) => log.push(label + " [" + a.join(",") + "]"), + (e ) => log.push(label + " rejected " + (e ).message), + ); +} +async function run(name , body ) { + log.length = 0; + const tick = ticker(20); + await body(); + await tick; + console.log(name + ": " + log.join(" ")); +} +async function main() { + await run("plain", () => report("plain", Array.fromAsync(nums(3)))); + await run("map", () => report("map", Array.fromAsync(nums(3), (x , i ) => x * 10 + i))); + await run("async map", () => report("async map", Array.fromAsync(nums(2), async (x ) => x + 1))); + await run("map throws", () => + report("map throws", Array.fromAsync(nums(3), (x ) => { + if (x === 1) throw new Error("map failure"); + return x; + })), + ); + await run("map rejects", () => + report("map rejects", Array.fromAsync(nums(3), async (x ) => { + if (x === 1) throw new Error("async map failure"); + return x; + })), + ); + await run("generator throws", () => report("generator throws", Array.fromAsync(failing()))); + await run("empty", () => report("empty", Array.fromAsync(nums(0)))); + await run("two at once", async () => { + await Promise.all([report("a", Array.fromAsync(nums(2))), report("b", Array.fromAsync(nums(1)))]); + }); +} +void main(); diff --git a/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts b/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts new file mode 100644 index 000000000..9bd24a0bb --- /dev/null +++ b/tests/corpus/5910-set-prototype-of-primitive-object-kinds.ts @@ -0,0 +1,63 @@ +// Object.setPrototypeOf with a statically typed primitive receiver returns the +// receiver unchanged when the prototype is any object or function: a class +// instance, a class, a Map, a typed array, a promise, an arrow function or +// Object.prototype. A prototype that is not an object or null throws a +// TypeError. + +class Base { + hello(): string { + return "base"; + } +} + +function plain(): number { + return 1; +} + +function show(label: string, result: unknown, receiver: unknown): void { + console.log(label, typeof result, Object.is(result, receiver)); +} + +const prototypes: [string, object | null][] = [ + ["literal", { inherited: 1 }], + ["instance", new Base()], + ["class", Base], + ["function", plain], + ["arrow", () => 2], + ["array", [1, 2]], + ["map", new Map()], + ["set", new Set()], + ["regexp", /a/], + ["bytes", new Uint8Array(2)], + ["promise", Promise.resolve(1)], + ["error", new Error("e")], + ["null-proto", Object.create(null) as object], + ["object-prototype", Object.prototype], + ["null", null], +]; + +const symbol = Symbol("tag"); +for (const [label, prototype] of prototypes) { + show(`number/${label}`, Object.setPrototypeOf(5, prototype), 5); + show(`negative-zero/${label}`, Object.setPrototypeOf(-0, prototype), -0); + show(`string/${label}`, Object.setPrototypeOf("text", prototype), "text"); + show(`boolean/${label}`, Object.setPrototypeOf(false, prototype), false); + show(`bigint/${label}`, Object.setPrototypeOf(10n, prototype), 10n); + show(`symbol/${label}`, Object.setPrototypeOf(symbol, prototype), symbol); +} + +const proto = { greet(): string { return "hi"; } }; +const five = Object.setPrototypeOf(5, proto); +console.log(five, typeof five); +let count: number = 3.5; +count = Object.setPrototypeOf(count, proto); +console.log(count + 1); + +for (const invalid of [7, "proto", true, undefined]) { + try { + Object.setPrototypeOf(5, invalid as unknown as object); + console.log("no throw", typeof invalid); + } catch (error) { + console.log(typeof invalid, error instanceof TypeError); + } +} diff --git a/tests/corpus/5911-dyn-class-to-primitive.ts b/tests/corpus/5911-dyn-class-to-primitive.ts new file mode 100644 index 000000000..fcdc0cbe7 --- /dev/null +++ b/tests/corpus/5911-dyn-class-to-primitive.ts @@ -0,0 +1,203 @@ +// Class instances held as `any` convert to primitives through their own +// valueOf and toString methods in dynamic operators, template literals, +// String(), Number() and loose equality, in the order and with the hint +// JavaScript uses. A conversion method that mutates the instance mutates +// the original object. + +const log: string[] = []; + +class Counter { + total = 0; + valueOf(): number { + log.push("Counter.valueOf"); + return ++this.total; + } +} + +class Label { + text: string; + constructor(text: string) { + this.text = text; + } + toString(): string { + log.push(`Label.toString ${this.text}`); + return `<${this.text}>`; + } +} + +class Money { + cents: number; + constructor(cents: number) { + this.cents = cents; + } + valueOf(): number { + return this.cents / 100; + } + toString(): string { + return `$${(this.cents / 100).toFixed(2)}`; + } +} + +class Base { + value: number; + constructor(value: number) { + this.value = value; + } + valueOf(): number { + return this.value; + } +} + +class Derived extends Base { + toString(): string { + return `Derived(${this.value})`; + } +} + +class Wrapped { + valueOf(): object { + return {}; + } + toString(): string { + return "wrapped"; + } +} + +class Opaque { + valueOf(): object { + return {}; + } + toString(): object { + return []; + } +} + +class Nothing { + valueOf(): undefined { + return undefined; + } +} + +class Tagged { + name = "t"; + toString(): number { + return 42; + } +} + +class Plain { + x = 1; +} + +function show(label: string, value: unknown): void { + console.log(label, typeof value, value, log.splice(0).join(", ")); +} + +const counter: any = new Counter(); +show("c + 1", counter + 1); +show("c * 3", counter * 3); +show("c - 1", counter - 1); +show("c / 2", counter / 2); +show("c % 4", counter % 4); +show("c ** 2", counter ** 2); +show("c | 0", counter | 0); +show("c & 7", counter & 7); +show("c ^ 1", counter ^ 1); +show("c << 1", counter << 1); +show("c >> 1", counter >> 1); +show("c >>> 0", counter >>> 0); +show("~c", ~counter); +show("c > 2", counter > 2); +show("c <= 100", counter <= 100); +show("+c", +counter); +show("Number(c)", Number(counter)); +show("c == 17", counter == 17); +show("c == c", counter == counter); +show("c == null", counter == null); +show("c == true", counter == true); +show("total", (counter as Counter).total); +let stepped = counter; +stepped++; +show("c++", stepped); +show("total", (counter as Counter).total); + +const label: any = new Label("x"); +show("concat", "[" + label + "]"); +show("template", `${label}!`); +show("String", String(label)); +show("label + 1", label + 1); +show("Number(label)", Number(label)); +show("label < 'a'", label < "a"); +show("label == ''", label == ""); +const bag: any = {}; +bag[label] = 1; +show("key", Object.keys(bag).join(",")); + +const money: any = new Money(1250); +show("money + 1", money + 1); +show("money template", `${money}`); +show("money String", String(money)); +show("money concat", "total: " + money); +show("money > 10", money > 10); + +const derived: any = new Derived(7); +const base: any = new Base(3); +show("derived * 2", derived * 2); +show("derived template", `${derived}`); +show("base template", `${base}`); +show("base + derived", base + derived); + +const wrapped: any = new Wrapped(); +show("wrapped + 1", wrapped + 1); +show("wrapped template", `${wrapped}`); + +const nothing: any = new Nothing(); +show("nothing + 1", nothing + 1); +show("nothing template", `${nothing}`); + +const tagged: any = new Tagged(); +show("tagged template", `${tagged}`); +show("tagged + 1", tagged + 1); + +const plain: any = new Plain(); +show("plain + 1", plain + 1); +show("plain template", `${plain}`); + +const literal: any = { valueOf: () => 5 }; +show("literal + 1", literal + 1); + +const opaque: any = new Opaque(); +for (const attempt of [() => opaque + 1, () => `${opaque}`, () => Number(opaque)]) { + try { + show("opaque", attempt()); + } catch (error) { + console.log("opaque", error instanceof TypeError, (error as Error).message); + } +} + +// Both operands are evaluated before either is converted. +const first: any = new Label("a"); +const second: any = new Label("b"); +const pick = (name: string, value: any): any => { + log.push(`evaluate ${name}`); + return value; +}; +show("order", pick("left", first) + pick("right", second)); +show("order compare", pick("left", first) < pick("right", second)); + +// Property keys convert with the string hint after the assigned value is +// evaluated, and only for receivers that admit a key. +const keyed: any = {}; +keyed[pick("key", label)] = pick("value", 2); +show("keys", Object.keys(keyed).join(",")); +show("in", label in keyed); +show("hasOwn", Object.hasOwn(keyed, label)); +show("read", keyed[label]); +const missing: any = JSON.parse("null"); +for (const attempt of [() => (missing[label] = 1), () => Object.hasOwn(missing, label), () => label in (5 as any)]) { + try { + show("receiver", attempt()); + } catch (error) { + console.log("receiver", error instanceof TypeError, log.splice(0).join(", ")); + } +} diff --git a/tests/corpus/5912-dyn-class-conversion-methods.ts b/tests/corpus/5912-dyn-class-conversion-methods.ts new file mode 100644 index 000000000..de43b0179 --- /dev/null +++ b/tests/corpus/5912-dyn-class-conversion-methods.ts @@ -0,0 +1,123 @@ +// valueOf() and toString() called on values held as `any`, and classes with +// a Symbol.toPrimitive method in dynamic conversions. A class's own methods +// run on the instance itself; primitives, arrays and plain objects answer +// their own valueOf; Symbol.toPrimitive receives the conversion's hint and +// must return a primitive. + +const log: string[] = []; + +class Counter { + total = 0; + valueOf(): number { + log.push("valueOf"); + return ++this.total; + } + toString(): string { + log.push("toString"); + return `Counter(${this.total})`; + } +} + +class Radix { + value: number; + constructor(value: number) { + this.value = value; + } + toString(base?: number): string { + return base === undefined ? `Radix(${this.value})` : this.value.toString(base); + } +} + +class Temperature { + degrees: number; + constructor(degrees: number) { + this.degrees = degrees; + } + [Symbol.toPrimitive](hint: string): string | number { + log.push(`toPrimitive ${hint}`); + return hint === "string" ? `${this.degrees}°` : this.degrees; + } +} + +class Celsius extends Temperature { + valueOf(): number { + log.push("Celsius.valueOf"); + return -1; + } +} + +class Broken { + [Symbol.toPrimitive](): object { + return {}; + } +} + +class Plain { + x = 1; +} + +function show(label: string, value: unknown): void { + console.log(label, typeof value, value, log.splice(0).join(", ")); +} + +function attempt(label: string, run: () => unknown): void { + try { + show(label, run()); + } catch (error) { + console.log(label, error instanceof TypeError, (error as Error).message, log.splice(0).join(", ")); + } +} + +const counter: any = new Counter(); +show("valueOf()", counter.valueOf()); +show("valueOf() again", counter.valueOf()); +show("toString()", counter.toString()); +show("total", (counter as Counter).total); + +const radix: any = new Radix(255); +show("toString()", radix.toString()); +show("toString(16)", radix.toString(16)); +show("toString(2)", radix.toString(2)); + +const plain: any = new Plain(); +show("plain valueOf", plain.valueOf() === plain); +show("plain toString", plain.toString()); + +const values: [string, any][] = [ + ["number", 5], + ["string", "text"], + ["boolean", true], + ["bigint", 10n], + ["literal", { valueOf: () => 7 }], +]; +for (const [label, value] of values) show(`${label} valueOf`, value.valueOf()); +const array: any = [1, 2]; +show("array valueOf", array.valueOf() === array); +const object: any = { a: 1 }; +show("object valueOf", object.valueOf() === object); +const dictionary: any = Object.create(null); +attempt("dictionary valueOf", () => dictionary.valueOf()); +const missing: any = JSON.parse("null"); +attempt("null valueOf", () => missing.valueOf()); + +const temperature: any = new Temperature(21); +show("+", temperature + 1); +show("==", temperature == 21); +show("*", temperature * 2); +show("<", temperature < 30); +show("template", `${temperature}`); +show("String", String(temperature)); +show("Number", Number(temperature)); +show("unary +", +temperature); +const keyed: any = {}; +keyed[temperature] = "warm"; +show("key", Object.keys(keyed).join(",")); + +const celsius: any = new Celsius(5); +show("inherited +", celsius + 1); +show("inherited template", `${celsius}`); +show("own valueOf()", celsius.valueOf()); + +const broken: any = new Broken(); +attempt("broken +", () => broken + 1); +attempt("broken template", () => `${broken}`); diff --git a/tests/corpus/5913-prototype-type-error-messages.ts b/tests/corpus/5913-prototype-type-error-messages.ts new file mode 100644 index 000000000..cb78e4cb1 --- /dev/null +++ b/tests/corpus/5913-prototype-type-error-messages.ts @@ -0,0 +1,39 @@ +// Object.setPrototypeOf and Object.create reject a prototype that is +// neither an object nor null with Node's TypeError, whose message ends with +// the value's string form: numbers as String(n) (-0 as 0), bigints without +// the n suffix, symbols as Symbol(description), and the empty string as +// nothing. + +function message(run: () => unknown): string { + try { + run(); + return "no error"; + } catch (error) { + return `${error instanceof TypeError} ${(error as Error).message}`; + } +} + +const prototypes: [string, unknown][] = [ + ["number", 7], + ["fraction", 1.5], + ["negative zero", -0], + ["nan", NaN], + ["string", "proto"], + ["empty string", ""], + ["boolean", true], + ["undefined", undefined], + ["bigint", 10n], + ["symbol", Symbol("tag")], +]; + +for (const [label, prototype] of prototypes) { + const invalid = prototype as object | null; + const target: unknown = JSON.parse('{"own":1}'); + console.log(label, "object receiver |", message(() => Object.setPrototypeOf(target, invalid))); + console.log(label, "primitive receiver |", message(() => Object.setPrototypeOf(5, invalid))); + console.log(label, "create |", message(() => Object.create(invalid))); +} + +const valid: unknown = JSON.parse('{"own":1}'); +console.log("null prototype |", message(() => Object.setPrototypeOf(valid, null))); +console.log("object prototype |", message(() => Object.create({ inherited: true }))); diff --git a/tests/corpus/5914-dyn-in-class-prototype-members.ts b/tests/corpus/5914-dyn-in-class-prototype-members.ts new file mode 100644 index 000000000..dd4f85f75 --- /dev/null +++ b/tests/corpus/5914-dyn-in-class-prototype-members.ts @@ -0,0 +1,52 @@ +// The `in` operator with literal keys on class instances held as `any`, in +// a program whose instanceof and Object.getPrototypeOf checks give the +// classes runtime prototypes: methods, getters, setters and inherited +// members answer true, as do own fields and Object.prototype members; +// static methods and missing names answer false. + +class Shape { + name = "shape"; + static create(): Shape { + return new Shape(); + } + area(): number { + return 0; + } + get label(): string { + return this.name; + } + set size(value: number) { + this.name = `size ${value}`; + } +} + +class Square extends Shape { + side = 2; + override area(): number { + return this.side * this.side; + } + perimeter(): number { + return this.side * 4; + } +} + +const shape: any = new Shape(); +const square: any = new Square(); +console.log("instanceof", square instanceof Shape, shape instanceof Square); +console.log("same prototype", Object.getPrototypeOf(shape) === Object.getPrototypeOf(square)); + +console.log("method", "area" in shape, "area" in square); +console.log("subclass method", "perimeter" in shape, "perimeter" in square); +console.log("getter", "label" in shape, "label" in square); +console.log("setter", "size" in shape, "size" in square); +console.log("own field", "name" in shape, "side" in square, "side" in shape); +console.log("object prototype", "toString" in shape, "hasOwnProperty" in square); +console.log("static", "create" in shape, "create" in square); +console.log("missing", "volume" in shape, "volume" in square); +console.log("negated", !("area" in shape), !("volume" in square)); + +const names = ["area", "perimeter", "label", "size", "name", "create", "volume"]; +for (const name of names) { + const key: any = JSON.parse(JSON.stringify(name)); + console.log("computed", name, key in shape, key in square); +} 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/corpus/5920-promise-try-args.ts b/tests/corpus/5920-promise-try-args.ts new file mode 100644 index 000000000..a5d309397 --- /dev/null +++ b/tests/corpus/5920-promise-try-args.ts @@ -0,0 +1,22 @@ +// Promise.try(f, ...args): the arguments are evaluated at the call and passed +// to f, which runs synchronously; throws reject and returned promises are +// adopted. +Promise.try(() => 5).then((v) => console.log("value", v)); +Promise.try((): number => { throw new Error("sync"); }).catch((e) => console.log("caught", (e as Error).message)); +Promise.try(() => Promise.resolve("inner")).then((v) => console.log("adopted", v)); +Promise.try(async () => 7).then((v) => console.log("async", v)); +const order: string[] = []; +const p = Promise.try(() => { order.push("run"); return 1; }); +order.push("after"); +p.then(() => console.log(order.join(","))); +Promise.try((a: number, b: number) => a + b, 1, 2).then((v) => console.log("args", v)); +Promise.try(() => undefined).then((v) => console.log("void", v)); +const log: string[] = []; +const v = (x: number): number => { log.push("arg" + x); return x; }; +const q = Promise.try((a: number, b: number) => { log.push("call"); return a + b; }, v(1), v(2)); +log.push("after"); +q.then((r) => console.log(r, log.join(","))); +Promise.try((s: string) => { throw new Error(s); }, "bad").catch((e) => console.log("caught", (e as Error).message)); +Promise.try((n: number) => Promise.resolve(n * 2), 21).then((r) => console.log("adopted", r)); +function named(this: void, x: number): number { return x + 1; } +Promise.try(named, 4).then((r) => console.log("named", r)); diff --git a/tests/corpus/5921-promise-try-args-js.js b/tests/corpus/5921-promise-try-args-js.js new file mode 100644 index 000000000..10171c11d --- /dev/null +++ b/tests/corpus/5921-promise-try-args-js.js @@ -0,0 +1,7 @@ +// Promise.try(f, ...args) in JavaScript: unannotated parameters take the +// passed values. +Promise.try((a, b) => a * b, 6, 7).then((v) => console.log("product", v)); +Promise.try((s) => s.toUpperCase(), "js").then((v) => console.log(v)); +Promise.try((x) => { + throw new RangeError("r" + x); +}, 1).catch((e) => console.log(e.name, e.message)); diff --git a/tests/corpus/5922-matchall-groups-callbacks.ts b/tests/corpus/5922-matchall-groups-callbacks.ts new file mode 100644 index 000000000..9c0c62639 --- /dev/null +++ b/tests/corpus/5922-matchall-groups-callbacks.ts @@ -0,0 +1,19 @@ +// .groups on matchAll rows reached through array callbacks (map, forEach, +// filter, some, find), Array.from's mapping function and parameter +// destructuring, on rows kept by filter, slice and toReversed or picked by +// find and at, with plain, duplicate and nonparticipating named groups. +const text = "a1b2c3"; +console.log([...text.matchAll(/(?[a-z])/g)].map((m) => m.groups!.k).join("")); +console.log([...text.matchAll(/(?[a-z])|(?\d)/g)].map((m) => m.groups!.k).join("")); +console.log(Array.from(text.matchAll(/(?\d)/g), (m) => m.groups!.d).join("+")); +const rows = [...text.matchAll(/(?[a-z])(?\d)/g)]; +rows.forEach((m) => console.log(m.groups!.letter, m.groups!.digit, JSON.stringify(m.groups))); +console.log(rows.filter((m) => m.groups!.digit !== "2").length); +console.log(rows.some((m) => m.groups!.letter === "c"), rows.find((m) => m.groups!.digit === "3") !== undefined); +console.log(rows.map(({ groups }) => groups!.letter + groups!.digit).join(",")); +console.log(rows[0]!.groups); +const mixed = [..."2024 and x9".matchAll(/(?[a-z]+)|(?\d+)/g)]; +console.log(mixed.filter((m) => m.groups!.num !== undefined).map((m) => m.groups!.num).join(",")); +console.log(mixed.slice(1).map((m) => m.groups!.word ?? m.groups!.num).join(" ")); +console.log(mixed.find((m) => m.groups!.word === "and")!.groups); +console.log(mixed.at(-1)!.groups!.num, mixed.toReversed().map((m) => m.groups!.word ?? "-").join("")); diff --git a/tests/corpus/5923-matchall-groups-js.js b/tests/corpus/5923-matchall-groups-js.js new file mode 100644 index 000000000..883d184b6 --- /dev/null +++ b/tests/corpus/5923-matchall-groups-js.js @@ -0,0 +1,42 @@ +// .groups on matchAll rows in JavaScript: array callbacks, Array.from's +// mapping function, destructuring, rows picked with find/at or kept by +// filter/slice, nonparticipating and duplicate named groups. +const text = "a1b2c3"; +console.log([...text.matchAll(/(?[a-z])/g)].map((m) => m.groups.k).join("")); +console.log([...text.matchAll(/(?[a-z])|(?\d)/g)].map((m) => m.groups.k).join("")); +console.log(Array.from(text.matchAll(/(?\d)/g), (m) => m.groups.d).join("+")); +const rows = [...text.matchAll(/(?[a-z])(?\d)/g)]; +rows.forEach((m) => console.log(m.groups.letter, m.groups.digit, JSON.stringify(m.groups))); +console.log(rows.filter((m) => m.groups.digit !== "2").length); +console.log(rows.some((m) => m.groups.letter === "c"), rows.find((m) => m.groups.digit === "3") !== undefined); +rows.forEach(function (m) { + const { groups } = m; + const { + groups: { digit }, + } = m; + console.log(groups.letter, digit); +}); +console.log(rows.map(({ groups }) => groups.letter + groups.digit).join(",")); + +const mixed = [..."2024 and x9".matchAll(/(?[a-z]+)|(?\d+)/g)]; +mixed.forEach((m) => { + console.log(m.groups.word === undefined, m.groups.num ?? "-", typeof m.groups.word, m.groups); +}); +console.log(mixed.filter((m) => m.groups.num !== undefined).map((m) => m.groups.num).join(",")); +console.log(mixed.slice(1).map((m) => m.groups.word ?? m.groups.num).join(" ")); +console.log(mixed.find((m) => m.groups.word === "and").groups); +console.log(mixed.findLast((m) => m.groups.word !== undefined).groups.word); +console.log(mixed.at(-1).groups.num, Object.keys(mixed.at(0).groups)); +try { + console.log(mixed.find((m) => m.groups.word === "zzz").groups); +} catch (e) { + console.log(e instanceof TypeError, e.message); +} + +const dup = [..."a1-b".matchAll(/(?[a-z])|(?\d)|-/g)]; +console.log(dup.map((m) => String(m.groups.v)).join("|")); +const plain = [..."ab".matchAll(/[a-z]/g)]; +console.log(plain.map((m) => m.groups === undefined).join()); + +const one = /(?a)|(?b)/.exec("a"); +console.log(one.groups.n, one.groups.n === undefined, one.groups); diff --git a/tests/corpus/5924-js-literal-identity.js b/tests/corpus/5924-js-literal-identity.js new file mode 100644 index 000000000..732c0202f --- /dev/null +++ b/tests/corpus/5924-js-literal-identity.js @@ -0,0 +1,43 @@ +// JavaScript: an object stored in a file-scope binding keeps its identity in +// array and object literals, directly or nested, at file scope and in +// functions: writes through the container reach the binding's object. +const marked = { inner: true }; +const arr = [1, marked]; +console.log(arr[1] === marked); +arr[1].inner = false; +console.log(marked.inner); +const holder = { deeper: { nested: marked } }; +console.log(holder.deeper.nested === marked); +const one = [marked]; +one[0].inner = "x"; +console.log(marked.inner, one[0] === marked); + +const tracked = { inner: 1 }; +const other = { inner: 2 }; +const list = [tracked]; +list.push(other); +console.log(list.indexOf(other), list.includes(tracked)); +const o = { list: [tracked] }; +o.list[0].inner = 10; +console.log(tracked.inner); +const nested = [[tracked]]; +nested[0][0].inner = 11; +console.log(tracked.inner, nested[0][0] === tracked); +function g() { return [tracked, other]; } +const got = g(); +got[1].inner = 12; +console.log(other.inner); +const mapped = [tracked, other].map((m) => m); +console.log(mapped[0] === tracked); +const scoped = { inner: true }; +function inFunction() { + const arr = [1, scoped]; + console.log(arr[1] === scoped); + arr[1].inner = false; + console.log(scoped.inner); + const h = { deeper: { nested: scoped } }; + console.log(h.deeper.nested === scoped); + const one = [scoped]; + console.log(one[0] === scoped); +} +inFunction(); diff --git a/tests/corpus/5927-switch-object-identity.ts b/tests/corpus/5927-switch-object-identity.ts new file mode 100644 index 000000000..04d2025a5 --- /dev/null +++ b/tests/corpus/5927-switch-object-identity.ts @@ -0,0 +1,71 @@ +// switch over objects, arrays, records and functions matches cases with ===: +// by identity, with case expressions evaluated lazily in source order. +class Mode { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} +const fast = new Mode("fast"); +const slow = new Mode("slow"); +function label(mode: Mode | undefined): string { + switch (mode) { + case fast: + return "fast"; + case slow: + return "slow"; + case undefined: + return "none"; + default: + return "other"; + } +} +console.log(label(fast), label(slow), label(undefined), label(new Mode("fast"))); +type Point = { x: number }; +const origin: Point = { x: 0 }; +function where(p: Point): string { + switch (p) { + case origin: + return "origin"; + default: + return `at ${p.x}`; + } +} +console.log(where(origin), where({ x: 0 })); +const list = [1, 2]; +const other = [1, 2]; +for (const arr of [list, other]) { + switch (arr) { + case list: + console.log("list"); + break; + case other: + console.log("other"); + } +} +const order: string[] = []; +const pick = (m: Mode): Mode => { + order.push(m.name); + return m; +}; +switch (slow) { + case pick(fast): + console.log("no"); + break; + case pick(slow): + console.log("matched second"); + break; + case pick(fast): + console.log("never evaluated"); +} +console.log(order.join(",")); +const f = (): number => 1; +const g = (): number => 2; +switch (g as () => number) { + case f: + console.log("f"); + break; + case g: + console.log("g"); + break; +} diff --git a/tests/corpus/5928-groupby-iterables.ts b/tests/corpus/5928-groupby-iterables.ts new file mode 100644 index 000000000..a3322e1ed --- /dev/null +++ b/tests/corpus/5928-groupby-iterables.ts @@ -0,0 +1,37 @@ +// Object.groupBy and Map.groupBy over Sets, Maps and generators: Sets and +// Maps are walked live, generators are pulled one element per callback and +// closed when a callback throws. +const words = new Set(["aa", "b", "cc", "ddd"]); +console.log(JSON.stringify(Object.groupBy(words, (s) => s.length))); +const byParity = Map.groupBy(new Set([1, 2, 3, 4, 5]), (n, i) => (n + i) % 3); +console.log([...byParity.entries()].map(([k, v]) => `${k}:${v.join("")}`).join(" ")); +const prices = new Map([["a", 3], ["b", 12], ["c", 7], ["d", 20]]); +const tiers = Object.groupBy(prices, ([, price]) => (price > 10 ? "high" : "low") as string); +console.log(JSON.stringify(tiers)); +const live = new Set([1, 2, 3]); +const seen = Map.groupBy(live, (n) => { + if (n === 1) live.add(4); + if (n === 2) live.delete(3); + return n % 2 === 0; +}); +console.log(seen.get(true)?.join(","), seen.get(false)?.join(",")); +function* gen(): Generator { + try { + for (let i = 0; i < 4; i++) { + console.log("yield", i); + yield i; + } + } finally { + console.log("gen done"); + } +} +console.log(JSON.stringify(Object.groupBy(gen(), (n) => { console.log("key", n); return (n < 2 ? "small" : "big") as string; }))); +try { + Map.groupBy(gen(), (n) => { + if (n === 1) throw new Error("stop"); + return n; + }); +} catch (e) { + console.log("caught", (e as Error).message); +} +console.log(Object.groupBy(new Set(), (s) => s)); diff --git a/tests/corpus/5929-groupby-js.js b/tests/corpus/5929-groupby-js.js new file mode 100644 index 000000000..40dfc19be --- /dev/null +++ b/tests/corpus/5929-groupby-js.js @@ -0,0 +1,6 @@ +// Object.groupBy and Map.groupBy in JavaScript: unannotated callback +// parameters take the element and index. +const m = Map.groupBy([1, 2, 3, 4], (n) => n % 2 === 0); +console.log(m.get(true)?.join(","), m.get(false)?.join(","), [...m.keys()].join(",")); +console.log(JSON.stringify(Object.groupBy(["aa", "b", "cc"], (s, i) => s.length + i))); +console.log(JSON.stringify(Object.groupBy(new Set(["x", "yy"]), (s) => s.length))); diff --git a/tests/corpus/5930-contract-int32-arrays.ts b/tests/corpus/5930-contract-int32-arrays.ts new file mode 100644 index 000000000..305837b2d --- /dev/null +++ b/tests/corpus/5930-contract-int32-arrays.ts @@ -0,0 +1,177 @@ +// Int32[] contracts (@scriptc/types): every element stored into an array +// that can reach an Int32[] slot is an int32 by construction, so element +// reads that find a value are int32s and integer kernels over them run in +// 32-bit arithmetic. Holes, missing indices and length changes keep their +// JavaScript behavior. `import type` is erased: Node never loads the +// package. +import type { Int32 } from "@scriptc/types"; + +function lcg(n: number, seed: number): Int32[] { + const out: Int32[] = []; + let x = seed | 0; + for (let i = 0; i < n; i++) { + x = (Math.imul(x, 1103515245) + 12345) | 0; + out.push(x >> 8); + } + return out; +} + +function sum(a: Int32[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + a[i]) | 0; + return s; +} + +function hash(a: readonly Int32[]): number { + let h = 17; + for (let i = 0; i < a.length; i++) h = (Math.imul(h, 31) + a[i]) | 0; + return h; +} + +function xorAll(a: Array): number { + let h = 0; + for (let i = 0; i < a.length; i++) h ^= a[i]; + return h; +} + +function dot(a: Int32[], b: Int32[]): number { + let s = 0; + for (let i = 0; i < a.length; i++) s = (s + Math.imul(a[i], b[i])) | 0; + return s; +} + +function prefix(a: Int32[], out: Int32[]): void { + let run = 0; + for (let i = 0; i < a.length; i++) { + run = (run + a[i]) | 0; + out[i] = run; + } +} + +function gather(table: Int32[], idx: Int32[], out: Int32[]): void { + for (let i = 0; i < idx.length; i++) out[i] = table[idx[i]]; +} + +function histogram(keys: Int32[], buckets: number): Int32[] { + const counts: Int32[] = new Array(buckets).fill(0); + for (let i = 0; i < keys.length; i++) { + const k = keys[i] & (buckets - 1); + counts[k] = (counts[k] + 1) | 0; + } + return counts; +} + +function forOfSum(a: Int32[]): number { + let s = 0; + for (const x of a) s = (s + x) | 0; + return s; +} + +class Grid { + readonly width: number; + cells: Int32[]; + constructor(width: number, height: number) { + this.width = width; + this.cells = new Array(width * height).fill(0); + } + set(x: number, y: number, v: number): void { + this.cells[y * this.width + x] = v | 0; + } + rowSum(y: number): number { + let s = 0; + for (let x = 0; x < this.width; x++) s = (s + this.cells[y * this.width + x]) | 0; + return s; + } +} + +interface Mesh { + name: string; + indices: number[]; +} + +function meshChecksum(m: Mesh): number { + let h = 0; + for (let i = 0; i < m.indices.length; i++) h = (Math.imul(h, 33) ^ m.indices[i]) | 0; + return h; +} + +const big = lcg(4096, 7); +const other = lcg(4096, 11); +console.log("sum", sum(big), "hash", hash(big), "xor", xorAll(big), "dot", dot(big, other)); +console.log("for-of", forOfSum(big) === sum(big)); + +// Values at the int32 edges stay exact. +const edges: Int32[] = [2147483647, -2147483648, 0, -1, 1]; +console.log("edges", sum(edges), hash(edges), edges[0] + edges[0], edges[1] - 1); + +const pre: Int32[] = []; +pre.length = big.length; +prefix(big, pre); +console.log("prefix", pre[0], pre[100], pre[pre.length - 1], pre.length); + +const idx: Int32[] = []; +for (let i = 0; i < 16; i++) idx.push((i * 7) & 15); +const table: Int32[] = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]; +const picked: Int32[] = []; +gather(table, idx, picked); +console.log("gather", picked.join(",")); + +const counts = histogram(big, 8); +console.log("histogram", counts.join(","), sum(counts)); + +// Holes, present undefined and missing indices read as in JavaScript: +// undefined, and NaN in arithmetic, which ToInt32 turns into 0. +const holey: Int32[] = [1, 2, 3]; +holey[6] = 7; +console.log("holey", holey.length, holey[4], holey[4] === undefined, sum(holey), hash(holey)); +console.log("missing", holey[100], (holey[100] + 1) | 0, holey[-1], 4 in holey, 6 in holey); +console.log("search", holey.indexOf(3), holey.includes(7), holey.includes(5)); +holey.length = 2; +console.log("truncated", holey.length, holey[0], holey[1], sum(holey)); + +// Mutating methods, copies and spreads. +const work: Int32[] = [5, -3, 9, 0, 12]; +const more = [8, 1]; +work.unshift(4, 4); +work.push(...more); +work.splice(2, 1, 100, 101); +work.sort((x, y) => x - y); +work.reverse(); +work.fill(-7, 0, 1); +const copy: Int32[] = work.slice(1, 4); +const joined: Int32[] = [...copy, ...work, 3]; +const concatenated: Int32[] = copy.concat(joined); +console.log("work", work.join(" "), "|", copy.join(" "), "|", joined.length, concatenated.length); +console.log("popped", work.pop(), work.shift(), work.length, sum(work), Math.max(...work), Math.min(...copy)); + +// Fields, records, closures and maps carry the facts along. +const grid = new Grid(4, 3); +for (let y = 0; y < 3; y++) for (let x = 0; x < 4; x++) grid.set(x, y, x * 10 + y); +console.log("grid", grid.rowSum(0), grid.rowSum(2), grid.cells.length); + +const mesh: Mesh = { name: "tri", indices: [0, 1, 2, 2, 1, 3] }; +const meshIndices: Int32[] = mesh.indices; +meshIndices.push(3); +console.log("mesh", mesh.name, meshChecksum(mesh), mesh.indices.length); + +const byName = new Map(); +const tracked: Int32[] = [1, 2, 3]; +byName.set("t", tracked); +byName.get("t")!.push(4); +let captured = 0; +const addAll = (): void => { + for (let i = 0; i < tracked.length; i++) captured = (captured + tracked[i]) | 0; +}; +addAll(); +console.log("map", tracked.join(","), captured, byName.get("t")!.length); + +// Library helpers shared with ordinary number arrays: sorting moves the +// array's own elements, filtering keeps them. +const prices = [2.5, 1.25, 9.75]; +const ranks: Int32[] = [5, 3, 9, 1]; +ranks.sort((x, y) => x - y); +prices.sort((x, y) => x - y); +const high: Int32[] = ranks.filter((x) => x > 2); +console.log("shared", ranks.join(","), prices.join(","), high.join(","), prices.filter((x) => x > 2).length); + +console.log(edges, JSON.stringify(copy)); diff --git a/tests/corpus/5930-method-census-asserted-number-keys.ts b/tests/corpus/5930-method-census-asserted-number-keys.ts new file mode 100644 index 000000000..149722431 --- /dev/null +++ b/tests/corpus/5930-method-census-asserted-number-keys.ts @@ -0,0 +1,56 @@ +// Element writes whose key is asserted to a number (`at as number`, +// `at!`) can only replace methods with numeric names. Ordinary methods +// keep their calls, and a method named by a number is still replaced. +class Counter { + n = 0; + bump(): void { + this.n++; + } + label(): string { + return `counter ${this.n}`; + } +} + +class Indexed { + 0(): string { + return "declared zero"; + } + name(): string { + return "indexed"; + } +} + +function clear(slots: (Counter | null)[], positions: Map, key: string): void { + const at = positions.get(key); + if (at !== undefined || slots.length > 0) slots[at as number] = null; +} + +function clearAsserted(slots: (Counter | null)[], positions: Map, key: string): void { + const at = positions.get(key); + if (at !== undefined) slots[at!] = null; +} + +function replace(target: Indexed, positions: Map): void { + const at = positions.get("zero"); + (target as any)[at as number] = () => "replaced zero"; +} + +const counters: Counter[] = []; +for (let i = 0; i < 4; i++) counters.push(new Counter()); +const slots: (Counter | null)[] = [...counters]; +const positions = new Map([ + ["a", 1], + ["b", 2], + ["zero", 0], +]); +clear(slots, positions, "a"); +clearAsserted(slots, positions, "b"); +console.log(slots.map((slot) => (slot === null ? "empty" : slot.label())).join(", ")); + +for (let round = 0; round < 3; round++) for (const c of counters) c.bump(); +console.log(counters.map((c) => c.label()).join(", ")); + +const indexed = new Indexed(); +console.log(indexed[0](), indexed.name()); +replace(indexed, positions); +console.log(indexed[0](), indexed.name()); diff --git a/tests/corpus/5931-asserted-reads-keep-number-parameters.ts b/tests/corpus/5931-asserted-reads-keep-number-parameters.ts new file mode 100644 index 000000000..fa4f96f29 --- /dev/null +++ b/tests/corpus/5931-asserted-reads-keep-number-parameters.ts @@ -0,0 +1,52 @@ +// Arguments asserted to a type without undefined (`xs[i] as number`, +// `stack.pop() as number`, `map.get(k) as Item`) pass present values. +// A callee that also receives an unchecked missing read still observes +// undefined there. +class Item { + weight: number; + constructor(weight: number) { + this.weight = weight; + } +} + +function hot(n: number): number { + return (n * 2) | 0; +} + +function describe(n: number): string { + return n === undefined ? "missing" : `value ${n}`; +} + +function weigh(item: Item): number { + return item.weight; +} + +function cold(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s + hot(xs[i] as number)) | 0; + return s; +} + +function drain(stack: number[]): number { + let s = 0; + while (stack.length > 0) s = (s + hot(stack.pop() as number)) | 0; + return s; +} + +function total(items: Map, keys: string[]): number { + let sum = 0; + for (const key of keys) if (items.has(key)) sum += weigh(items.get(key) as Item); + return sum; +} + +const xs = [1, 2, 3]; +console.log(cold(xs), drain([4, 5, 6])); +console.log(describe(xs[1] as number), describe(xs[7])); +const items = new Map([ + ["a", new Item(10)], + ["b", new Item(20)], +]); +console.log(total(items, ["a", "b", "c"])); +let t = 0; +for (let i = 0; i < 1000; i++) t = (t + hot(i)) | 0; +console.log(t); diff --git a/tests/corpus/5932-generic-class-observed-methods.ts b/tests/corpus/5932-generic-class-observed-methods.ts new file mode 100644 index 000000000..0756a88ce --- /dev/null +++ b/tests/corpus/5932-generic-class-observed-methods.ts @@ -0,0 +1,93 @@ +// A write with a computed string key can replace any method, so calls of +// every method name check the receiver for an own property first. Generic +// class instances, their subclasses and mixin classes keep their declared +// methods when no own property replaces them. +class Ctx { + v: number; + constructor() { + this.v = 0; + } + json(o: T): string { + return JSON.stringify(o); + } + status(n: number): Ctx { + this.v = n; + return this; + } +} + +class Box { + value: T; + constructor(value: T) { + this.value = value; + } + get(): T { + return this.value; + } + describe(): string { + return `box(${String(this.value)})`; + } +} + +class NumberBox extends Box { + double(): number { + return this.value * 2; + } + describe(): string { + return `number ${super.describe()}`; + } +} + +class Base { + name(): string { + return "base"; + } +} + +class Tagged extends Base { + tag: T; + constructor(tag: T) { + super(); + this.tag = tag; + } + label(): string { + return `${this.name()}:${String(this.tag)}`; + } +} + +type Ctor = new (...args: any[]) => T; +function Sized(Base: B) { + return class extends Base { + size(): number { + return 3; + } + }; +} +const SizedBase = Sized(Base); + +function write(target: object, key: string, value: unknown): void { + (target as any)[key] = value; +} + +const c = new Ctx<{ ok: boolean }>(); +write(c, "other", () => 42); +console.log(typeof c.json, typeof c.status, c.status(200).json({ ok: true }), c.v); + +const a = new Box("a"); +const b = new Box(1); +const n = new NumberBox(4); +const t = new Tagged(true); +const s = new SizedBase(); +console.log(a.get(), a.describe(), b.get(), b.describe()); +console.log(n.get(), n.double(), n.describe(), t.label(), t.name(), s.size(), s.name()); +console.log(Object.prototype.hasOwnProperty.call(a, "get")); +const read = a.get; +console.log(read.call(a)); + +write(b, "describe", () => "replaced"); +write(n, "double", () => -1); +write(s, "size", () => 0); +console.log(b.describe(), a.describe(), Object.prototype.hasOwnProperty.call(b, "describe")); +console.log(n.double(), new NumberBox(3).double(), s.size(), new SizedBase().size()); +const boxes: Box[] = [b, n, new Box(7)]; +console.log(boxes.map((box) => box.describe()).join(" | ")); diff --git a/tests/corpus/5933-class-fields-unknown-object.ts b/tests/corpus/5933-class-fields-unknown-object.ts new file mode 100644 index 000000000..aadcec590 --- /dev/null +++ b/tests/corpus/5933-class-fields-unknown-object.ts @@ -0,0 +1,99 @@ +// TypeScript class fields declared `unknown` or `object` (also with +// `null` or `undefined`, or as arrays) hold any value of those types. +// Reads narrow with typeof and instanceof, stored references keep their +// identity, and static fields and parameter properties work the same way. +class Item { + name: string; + constructor(name: string) { + this.name = name; + } + greet(): string { + return `item ${this.name}`; + } +} + +class Slot { + value: unknown = null; + data: object | null = null; + list: unknown[] = []; + extra?: unknown; + set(next: unknown): void { + this.value = next; + } + describe(): string { + if (typeof this.value === "string") return `string of ${this.value.length}`; + if (typeof this.value === "number") return `number ${this.value * 2}`; + if (this.value instanceof Item) return this.value.greet(); + if (Array.isArray(this.value)) return `array of ${this.value.length}`; + return `other ${String(this.value)}`; + } +} + +class Holder { + payload: unknown; + readonly meta: object; + constructor(payload: unknown, meta: object) { + this.payload = payload; + this.meta = meta; + } +} + +class Registry { + static current: unknown = null; + static entries: object[] = []; +} + +class Cell { + item: T; + note: unknown = undefined; + constructor(item: T) { + this.item = item; + } +} + +const slot = new Slot(); +console.log(slot.value, slot.data, slot.list, slot.extra, slot.describe()); +const item = new Item("a"); +for (const next of ["text", 21, item, [1, 2, 3], true, undefined] as unknown[]) { + slot.set(next); + console.log(slot.describe()); +} + +slot.value = item; +item.name = "b"; +console.log((slot.value as Item).name, slot.value === item); +const record = { count: 1 }; +slot.data = record; +record.count = 2; +console.log(slot.data, slot.data === record); +const numbers = [1, 2]; +slot.data = numbers; +numbers.push(3); +console.log(slot.data, slot.data === numbers); +const lookup = new Map([["k", 1]]); +slot.value = lookup; +lookup.set("j", 2); +console.log(slot.value); +const double = (x: number): number => x * 2; +slot.value = double; +if (typeof slot.value === "function") console.log(slot.value === double, (slot.value as (x: number) => number)(4)); +slot.list.push(item, 1, null, "s"); +console.log(slot.list.length, slot.list[0] === item, slot.list.slice(1)); +slot.extra = { nested: [1, { deep: true }] }; +console.log(JSON.stringify(slot.extra), slot.extra ?? "none"); +slot.value = undefined; +console.log(slot.value === undefined, slot.value ?? "fallback"); + +const holder = new Holder([1, 2, 3], { tag: "m" }); +console.log(Array.isArray(holder.payload), holder.payload, holder.meta); +const values: unknown[] = [holder.payload, holder.meta]; +console.log(values.length, values[0] === holder.payload); + +console.log(Registry.current, Registry.entries); +Registry.current = { a: 1 }; +Registry.entries.push(record, numbers); +console.log(Registry.current, Registry.entries.length, Registry.entries[0] === record); + +const cell = new Cell(1); +cell.note = "note"; +console.log(cell.item, cell.note, typeof cell.note); diff --git a/tests/corpus/5934-js-class-fields-property-order.js b/tests/corpus/5934-js-class-fields-property-order.js new file mode 100644 index 000000000..bb545412d --- /dev/null +++ b/tests/corpus/5934-js-class-fields-property-order.js @@ -0,0 +1,93 @@ +// Property order of JavaScript class instances that also hold added +// properties. Fields assigned at the top of a constructor exist from +// construction, so they come first in their assignment order; fields +// created later, or after the instance was passed somewhere, keep their +// creation position among the added properties. +class Point { + constructor(x, y) { + this.x = x; + this.y = y; + } + move() { + this.x++; + } +} + +class Point3 extends Point { + constructor(x, y, z) { + super(x, y); + this.z = z; + } +} + +class Late { + constructor(name) { + this.name = name; + this.setup(); + this.after = 1; + } + setup() { + this.extra = "set up"; + } +} + +class Registered { + constructor(list) { + list.push(this); + this.id = list.length; + } +} + +class Derived extends Late { + constructor() { + super("derived"); + this.own = true; + } +} + +class Declared { + label = "declared"; + constructor(count) { + this.count = count; + } +} + +/** @param {Record} target @param {string} key @param {unknown} value */ +function put(target, key, value) { + target[key] = value; +} + +const p = new Point(1, 2); +put(p, "tag", "a"); +p.move(); +console.log(Object.keys(p)); +for (const key in p) console.log("key", key); + + +const q = new Point3(3, 4, 5); +put(q, "w", 6); +q.move(); +console.log(Object.keys(q)); + +const late = new Late("first"); +put(late, "added", 2); +console.log(Object.keys(late)); + +const list = []; +const r = new Registered(list); +put(r, "note", "n"); +console.log(Object.keys(r)); + +const d = new Derived(); +put(d, "more", 3); +console.log(Object.keys(d)); + +const c = new Declared(4); +put(c, "next", 5); +console.log(Object.keys(c)); + +p.move = function () { + this.y--; +}; +p.move(); +console.log(p.x, p.y, Object.keys(p)); diff --git a/tests/corpus/5935-js-subclass-fields-and-element-calls.js b/tests/corpus/5935-js-subclass-fields-and-element-calls.js new file mode 100644 index 000000000..cd36730c4 --- /dev/null +++ b/tests/corpus/5935-js-subclass-fields-and-element-calls.js @@ -0,0 +1,71 @@ +// JavaScript subclass fields keep their assigned types when the base +// constructor only assigns fields, and stay undefined until assigned when +// the base constructor calls an overridden method first. Calls on a +// possibly missing array element throw Node's TypeError. +class Shape { + constructor(name) { + this.name = name; + this.sides = 0; + } + area() { + return 0; + } + describe() { + return `${this.name} with ${this.sides} sides, area ${this.area()}`; + } +} + +class Rect extends Shape { + constructor(width, height) { + super("rect"); + this.sides = 4; + this.width = width; + this.height = height; + } + area() { + return this.width * this.height; + } +} + +class Square extends Rect { + constructor(size) { + super(size, size); + this.name = "square"; + this.size = size; + } +} + +class Reporter { + constructor() { + this.log = []; + this.report(); + } + report() { + this.log.push("base"); + } +} + +class EarlyReporter extends Reporter { + constructor() { + super(); + this.label = "early"; + this.count = 1; + } + report() { + console.log("before assignment:", this.label, this.count); + } +} + +const shapes = [new Shape("dot"), new Rect(2, 3), new Square(4)]; +let total = 0; +for (let i = 0; i < shapes.length; i++) total += shapes[i].area(); +for (let i = 0; i < shapes.length; i++) console.log(shapes[i].describe()); +console.log(total, Object.keys(shapes[2])); +try { + shapes[7].describe(); +} catch (error) { + console.log(error instanceof TypeError, error.message); +} +const reporter = new EarlyReporter(); +reporter.report(); +console.log(Object.keys(reporter)); diff --git a/tests/corpus/5936-checked-js-class-types.js b/tests/corpus/5936-checked-js-class-types.js new file mode 100644 index 000000000..75d0154a1 --- /dev/null +++ b/tests/corpus/5936-checked-js-class-types.js @@ -0,0 +1,68 @@ +// @ts-check +// A JavaScript file TypeScript checks uses its JSDoc types for class +// layouts: typed constructor parameters and fields, method overrides with +// documented parameters, and methods that only a typed write replaces. +class Account { + /** + * @param {string} owner + * @param {number} balance + */ + constructor(owner, balance) { + this.owner = owner; + this.balance = balance; + this.history = /** @type {number[]} */ ([]); + } + /** @param {number} amount */ + deposit(amount) { + this.balance += amount; + this.history.push(amount); + } + /** @returns {number} */ + fee() { + return 1; + } + close() { + this.balance = 0; + } + /** @returns {string} */ + summary() { + return `${this.owner}: ${this.balance} (fee ${this.fee()}, ${this.history.length} deposits)`; + } +} + +class Savings extends Account { + /** + * @param {string} owner + * @param {number} balance + * @param {number} rate + */ + constructor(owner, balance, rate) { + super(owner, balance); + this.rate = rate; + } + /** @returns {number} */ + fee() { + return 0; + } + /** @param {number} amount */ + deposit(amount) { + super.deposit(amount + amount * this.rate); + } +} + +/** @type {Account[]} */ +const accounts = [new Account("ann", 10), new Savings("bo", 100, 0.5)]; +for (let round = 0; round < 3; round++) + for (let i = 0; i < accounts.length; i++) accounts[i].deposit(round + 1); +for (const account of accounts) console.log(account.summary()); +let fees = 0; +for (let i = 0; i < accounts.length; i++) fees += accounts[i].fee(); +console.log(fees, Object.keys(accounts[1])); + +const closing = new Account("cy", 5); +closing.close = function () { + this.balance = -1; +}; +closing.close(); +accounts[0].close(); +console.log(closing.balance, accounts[0].balance); diff --git a/tests/corpus/5937-override-narrowed-union-returns.ts b/tests/corpus/5937-override-narrowed-union-returns.ts new file mode 100644 index 000000000..f0780fe8c --- /dev/null +++ b/tests/corpus/5937-override-narrowed-union-returns.ts @@ -0,0 +1,80 @@ +// An override may narrow its base method's return type to part of the +// base's union (`Meta` for `Meta | undefined`, `number` for +// `number | undefined`, a subclass of a union arm). Calls through the base +// type return the narrowed value, and calls through the overriding class +// see its declared type. +class Meta { + readonly name: string; + constructor(name: string) { + this.name = name; + } +} +class TaggedMeta extends Meta { + tag(): string { + return `#${this.name}`; + } +} +abstract class Host { + metadata(_key: string): Meta | undefined { + return undefined; + } + size(): number | undefined { + return undefined; + } + value(): string | number | null { + return null; + } + abstract tagged(): TaggedMeta | undefined; +} +class FileHost extends Host { + metadata(key: string): Meta { + return new Meta(key); + } + size(): number { + return 3; + } + value(): string | number { + return this.size() > 2 ? "big" : 1; + } + tagged(): TaggedMeta { + return new TaggedMeta("t"); + } +} +class CachedHost extends FileHost { + metadata(key: string): TaggedMeta { + return new TaggedMeta(key.toUpperCase()); + } + size(): number { + return super.size() * 2; + } +} +class EmptyHost extends Host { + tagged(): TaggedMeta | undefined { + return undefined; + } +} +abstract class NamedHost extends Host { + abstract metadata(key: string): Meta; +} +class DirHost extends NamedHost { + metadata(key: string): Meta { + return new Meta(`dir:${key}`); + } + tagged(): TaggedMeta { + return new TaggedMeta("d"); + } +} +const hosts: Host[] = [new FileHost(), new CachedHost(), new EmptyHost(), new DirHost()]; +for (const host of hosts) { + console.log(host.metadata("a")?.name, host.size(), host.value(), host.tagged()?.tag()); +} +const file = new FileHost(); +console.log(file.metadata("b").name.length, file.size() + 1, file.tagged().tag()); +const cached = new CachedHost(); +console.log(cached.metadata("c").tag(), cached.size() * 10, typeof cached.value()); +const pick = (h: FileHost): Meta => h.metadata("d"); +console.log(pick(cached).name, pick(file) instanceof TaggedMeta); +const none = new EmptyHost().tagged(); +console.log(none, [none].length); +const named: NamedHost = new DirHost(); +console.log(named.metadata("e").name, named.metadata("e") instanceof Meta); diff --git a/tests/corpus/5939-js-let-object-reassigned-wider.js b/tests/corpus/5939-js-let-object-reassigned-wider.js new file mode 100644 index 000000000..f23a6a059 --- /dev/null +++ b/tests/corpus/5939-js-let-object-reassigned-wider.js @@ -0,0 +1,29 @@ +// An untyped `let` that starts as one object literal and is later +// assigned object literals with more properties holds each object as +// written: the added properties read back, and properties an object lacks +// read as undefined. +function zip(left, right) { + let state = { _tag: "PullBoth" }; + const out = []; + for (let i = 0; i < left.length; i++) { + if (state._tag === "PullBoth") state = { _tag: "PullLeft", right: right[i] }; + else if (state._tag === "PullLeft") { + out.push([left[i], state.right]); + state = { _tag: "PullBoth" }; + } + } + return { out, last: state._tag, pending: state.right }; +} +console.log(zip([1, 2, 3, 4], ["a", "b", "c", "d"])); + +let point = { x: 1 }; +const update = () => { + point = { x: 2, y: 3 }; +}; +console.log(point.y, Object.keys(point)); +update(); +console.log(point.x, point.y, Object.keys(point), JSON.stringify(point)); + +let config = { name: "base" }; +config = { ...config, debug: true }; +console.log(config.debug, config); diff --git a/tests/corpus/5940-callback-helper-specialization.ts b/tests/corpus/5940-callback-helper-specialization.ts new file mode 100644 index 000000000..1ceafef64 --- /dev/null +++ b/tests/corpus/5940-callback-helper-specialization.ts @@ -0,0 +1,135 @@ +// Helpers called from several sites with different callbacks keep Node's +// behavior: results, callback order, closures, identity and a mix of known +// and unknown callbacks per helper. +function mapInPlace(values: number[], f: (x: number) => number): number[] { + for (let i = 0; i < values.length; i++) values[i] = f(values[i]!); + return values; +} + +function countIf(values: number[], pred: (x: number) => boolean): number { + let n = 0; + for (const v of values) if (pred(v)) n++; + return n; +} + +function forEachPair(values: string[], f: (a: string, b: string) => void): void { + for (let i = 0; i < values.length; i++) + for (let j = i + 1; j < values.length; j++) f(values[i]!, values[j]!); +} + +function sortBy(items: T[], key: (item: T) => number): T[] { + for (let i = 1; i < items.length; i++) { + const cur = items[i]!; + const k = key(cur); + let j = i - 1; + while (j >= 0 && key(items[j]!) > k) { + items[j + 1] = items[j]!; + j--; + } + items[j + 1] = cur; + } + return items; +} + +function triple(x: number): number { + return x * 3; +} +const negate = (x: number): number => -x; + +interface Point { + x: number; + y: number; + label: string; +} + +function main(): void { + console.log(mapInPlace([1, 2, 3, 4], (x) => x + 1).join(",")); + console.log(mapInPlace([1, 2, 3, 4], (x) => x * x).join(",")); + console.log(mapInPlace([1, 2, 3, 4], triple).join(",")); + console.log(mapInPlace([1, 2, 3, 4], negate).join(",")); + const half = (x: number): number => x / 2; + console.log(mapInPlace([1, 2, 3, 4], half).join(",")); + let offset = 10; + const shift = (x: number): number => x + offset; + console.log(mapInPlace([1, 2], shift).join(",")); + offset = 20; + console.log(mapInPlace([1, 2], shift).join(",")); + + const values = [5, 12, 7, 30, 1, 18]; + console.log(countIf(values, (x) => x > 6), countIf(values, (x) => (x & 1) === 1)); + let calls = 0; + console.log( + countIf(values, (x) => { + calls++; + return x % 3 === 0; + }), + calls, + ); + + const seen: string[] = []; + forEachPair(["a", "b", "c"], (a, b) => seen.push(a + b)); + forEachPair(["x", "y", "z"], (a, b) => seen.push(b + a)); + console.log(seen.join(" ")); + + const points: Point[] = [ + { x: 3, y: 1, label: "p" }, + { x: 1, y: 4, label: "q" }, + { x: 2, y: 2, label: "r" }, + { x: 5, y: 0, label: "s" }, + ]; + console.log( + sortBy(points.slice(), (p) => p.x) + .map((p) => p.label) + .join(""), + ); + console.log( + sortBy(points.slice(), (p) => p.y) + .map((p) => p.label) + .join(""), + ); + console.log(sortBy(["ccc", "a", "bb"], (s) => s.length).join(",")); + console.log(sortBy([3, 1, 2], (n) => -n).join(",")); + + // The order of key calls, observed through a side effect. + const order: number[] = []; + sortBy([3, 1, 2], (n) => { + order.push(n); + return n; + }); + console.log(order.join(",")); + + // A callback chosen at run time stays a callback value. + const pick = values.length > 3 ? (x: number) => x - 1 : (x: number) => x + 1; + console.log(mapInPlace([10, 20], pick).join(",")); + let chosen: (x: number) => number = triple; + if (values[0] === 5) chosen = negate; + console.log(mapInPlace([10, 20], chosen).join(",")); + + // The helper as a value next to direct calls. + const helper = mapInPlace; + console.log(helper([1, 2], (x) => x * 100).join(","), helper === mapInPlace); + + // Function identity inside the helper: a declared function is one value. + console.log(countIf([1, 2, 3], (x) => x > 1), countIf([1], triple === triple ? (x) => x > 0 : (x) => x < 0)); + + // More distinct callbacks than the helper gets copies. + const results: number[] = []; + for (const f of [ + (x: number) => x + 1, + (x: number) => x + 2, + ]) + results.push(mapInPlace([1], f)[0]!); + results.push(mapInPlace([1], (x) => x + 3)[0]!); + results.push(mapInPlace([1], (x) => x + 4)[0]!); + results.push(mapInPlace([1], (x) => x + 5)[0]!); + results.push(mapInPlace([1], (x) => x + 6)[0]!); + results.push(mapInPlace([1], (x) => x + 7)[0]!); + results.push(mapInPlace([1], (x) => x + 8)[0]!); + results.push(mapInPlace([1], (x) => x + 9)[0]!); + results.push(mapInPlace([1], (x) => x + 10)[0]!); + results.push(mapInPlace([1], (x) => x + 11)[0]!); + results.push(mapInPlace([1], (x) => x + 12)[0]!); + console.log(results.join(",")); +} + +main(); diff --git a/tests/corpus/5941-callback-specialization-recursion.ts b/tests/corpus/5941-callback-specialization-recursion.ts new file mode 100644 index 000000000..a0ce1d285 --- /dev/null +++ b/tests/corpus/5941-callback-specialization-recursion.ts @@ -0,0 +1,151 @@ +// Recursive and forwarding helpers with several callbacks: recursion that +// forwards its callback, recursion that switches callbacks, helper chains, +// and callbacks captured by inner closures and array methods. +function quickSort( + values: number[], + lo: number, + hi: number, + cmp: (a: number, b: number) => number, +): void { + if (lo >= hi) return; + const pivot = values[(lo + hi) >> 1]!; + let i = lo; + let j = hi; + while (i <= j) { + while (cmp(values[i]!, pivot) < 0) i++; + while (cmp(values[j]!, pivot) > 0) j--; + if (i <= j) { + const t = values[i]!; + values[i] = values[j]!; + values[j] = t; + i++; + j--; + } + } + quickSort(values, lo, j, cmp); + quickSort(values, i, hi, cmp); +} + +interface Tree { + value: number; + children: Tree[]; +} + +// Mutual recursion forwarding one visitor. +function visitTree(tree: Tree, visit: (value: number, depth: number) => void, depth: number): void { + visit(tree.value, depth); + visitChildren(tree.children, visit, depth + 1); +} +function visitChildren( + children: Tree[], + visit: (value: number, depth: number) => void, + depth: number, +): void { + for (const child of children) visitTree(child, visit, depth); +} + +// Recursion that passes a different callback below the first level. +function render(tree: Tree, format: (value: number) => string, out: string[]): void { + out.push(format(tree.value)); + for (const child of tree.children) render(child, (v) => "<" + format(v) + ">", out); +} + +// A chain of helpers forwarding the callback. +function total(items: T[], key: (item: T) => number): number { + return sum(items.map(key)); +} +function sum(values: number[]): number { + let s = 0; + for (const v of values) s += v; + return s; +} +function weighted(items: T[], key: (item: T) => number, weight: number): number { + return total(items, key) * weight; +} + +// The callback captured by closures the helper creates. +function sumBy(items: T[], key: (item: T) => number): number { + return items.reduce((acc, item) => acc + key(item), 0); +} +function anyBy(items: T[], test: (item: T) => boolean): boolean { + let found = false; + items.forEach((item) => { + if (test(item)) found = true; + }); + return found; +} +function compose(f: (x: number) => number, g: (x: number) => number): (x: number) => number { + return (x) => g(f(x)); +} +function applyTwice(values: number[], f: (x: number) => number): number[] { + const twice = (x: number): number => f(f(x)); + return values.map(twice); +} + +function fib(n: number, memo: (k: number) => number): number { + return n < 2 ? n : memo(n - 1) + memo(n - 2); +} + +function main(): void { + const data = [5, 3, 9, 1, 7, 2, 8, 6, 4, 0]; + const asc = data.slice(); + quickSort(asc, 0, asc.length - 1, (a, b) => a - b); + const desc = data.slice(); + quickSort(desc, 0, desc.length - 1, (a, b) => b - a); + const parity = data.slice(); + quickSort(parity, 0, parity.length - 1, (a, b) => (a & 1) - (b & 1) || a - b); + console.log(asc.join(","), desc.join(","), parity.join(",")); + + const tree: Tree = { + value: 1, + children: [ + { value: 2, children: [{ value: 4, children: [] }] }, + { value: 3, children: [] }, + ], + }; + const lines: string[] = []; + visitTree(tree, (value, depth) => lines.push(" ".repeat(depth) + value), 0); + let deepest = 0; + visitTree( + tree, + (_value, depth) => { + if (depth > deepest) deepest = depth; + }, + 0, + ); + console.log(lines.join("|"), deepest); + + const out: string[] = []; + render(tree, (v) => String(v * 10), out); + render(tree, (v) => "#" + v, out); + console.log(out.join(" ")); + + const words = ["alpha", "be", "gamma"]; + console.log(total(words, (w) => w.length), total(data, (n) => n * 2)); + console.log(weighted(words, (w) => w.charCodeAt(0), 2), weighted(data, (n) => n, 3)); + + console.log(sumBy(words, (w) => w.length), sumBy(data, (n) => n * n)); + console.log(anyBy(data, (n) => n > 8), anyBy(words, (w) => w === "delta")); + const inc = compose( + (x) => x + 1, + (x) => x * 2, + ); + const dec = compose( + (x) => x - 1, + (x) => x / 2, + ); + console.log(inc(3), dec(3)); + console.log(applyTwice([1, 2], (x) => x + 10).join(","), applyTwice([1, 2], (x) => x * x).join(",")); + + const cache = new Map(); + const memo = (k: number): number => { + const hit = cache.get(k); + if (hit !== undefined) return hit; + const v = fib(k, memo); + cache.set(k, v); + return v; + }; + console.log(fib(30, memo), fib(10, (k) => (k < 2 ? k : fib(k - 1, () => 1)))); +} + +main(); diff --git a/tests/corpus/5942-callback-specialization-throws.ts b/tests/corpus/5942-callback-specialization-throws.ts new file mode 100644 index 000000000..8ad180a07 --- /dev/null +++ b/tests/corpus/5942-callback-specialization-throws.ts @@ -0,0 +1,79 @@ +// Exceptions, `this` and argument evaluation order through helpers called +// with several callbacks: a throwing callback unwinds through the helper's +// finally blocks, and callbacks see `this` and their arguments as in Node. +function each(values: number[], f: (x: number) => void): number { + let done = 0; + try { + for (const v of values) { + f(v); + done++; + } + } finally { + console.log("each finished after", done); + } + return done; +} + +function transform(values: number[], f: (x: number) => number): number[] { + const out: number[] = []; + for (const v of values) out.push(f(v)); + return out; +} + +function receiver(f: (this: unknown, x: number) => string): string { + return f(1); +} + +function trace(label: string): number { + console.log("arg", label); + return label.length; +} + +function main(): void { + const log: string[] = []; + console.log(each([1, 2, 3], (x) => log.push("a" + x))); + try { + each([1, 2, 3], (x) => { + if (x === 2) throw new Error("stop at " + x); + log.push("b" + x); + }); + } catch (e) { + console.log("caught", (e as Error).message); + } + try { + each([4, 5], (x) => { + throw new RangeError("range " + x); + }); + } catch (e) { + console.log("caught", e instanceof RangeError, (e as Error).message); + } + console.log(log.join(",")); + + try { + console.log(transform([1, 2, 3], (x) => x * 2).join(",")); + console.log( + transform([1, 0, 3], (x) => { + if (x === 0) throw new TypeError("zero"); + return 1 / x; + }).join(","), + ); + } catch (e) { + console.log("caught", e instanceof TypeError, (e as Error).message); + } + console.log(transform([4, 9], Math.sqrt).join(",")); + + console.log( + receiver(function (this: unknown, x: number) { + return typeof this + x; + }), + ); + console.log(receiver((x) => "arrow" + x)); + + // Arguments are evaluated left to right before the helper runs. + console.log( + transform([trace("first"), trace("second!")], (x) => x + trace("cb")).join(","), + ); + console.log(transform([trace("third")], (x) => -x).join(",")); +} + +main(); diff --git a/tests/corpus/5942-local-class-observed-methods.ts b/tests/corpus/5942-local-class-observed-methods.ts new file mode 100644 index 000000000..7445b322a --- /dev/null +++ b/tests/corpus/5942-local-class-observed-methods.ts @@ -0,0 +1,63 @@ +// After a write with a computed string key, calls of every method name +// check the receiver for the property. Instances of function-local +// classes keep their declared methods, see own-property replacements, and +// see methods the program replaces on the class's prototype. +function write(target: object, key: string, value: unknown): void { + (target as any)[key] = value; +} + +function makeCounter(start: number) { + class Counter { + n: number; + constructor() { + this.n = start; + } + bump(): number { + return ++this.n; + } + label(): string { + return `counter ${this.n}`; + } + } + return new Counter(); +} + +function run(start: number): string { + class Step { + value = start; + next(): number { + this.value *= 2; + return this.value; + } + twice(): number { + this.next(); + return this.next(); + } + } + const step = new Step(); + return `${typeof step.next} ${step.twice()} ${step.next()}`; +} + +function makePatched(start: number) { + class Patched { + n = start; + bump(): number { + return ++this.n; + } + } + Patched.prototype.bump = function (this: Patched) { + this.n += 10; + return this.n; + }; + return new Patched(); +} + +const counter = makeCounter(5); +write({}, "other", 1); +console.log(typeof counter.bump, counter.bump(), counter.label()); +const bump = counter.bump; +console.log(bump.call(counter)); +write(counter, "label", () => "replaced"); +console.log(counter.label(), makeCounter(1).label()); +console.log(run(1), run(10)); +console.log(makePatched(1).bump()); diff --git a/tests/corpus/5950-tuple-predicate-methods.ts b/tests/corpus/5950-tuple-predicate-methods.ts new file mode 100644 index 000000000..da2533a05 --- /dev/null +++ b/tests/corpus/5950-tuple-predicate-methods.ts @@ -0,0 +1,112 @@ +// filter, find, findIndex, some and every on fixed tuples, readonly and +// mutable: each position is read when it is visited, find/findIndex/some/ +// every stop at the first deciding position, and results match arrays. +export {}; + +const tools = ["read", "write", "search"] as const; +const allowed: string[] = ["read", "search"]; +console.log(JSON.stringify(tools.filter((name) => allowed.includes(name)))); +console.log(tools.find((name) => name.startsWith("w")), tools.find((name) => name === allowed[5])); +console.log(tools.findIndex((name) => name === "search"), tools.findIndex((name) => name.length > 9)); +console.log(tools.some((name) => name.length > 5), tools.some((name) => name.length > 9)); +console.log(tools.every((name) => name.length > 3), tools.every((name) => name.length > 4)); +console.log(tools.filter((_, index) => index !== 1).join(",")); + +// Mixed element types, with and without inferred narrowing. +const mixed: readonly [number, string, boolean, number] = [7, "eight", true, 0]; +console.log(JSON.stringify(mixed.filter((value): boolean => typeof value !== "boolean"))); +const numbers = mixed.filter((value) => typeof value === "number"); +console.log(numbers.reduce((sum, value) => sum + value, 0), numbers.length); +const text = mixed.find((value) => typeof value === "string"); +console.log(text === undefined ? "none" : text.toUpperCase()); +console.log(mixed.findIndex((value) => value === true), mixed.some((value) => value === "eight")); +console.log(mixed.every((value) => typeof value !== "object")); + +// Truthiness of non-boolean predicate results, and Boolean. +const counts: [number, number, number] = [0, 3, 0]; +console.log(counts.filter((value) => value).join(","), counts.findIndex((value) => value)); +console.log(counts.some((value) => value), counts.every((value) => value)); +const labels: [string, string, string] = ["", "b", "c"]; +console.log(labels.filter(Boolean).join(","), labels.find((value) => value)); +const maybe: [string | undefined, string | undefined] = [undefined, "x"]; +console.log(JSON.stringify(maybe.filter(Boolean)), maybe.findIndex((value) => value)); + +// A single-element tuple. +const single: [number] = [4]; +console.log(JSON.stringify(single.filter((value) => value > 4)), single.find((value) => value === 4)); + +// A callback's write to a later position is visible when it is visited; +// filter and find keep the value read before the callback ran. +const mutable: [number, number, number] = [1, 2, 3]; +console.log(mutable.filter((value, index) => { + if (index === 0) mutable[1] = 20; + if (index === 1) mutable[1] = 200; + return value > 1; +}).join(","), mutable.join(",")); +const slots: [number, number] = [5, 6]; +console.log(slots.find((value) => { + slots[0] = 50; + return value === 5; +}), slots[0]); +const later: [number, number, number] = [1, 2, 3]; +console.log(later.some((value, index) => { + if (index === 0) later[2] = 30; + return value === 30; +})); + +// Reference identity of kept and found elements. +const a = { id: "a" }; +const b = { id: "b" }; +const objects: [typeof a, typeof b] = [a, b]; +const kept = objects.filter((value) => value.id === "b"); +const found = objects.find((value) => value.id === "a"); +console.log(kept[0] === b, found === a); +b.id = "B"; +console.log(kept[0]!.id); + +// Short-circuiting and visit order. +let visited = ""; +const letters = ["p", "q", "r"] as const; +console.log(letters.find((value) => { visited += value; return value === "q"; }), visited); +visited = ""; +console.log(letters.findIndex((value) => { visited += value; return value === "p"; }), visited); +visited = ""; +console.log(letters.some((value) => { visited += value; return value === "q"; }), visited); +visited = ""; +console.log(letters.every((value) => { visited += value; return value === "p"; }), visited); +visited = ""; +console.log(letters.filter((value) => { visited += value; return true; }).length, visited); + +// The receiver evaluates before the callback expression. +let calls = ""; +function receiver(): [number, number] { calls += "R"; return [8, 9]; } +function predicate(): (value: number) => boolean { + calls += "C"; + return (value) => { calls += String(value); return value === 9; }; +} +console.log(receiver().filter(predicate()).join(","), calls); +calls = ""; +console.log(receiver().find(predicate()), calls); + +// A throwing callback stops the visit. +visited = ""; +try { + letters.filter((value, index) => { + visited += value; + if (index === 1) throw new Error("stop"); + return true; + }); +} catch (error) { + if (error instanceof Error) console.log(error.message, visited); +} + +// Generic consumers. +function firstMatch(value: T, other: T): T | undefined { + const pair: [T, T] = [value, other]; + return pair.find((item) => item === other); +} +function anyEqual(value: T, other: T): boolean { + const pair: readonly [T, T] = [value, other]; + return pair.some((item, index) => index > 0 && item === value); +} +console.log(firstMatch(1, 2), firstMatch("x", "y"), anyEqual(3, 3), anyEqual("u", "v")); diff --git a/tests/corpus/5951-result-never-widening.ts b/tests/corpus/5951-result-never-widening.ts new file mode 100644 index 000000000..6929f4605 --- /dev/null +++ b/tests/corpus/5951-result-never-widening.ts @@ -0,0 +1,83 @@ +// A union arm with a required `never` property has no values, so +// Result and Result widen into Result at returns, +// initializers, arguments, array elements and async returns. +export {}; + +type Result = { ok: true; value: T } | { ok: false; error: E }; + +function ok(value: T): Result { + return { ok: true, value }; +} + +function err(error: E): Result { + return { ok: false, error }; +} + +function parse(value: string): Result { + return value.length > 0 ? ok(value) : { ok: false, error: "empty" }; +} + +function check(value: number): Result { + if (value < 0) return err("negative"); + return ok(value); +} + +function show(result: Result): string { + return result.ok ? `ok ${result.value}` : `error ${result.error}`; +} + +function showNumber(result: Result): string { + return result.ok ? `ok ${result.value}` : `error ${result.error}`; +} + +console.log(JSON.stringify(parse("hello")), JSON.stringify(parse(""))); +console.log(showNumber(check(4)), showNumber(check(-1))); + +const fromOk: Result = ok("bound"); +const fromErr: Result = err("failed"); +console.log(show(fromOk), show(fromErr)); +console.log(show(ok("argument")), showNumber(err("argument"))); + +const all: Result[] = [ok(1), err("two"), ok(3)]; +console.log(all.map((result) => showNumber(result)).join(", ")); + +let current: Result = ok("first"); +console.log(show(current)); +current = err("reassigned"); +console.log(show(current)); +current = ok("third"); +console.log(show(current)); + +function wrap(value: T): Result { + return ok(value); +} +console.log(showNumber(wrap(5)), show(wrap("five"))); + +// The never arm's fields stay readable in branches that cannot run. +const only = ok(10); +if (only.ok) console.log("value", only.value); +else console.log("unreachable", only.error); + +// Optional never properties, never[] and never-returning callbacks have +// values, so their arms convert as before. +type Loose = + | { ok: true; value: string; error?: never } + | { ok: false; value?: never; error: string }; +function looseOk(value: string): Loose { + return { ok: true, value }; +} +const loose: Loose | null = looseOk("loose"); +console.log(JSON.stringify(loose)); + +type Batch = { ok: true; items: string[] } | { ok: false; errors: never[] }; +function emptyFailure(): Batch { + return { ok: false, errors: [] }; +} +const batch: Batch | undefined = emptyFailure(); +console.log(JSON.stringify(batch)); + +async function load(name: string): Promise> { + return name === "" ? err("missing") : ok(`loaded ${name}`); +} +void load("config").then((result) => console.log(show(result))); +void load("").then((result) => console.log(show(result))); diff --git a/tests/corpus/5955-any-promise-methods.js b/tests/corpus/5955-any-promise-methods.js new file mode 100644 index 000000000..911bc150b --- /dev/null +++ b/tests/corpus/5955-any-promise-methods.js @@ -0,0 +1,43 @@ +// A promise held in an untyped binding exposes Promise.prototype.then, +// catch and finally as function values: typeof, truthiness, equality and +// identity match Node, and calls through the value use their receiver. +function describe(p) { + console.log("typeof p.then =", typeof p.then); + console.log("is function:", typeof p.then === "function", !!p.catch, p.finally !== undefined); + console.log("missing:", typeof p.nope, p.nope === undefined); + console.log("in:", "then" in p, "finally" in p, "nope" in p); +} +function make() { + return Promise.resolve(1); +} +describe(make()); +describe({ then(callback) { callback(2); } }); +describe({}); + +function isThenable(value) { + return value !== null && typeof value === "object" && typeof value.then === "function"; +} +console.log(isThenable(make()), isThenable({}), isThenable(null)); + +function methods(promise, other, failed, events) { + const then = promise.then; + console.log(typeof then, then === promise.then, promise.then === other.then); + console.log(typeof promise.catch, promise.catch === other.catch, promise.catch !== promise.then); + console.log(promise.then, promise["finally"], Object.keys(promise).length); + then.call(promise, (value) => events.push(`then ${value}`)); + promise.finally.call(promise, () => events.push("finally")); + failed.catch.call(failed, (error) => events.push(`catch ${error.message}`)); + try { + then(() => {}); + } catch (error) { + console.log(error instanceof TypeError, error.message); + } + try { + then.call({}, () => {}); + } catch (error) { + console.log(error instanceof TypeError, error.message); + } +} +const events = []; +methods(make(), make(), Promise.reject(new Error("boom")), events); +setTimeout(() => console.log(events.join(", ")), 5); diff --git a/tests/corpus/5956-erased-generic-members.ts b/tests/corpus/5956-erased-generic-members.ts new file mode 100644 index 000000000..efa80fa83 --- /dev/null +++ b/tests/corpus/5956-erased-generic-members.ts @@ -0,0 +1,125 @@ +// Generic members reached through a receiver whose runtime object is not +// statically known (a round trip through `any`, a function result, an +// array element, a class behind an interface) call the member's erased +// instance, as JavaScript does; results convert to the call's type. +export {}; + +interface GenericIdentity { + id: (value: T) => T; +} + +const original = { id: (value: T): T => value }; +const erased: any = original; +const restored = erased as GenericIdentity; +console.log(restored.id(42)); +console.log(restored.id("hello")); + +interface Box { + wrap: (value: T) => T[]; + pair: (a: A, b: B) => [A, B]; + join: (a: T, b: T) => T; + pick: (obj: T, key: K) => T[K]; + count: (items: T[], pred: (item: T) => boolean) => number; + name: string; +} +function makeBox(name: string): Box { + return { + wrap: (value: T): T[] => [value, value], + pair: (a: A, b: B): [A, B] => [a, b], + join: (a: T, b: T): T => (a as any) + (b as any), + pick: (obj: T, key: K): T[K] => obj[key], + count: (items: T[], pred: (item: T) => boolean): number => items.filter(pred).length, + name, + }; +} +const box = makeBox("box"); +console.log(box.wrap(1).length, box.wrap("s").join("+"), box.pair(1, "x")[1], box.name); +console.log(box.join(1, 2), box.join("a", "b"), box.pick({ a: 1, b: "x" }, "b")); +console.log(box.count([1, 2, 3], (n) => n > 1), box.count(["a"], (s) => s === "b")); +const boxes: Box[] = [makeBox("first"), makeBox("second")]; +console.log(boxes.map((b) => `${b.name}:${b.wrap(true)[0]}`).join(",")); +console.log(Object.keys(box).join(","), typeof box.wrap); +const viaAny: any = box; +const back = viaAny as Box; +console.log(back.wrap(3)[1], back.pair("p", 2)[0]); + +// Arrays keep their identity across the erased boundary. +interface Ops { + append: (items: T[], value: T) => T[]; +} +const ops: Ops = { append: (items: T[], value: T): T[] => (items.push(value), items) }; +const nums = [1, 2]; +const same = ops.append(nums, 3); +same.push(9); +console.log(same === nums, nums.join(",")); + +// A class instance behind an interface dispatches to its generic method. +interface Repo { + get: (fallback: T) => T; +} +class MemRepo implements Repo { + calls = 0; + get(fallback: T): T { + this.calls++; + return fallback; + } +} +function lookup(repo: Repo): string { + return `${repo.get("dflt")}:${repo.get(7)}`; +} +const repo = new MemRepo(); +console.log(lookup(repo), repo.calls); +const repos: Repo[] = [new MemRepo()]; +console.log(repos[0]!.get(true)); + +// A subclass that redeclares the generic method answers for its instances. +class LoudRepo extends MemRepo { + get(fallback: T): T { + console.log("loud"); + return fallback; + } +} +class QuietChild extends LoudRepo {} +console.log(lookup(new LoudRepo()), lookup(new QuietChild()), lookup(new MemRepo())); + +// Async and generator members erase their element types. +interface Later { + later: (value: T) => Promise; + each: (value: T) => Generator; +} +const later: Later = { + later: async (value: T): Promise => value, + each: function* (value: T): Generator { + yield value; + yield value; + }, +}; +const lateBox: any = later; +const lateBack = lateBox as Later; +void lateBack.later(5).then((value) => console.log("later", value + 1)); +console.log([...lateBack.each("e")].join("")); +console.log(JSON.stringify({ ...makeBox("json") }), JSON.stringify(original)); + +// Object-literal generic methods inside functions, rebound `let` objects, +// interface-typed literals and generic IIFEs reach their erased values. +function localObject(): number { + const local = { m(x: T): T { return x; } }; + return local.m(3); +} +console.log(localObject()); +let rebound = { m(x: T): T { return x; } }; +console.log(rebound.m(4)); +rebound = { m(x: T): T { console.log("second"); return x; } }; +console.log(rebound.m(4)); +interface HasGenericMethod { + m(x: T): T; +} +const throughInterface: HasGenericMethod = { m(x: T): T { return x; } }; +console.log(throughInterface.m(5), throughInterface.m("s")); +console.log(((x: T): T => x)(5)); +function counterObject(start: number) { + let n = start; + return { bump(x: T): [T, number] { n += 1; return [x, n]; } }; +} +const bumper = counterObject(10); +console.log(bumper.bump("a"), bumper.bump(true)); diff --git a/tests/corpus/5958-asserted-union-receiver.ts b/tests/corpus/5958-asserted-union-receiver.ts new file mode 100644 index 000000000..23a591827 --- /dev/null +++ b/tests/corpus/5958-asserted-union-receiver.ts @@ -0,0 +1,47 @@ +// A class instance asserted to a wider union (`(concrete as A | B).value`) +// keeps its class: field and getter reads go to the known class. +class A { + value: number; + label = "a"; + constructor(value: number) { + this.value = value; + } + describe(): string { + return `A(${this.value})`; + } + get doubled(): number { + return this.value * 2; + } +} +class B { + value: number; + flag = true; + constructor(value: number) { + this.value = value; + } + describe(): string { + return `B(${this.value})`; + } + get doubled(): number { + return this.value * 3; + } +} +class C extends A { + override get doubled(): number { + return this.value * 10; + } +} +type Rec = { value: boolean }; + +const concrete = new A(41); +console.log((concrete as A | B).value + 1, (concrete as A | B).doubled); +console.log((new C(5) as A | B).value, (new C(5) as A | B).doubled, (concrete as A | B).describe()); +console.log((concrete as A | Rec).value, ((concrete as A | B) as A | B).value); +const maybe = (concrete as A | B | undefined)?.value; +console.log(maybe); +function show(x: A): string { + return String((x as A | B).value) + (x satisfies A as A | B).doubled; +} +console.log(show(concrete), show(new C(2))); +const widened = concrete as A | B; +console.log(widened.value, widened instanceof A); diff --git a/tests/corpus/5959-date-to-json.ts b/tests/corpus/5959-date-to-json.ts new file mode 100644 index 000000000..4776a76f9 --- /dev/null +++ b/tests/corpus/5959-date-to-json.ts @@ -0,0 +1,32 @@ +// Date.prototype.toJSON answers the toISOString() string for a valid date +// and null for an invalid one (the lib types say string), through +// bindings, literals, JSON.stringify and console.log. +const good = new Date(Date.UTC(2024, 1, 29, 12, 30, 15, 250)); +const bad = new Date("not a date"); +const epoch = new Date(0); + +console.log(good.toJSON(), bad.toJSON(), epoch.toJSON("ignored key")); +console.log(JSON.stringify(good.toJSON()), JSON.stringify(bad.toJSON())); + +const kept = bad.toJSON(); +const shown = good.toJSON(); +console.log(kept, shown, kept === null, typeof kept, kept ?? "fallback"); +console.log(`${bad.toJSON()} / ${good.toJSON()}`); + +const annotated: string | null = bad.toJSON(); +console.log(annotated, JSON.stringify({ annotated })); + +let changing: string | null = "start"; +changing = bad.toJSON(); +console.log(changing === null, JSON.stringify([changing, epoch.toJSON()])); + +const record = { at: good.toJSON(), never: bad.toJSON(), kept }; +console.log(JSON.stringify(record), record.never === null, record.at); +console.log(JSON.stringify({ direct: bad.toJSON(), also: epoch.toJSON() })); +console.log(JSON.stringify([bad.toJSON(), good.toJSON(), shown])); + +function stamp(date: Date): string { + const text = date.toJSON(); + return text === null ? "invalid" : text.slice(0, 10); +} +console.log(stamp(good), stamp(bad), stamp(epoch)); diff --git a/tests/corpus/5960-generic-mapped-types.ts b/tests/corpus/5960-generic-mapped-types.ts new file mode 100644 index 000000000..229a97d88 --- /dev/null +++ b/tests/corpus/5960-generic-mapped-types.ts @@ -0,0 +1,64 @@ +// Mapped types over a generic function's own type parameters lower in its +// monomorphized instances: homomorphic copies, modifiers, conditional +// templates with infer, and indexed templates, including over an +// index-signature constraint whose keys only the instance knows. +type Schema = { parse(input: unknown): T }; +type Infer = S extends Schema ? T : never; +interface Typed { + readonly _output: Output; +} +type Output> = T["_output"]; + +const str: Schema = { parse: (input) => String(input) }; +const num: Schema = { parse: (input) => Number(input) }; + +function same(value: S): { [K in keyof S]: S[K] } { + return value; +} +function frozen(value: S): { readonly [K in keyof S]: S[K] } { + return { ...value }; +} +function loose(value: S): { [K in keyof S]?: S[K] } { + return value; +} +function pair; right: Schema }>( + shape: S, + input: { left: unknown; right: unknown }, +): { [K in keyof S]: Infer } { + return { + left: shape.left.parse(input.left), + right: shape.right.parse(input.right), + } as { [K in keyof S]: Infer }; +} +function objectWith>>( + shape: S, + build: (shape: S) => { [K in keyof S]: Infer }, +): { [K in keyof S]: Infer } { + const parsed: { [K in keyof S]: Infer } = build(shape); + return parsed; +} +function outputs>>( + values: S, + read: (values: S) => { [K in keyof S]: Output }, +): { [K in keyof S]: Output } { + return read(values); +} + +const s = same({ a: 1, b: "two" }); +console.log(s.a + 1, s.b.length); +const f = frozen({ x: true, y: [1, 2] }); +console.log(f.x, f.y.length); +const l = loose({ n: 3 }); +console.log(l.n ?? 0); +const p = pair({ left: str, right: num }, { left: 12, right: "34" }); +console.log(p.left.length, p.right + 1); +const person = objectWith({ name: str, age: num }, (shape) => ({ + name: shape.name.parse("Ada"), + age: shape.age.parse("36"), +})); +console.log(person.name, person.age + 1); +const out = outputs({ id: { _output: 7 }, tag: { _output: "t" } }, (values) => ({ + id: values.id._output, + tag: values.tag._output, +})); +console.log(out.id * 2, out.tag.toUpperCase()); diff --git a/tests/corpus/5970-net-connect-host-name.ts b/tests/corpus/5970-net-connect-host-name.ts new file mode 100644 index 000000000..4db67fbbb --- /dev/null +++ b/tests/corpus/5970-net-connect-host-name.ts @@ -0,0 +1,29 @@ +// net.connect resolves a host name through the system resolver before it +// dials. "LOCALHOST" is not the "localhost" literal, so it goes through +// getaddrinfo and the hosts file, as a container service name does. +import * as net from "node:net"; + +const server = net.createServer((socket) => { + socket.on("data", (chunk: Buffer) => socket.end("echo: " + chunk.toString())); +}); + +server.listen(0, () => { + const { port } = server.address() as net.AddressInfo; + const client = net.connect(port, "LOCALHOST", () => { + console.log("connected", net.isIP(client.remoteAddress ?? "") !== 0); + client.write("hello"); + }); + client.on("data", (chunk: Buffer) => console.log(chunk.toString())); + client.on("close", () => { + console.log("client closed"); + const options = net.createConnection({ port, host: "LOCALHOST" }); + options.on("connect", () => { + console.log("options form connected"); + options.end(); + }); + options.on("close", () => { + console.log("options form closed"); + server.close(() => console.log("server closed")); + }); + }); +}); diff --git a/tests/corpus/5971-buffer-chunks-to-string.ts b/tests/corpus/5971-buffer-chunks-to-string.ts new file mode 100644 index 000000000..a635c357c --- /dev/null +++ b/tests/corpus/5971-buffer-chunks-to-string.ts @@ -0,0 +1,70 @@ +// Chunks delivered by net, http, dgram and child_process are Buffers, so +// string concatenation and template literals decode them as UTF-8. +import { spawn } from "node:child_process"; +import * as dgram from "node:dgram"; +import * as http from "node:http"; +import * as net from "node:net"; + +function netStep(next: () => void): void { + const server = net.createServer((socket) => { + socket.on("data", (chunk: Buffer) => socket.end("net echo: " + chunk)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + const client = net.connect(port, "127.0.0.1", () => client.write("héllo")); + let got = ""; + client.on("data", (chunk: Buffer) => { + got += chunk; + }); + client.on("close", () => { + console.log(got); + server.close(next); + }); + }); +} + +function httpStep(next: () => void): void { + const server = http.createServer((req, res) => { + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk; + }); + req.on("end", () => res.end(`http echo: ${body}`)); + }); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + const req = http.request({ port, host: "127.0.0.1", method: "POST" }, (res) => { + let got = ""; + res.on("data", (chunk: Buffer) => { + got += chunk; + }); + res.on("end", () => { + console.log(got); + server.close(next); + }); + }); + req.end("wörld"); + }); +} + +function dgramStep(next: () => void): void { + const socket = dgram.createSocket("udp4"); + socket.on("message", (message: Buffer) => { + console.log(`dgram: ${message}`); + socket.close(next); + }); + socket.bind(0, "127.0.0.1", () => { + socket.send("datagram", socket.address().port, "127.0.0.1"); + }); +} + +function childStep(): void { + const child = spawn("sh", ["-c", "printf 'from child'"]); + let out = ""; + child.stdout!.on("data", (chunk: Buffer) => { + out += chunk; + }); + child.on("close", () => console.log("child: " + out)); +} + +netStep(() => httpStep(() => dgramStep(childStep))); diff --git a/tests/corpus/5972-net-set-encoding-listeners.ts b/tests/corpus/5972-net-set-encoding-listeners.ts new file mode 100644 index 000000000..a7b49adaa --- /dev/null +++ b/tests/corpus/5972-net-set-encoding-listeners.ts @@ -0,0 +1,42 @@ +// After socket.setEncoding("utf8"), every 'data' listener receives the +// string Node passes, whether its chunk parameter is unannotated, +// annotated Buffer, string or string | Buffer. A Buffer-only method on +// the string throws Node's TypeError. A socket without an encoding in +// the same program still receives Buffers. +import * as net from "node:net"; + +const server = net.createServer((socket) => { + socket.on("data", (chunk) => { + console.log("server chunk:", typeof chunk, chunk.length, "bytes:", chunk[0]); + socket.end("hello wörld"); + }); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as net.AddressInfo; + const client = net.connect(port, "127.0.0.1", () => client.write("hi")); + client.setEncoding("utf8"); + let text = ""; + client.on("data", (chunk) => { + text += chunk; + console.log("unannotated:", typeof chunk, chunk.length, chunk); + console.log("unannotated upper:", chunk.toString().toUpperCase()); + }); + client.on("data", (chunk: Buffer) => { + console.log("annotated Buffer:", typeof chunk, chunk); + console.log("annotated concat:", "<" + chunk + ">"); + try { + console.log(chunk.readUInt8(0)); + } catch (error) { + console.log("readUInt8:", (error as Error).name, (error as Error).message); + } + }); + client.once("data", (chunk: string) => console.log("string:", chunk.split(" "))); + client.on("data", (chunk: string | Buffer) => + console.log("union:", typeof chunk === "string" ? "string " + chunk : "buffer"), + ); + client.on("close", () => { + console.log("text:", text); + server.close(); + }); +}); 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/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/corpus/5992-worker-thread-name-lifetime.mjs b/tests/corpus/5992-worker-thread-name-lifetime.mjs new file mode 100644 index 000000000..455ccec4b --- /dev/null +++ b/tests/corpus/5992-worker-thread-name-lifetime.mjs @@ -0,0 +1,33 @@ +// worker.threadName read while the worker runs stays a usable string after +// the worker exits and the Worker object is released. +import { Worker, isMainThread, parentPort, threadName } from "node:worker_threads"; + +async function run(name) { + const worker = new Worker(new URL(import.meta.url), { name }); + const kept = worker.threadName; + await new Promise((resolve) => worker.once("exit", resolve)); + return kept; +} + +if (isMainThread) { + const fromFunction = await run("function worker"); + const names = []; + for (const name of ["first worker", " second "]) { + let worker = new Worker(new URL(import.meta.url), { name }); + names.push(worker.threadName); + const exited = new Promise((resolve) => worker.once("exit", resolve)); + const inner = await new Promise((resolve) => worker.once("message", resolve)); + await exited; + console.log("after exit", JSON.stringify(worker.threadName), JSON.stringify(inner)); + worker = null; + } + // allocations that would reuse the released names' storage + const filler = []; + for (let i = 0; i < 1000; i++) filler.push("x".repeat(i % 64)); + console.log("kept", filler.length, JSON.stringify(names), names.map((n) => n.length).join(",")); + console.log("joined", names.join("|").toUpperCase()); + console.log("from function", JSON.stringify(fromFunction), fromFunction.length, fromFunction.toUpperCase()); +} else { + const own = threadName; + parentPort.postMessage(`${own}:${own.length}`); +} 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/corpus/array-deque-unshift.ts b/tests/corpus/array-deque-unshift.ts new file mode 100644 index 000000000..2ce7ef85f --- /dev/null +++ b/tests/corpus/array-deque-unshift.ts @@ -0,0 +1,73 @@ +// Arrays used as double-ended queues: runs of unshift (one value, several +// values, spread) on arrays long enough to reserve room in front of their +// elements, mixed with push, pop, shift, splice, indexing and iteration. +function checksum(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s * 31 + xs[i]!) % 1000000007; + return s; +} + +// A long run of single unshifts builds the array in reverse. +const rev: number[] = []; +for (let i = 0; i < 5000; i++) rev.unshift(i); +console.log("reverse", rev.length, rev[0], rev[4999], rev[2500], checksum(rev)); +let ok = true; +for (let i = 0; i < rev.length; i++) if (rev[i] !== 4999 - i) ok = false; +console.log("reverse-order", ok); + +// Several values at a time keep their argument order. +const many: number[] = []; +for (let i = 0; i < 300; i++) many.unshift(i, i + 0.5, -i); +console.log("many", many.length, many.slice(0, 6).join(","), many.slice(-3).join(","), checksum(many)); + +// Spread unshift, including the array itself. +const sp: number[] = []; +for (let i = 0; i < 20; i++) sp.push(i); +const extra = [100, 101, 102]; +for (let i = 0; i < 5; i++) sp.unshift(...extra); +console.log("spread", sp.length, sp.slice(0, 7).join(","), sp[sp.length - 1]); +sp.unshift(...sp); +console.log("self-spread", sp.length, sp[0], sp[35], sp[69], checksum(sp)); + +// A deque: values enter at both ends and leave at both ends. +const dq: number[] = []; +const out: number[] = []; +for (let i = 0; i < 4000; i++) { + if (i % 3 === 0) dq.unshift(i); + else dq.push(i); + if (i % 5 === 0 && dq.length > 0) out.push(dq.shift()!); + if (i % 7 === 0 && dq.length > 0) out.push(dq.pop()!); + if (i % 11 === 0) dq.unshift(-i, -i - 1); +} +let iterated = 0; +for (const x of dq) iterated += x; +console.log("deque", dq.length, dq[0], dq[dq.length - 1], checksum(dq), checksum(out), iterated); +console.log("deque-search", dq.indexOf(-11), dq.lastIndexOf(3), dq.includes(3999), dq.at(-2)); +dq.splice(0, 100); +dq.splice(10, 5, 1, 2, 3); +dq.unshift(...dq.splice(dq.length - 4, 4)); +console.log("deque-splice", dq.length, dq.slice(0, 8).join(","), checksum(dq)); + +// Strings in a deque: the array owns every remaining element. +const words: string[] = []; +for (let i = 0; i < 200; i++) { + words.unshift("w" + i); + if (i % 4 === 0) words.push("t" + i); + if (i % 9 === 0) words.shift(); +} +console.log("words", words.length, words[0], words[words.length - 1], words.slice(50, 53).join(",")); +console.log("words-join", words.join("").length, words.filter((w) => w.startsWith("t")).length); + +// Holes stay holes when values are unshifted in front of them. +const holed: (number | undefined)[] = []; +for (let i = 0; i < 30; i++) holed.push(i); +holed.length = 34; +holed[32] = 32; +holed.copyWithin(5, 33, 34); +for (let i = 0; i < 20; i++) holed.unshift(-i); +const marks: string[] = []; +for (let i = 0; i < holed.length; i++) marks.push(i in holed ? "v" : "_"); +console.log("holed", holed.length, marks.join(""), holed[24], 25 in holed, holed[52]); +holed.length = 10; +holed.unshift(1, 2); +console.log("holed-trim", holed.join(",")); diff --git a/tests/corpus/array-queue-head-room.ts b/tests/corpus/array-queue-head-room.ts new file mode 100644 index 000000000..725ce0de4 --- /dev/null +++ b/tests/corpus/array-queue-head-room.ts @@ -0,0 +1,168 @@ +// Queues built from arrays: shift and splice(0, n) remove elements from the +// front while push, unshift, indexing, iteration and the other array +// operations keep seeing the remaining elements at indices from 0. Covers +// number, string, object and cycle-capable element arrays, holes moving +// with the elements, draining to empty and refilling, and sparse writes and +// growth after the front has moved. +function show(label: string, xs: (number | undefined)[]): void { + const parts: string[] = []; + for (let i = 0; i < xs.length; i++) parts.push(i in xs ? String(xs[i]) : "_"); + let visits = 0; + xs.forEach(() => visits++); + console.log(label, xs.length, parts.join(","), "visits", visits); +} + +function checksum(xs: number[]): number { + let s = 0; + for (let i = 0; i < xs.length; i++) s = (s * 31 + xs[i]!) % 1000000007; + return s; +} + +// Drain a large queue: every shift answers the next element. +const big: number[] = []; +for (let i = 0; i < 20000; i++) big.push(i * 3); +let drained = 0; +let ordered = true; +let expected = 0; +while (big.length > 0) { + const v = big.shift()!; + if (v !== expected) ordered = false; + expected += 3; + drained += v; +} +console.log("drain", drained, ordered, big.length, big[0], JSON.stringify(big)); + +// Drained to empty, the array is reused from the front of its storage. +for (let round = 0; round < 3; round++) { + for (let i = 0; i < 50; i++) big.push(round * 100 + i); + let s = 0; + while (big.length > 10) s += big.shift()!; + console.log("refill", round, s, big.length, big[0], big[9], big.join(" ")); + big.length = 0; +} + +// BFS-style: push at the back while shifting from the front, reading by +// index, from the end, and by iteration between operations. +const q: number[] = [1, 2, 3, 4, 5, 6, 7, 8]; +const log: string[] = []; +for (let step = 0; step < 40; step++) { + const head = q.shift()!; + if (head % 3 !== 0) q.push(head * 2 + step); + if (step % 4 === 0) q.push(-step); + let sum = 0; + for (const x of q) sum += x; + if (step % 5 === 0) log.push(`${step}:${head}:${q.length}:${q[0]}:${q[q.length - 1]}:${sum}`); +} +console.log(log.join(" ")); +console.log("bfs", q.length, checksum(q), q.indexOf(q[q.length - 1]!), q.includes(q[0]!)); + +// unshift reuses the room left by shift, then grows past it. +const d: number[] = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; +for (let i = 0; i < 6; i++) d.shift(); +console.log("shifted", d.join(","), d.length); +console.log("unshift", d.unshift(1), d.unshift(2, 3), d.join(",")); +console.log("unshift", d.unshift(4, 5, 6, 7, 8, 9), d.join(",")); +d.push(100, 101); +console.log("push", d.join(","), d[0], d[d.length - 1], d.length); +const spread = [-1, -2]; +console.log("unshift-spread", d.unshift(...spread), d.join(",")); +d.shift(); +d.shift(); +d.unshift(...d.slice(0, 3)); +console.log("self", d.join(",")); + +// splice(0, n) removes a prefix as a value and as a statement. +const s: number[] = []; +for (let i = 0; i < 30; i++) s.push(i); +const front = s.splice(0, 4); +console.log("splice-front", front.join(","), s.length, s[0], s[25]); +s.splice(0, 3); +console.log("splice-stmt", s.length, s[0], s.join(",")); +s.splice(0, 0); +s.splice(2, 2); +s.splice(0, 1, 99, 98); +console.log("splice-mixed", s.join(",")); +console.log("splice-all", s.splice(0).length, s.length, JSON.stringify(s)); +s.push(5); +console.log("after-all", s.join(","), s.length); + +// Holes move with the elements on shift and stay holes. +const holes: (number | undefined)[] = [1, 2, 3, 4, 5, 6, 7, 8]; +holes.length = 12; +holes[10] = 10; +holes.copyWithin(2, 9, 10); +holes[5] = undefined; +show("holes", holes); +console.log("hole-shift", holes.shift(), holes.shift()); +show("holes-shifted", holes); +console.log("hole-head", holes.shift(), 0 in holes); +show("holes-after", holes); +holes.unshift(7); +show("holes-unshift", holes); +holes.push(42); +holes[2] = 22; +show("holes-write", holes); + +// Sparse writes far past the end after the front moved, then more shifts +// (which then renumber the sparse entries) and growth over them. +const sp: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; +sp.shift(); +sp.shift(); +sp[3000000] = 7; +console.log("sparse", sp.length, sp[0], sp[7], sp[3000000], 3000000 in sp, 2999999 in sp); +console.log("sparse-shift", sp.shift(), sp.length, sp[2999999], 2999999 in sp, 3000000 in sp); +for (let i = 7; i < 40; i++) sp[i] = i; +console.log("sparse-fill", sp.length, sp[0], sp[6], sp[7], sp[39], sp.indexOf(7), sp.lastIndexOf(7)); +sp.length = 20; +console.log("sparse-trim", sp.join(",")); + +// Strings and objects: shifted references are owned by the caller, the +// remaining ones by the array. +const names: string[] = []; +for (let i = 0; i < 40; i++) names.push("n" + i); +const taken: string[] = []; +while (names.length > 25) taken.push(names.shift()!); +names.unshift("first", "second"); +names.splice(0, 1); +console.log("strings", taken.join(""), names.length, names[0], names[1], names[names.length - 1]); +console.log("strings-ops", names.slice(0, 3).join("|"), names.concat(["z"]).length, [...names].reverse()[0]); +console.log("strings-sorted", names.toSorted().slice(0, 4).join(","), names.map((n) => n.length).join("")); + +class Task { + id: number; + next: Task[]; + constructor(id: number, next: Task[]) { + this.id = id; + this.next = next; + } +} +const tasks: Task[] = []; +for (let i = 0; i < 12; i++) tasks.push(new Task(i, tasks)); +const done: number[] = []; +while (tasks.length > 3) { + const t = tasks.shift()!; + done.push(t.id); + if (t.id % 4 === 0) tasks.push(new Task(t.id + 100, tasks)); +} +tasks.unshift(new Task(-1, tasks)); +console.log("tasks", done.join(","), tasks.map((t) => t.id).join(","), tasks[0]!.next === tasks); + +// Small literals and boolean arrays shift by moving their elements. +const small: number[] = []; +small.push(1, 2, 3); +console.log("small", small.shift(), small.join(","), small.unshift(9), small.join(",")); +const flags: boolean[] = [true, false, true, true, false]; +console.log("bools", flags.shift(), flags.shift(), flags.join(","), flags.unshift(false), flags.join(",")); + +// Copying and searching operations on a shifted array. +const c: number[] = [5, 4, 3, 2, 1, 0, -1, -2]; +c.shift(); +c.shift(); +console.log("ops", c.slice(1, 3).join(","), c.toReversed().join(","), [...c].sort((x, y) => x - y).join(",")); +console.log("ops2", Array.from(c).length, c.filter((x) => x > 0).join(","), c.reduce((x, y) => x + y, 0)); +c.reverse(); +c.fill(7, 0, 2); +c.copyWithin(3, 0, 2); +console.log("ops3", c.join(","), c.at(-1), c.findIndex((x) => x === 3), c.every((x) => x < 10)); +c.sort((x, y) => y - x); +console.log("ops4", c.join(","), c.pop(), c.shift(), c.join(",")); diff --git a/tests/corpus/json-typed-parse-index-signatures.ts b/tests/corpus/json-typed-parse-index-signatures.ts new file mode 100644 index 000000000..1b06413dc --- /dev/null +++ b/tests/corpus/json-typed-parse-index-signatures.ts @@ -0,0 +1,84 @@ +// JSON.parse(text) as T with index signatures parses dictionaries and +// records with extra keyed members on the one-pass schema parser: each +// undeclared member joins the record's overflow map in source order. Key +// order, lookups, iteration, and stringify must match Node on both +// the direct and the checked-dynamic route. + +interface Pkg { + name: string; + version: string; + scripts?: Record; + dependencies: Record; +} +interface Flags { + version: number; + flags: Record; + weights: { [key: string]: number }; +} +interface Bucket { + ids: number[]; + label: string | null; +} +interface Labeled { + title: string; + [key: string]: string; +} + +function showPkg(label: string, text: string): void { + try { + const p = JSON.parse(text) as Pkg; + const deps = Object.keys(p.dependencies); + console.log(label, p.name, p.version, deps.join(","), deps.map((d) => p.dependencies[d]).join(",")); + console.log(" scripts", p.scripts === undefined ? "none" : Object.entries(p.scripts).map(([k, v]) => `${k}=${v}`).join(";")); + console.log(" has", p.dependencies["react"] !== undefined, p.scripts?.["build"] !== undefined, p.dependencies["left-pad"] ?? "-"); + console.log(" json", JSON.stringify(p)); + } catch (e) { + console.log("caught", label, e instanceof SyntaxError); + } +} + +showPkg( + "full", + '{"name":"app","version":"1.0.0","scripts":{"build":"tsc","test":"vitest"},"dependencies":{"react":"^19","left-pad":"1.3.0"}}', +); +showPkg("empty deps", '{"name":"lib","version":"0.0.1","dependencies":{}}'); +showPkg("reordered", '{"dependencies":{"z":"1","a":"2","m":"3"},"version":"2","name":"r"}'); +showPkg("escaped keys", '{"name":"e","version":"1","dependencies":{"\\u0061b":"x","c\\"d":"y","caf\\u00e9":"z"}}'); +showPkg("unicode keys", '{"name":"u","version":"1","dependencies":{"日本":"1","😀":"2"}}'); +showPkg("duplicate dep", '{"name":"d","version":"1","dependencies":{"a":"1","b":"2","a":"3"}}'); +showPkg("extra top-level", '{"name":"x","version":"1","dependencies":{"a":"1"},"private":true,"keywords":["k"]}'); +showPkg("truncated", '{"name":"t","version":"1","dependencies":{"a":"1"'); +showPkg("bad value", '{"name":"t","version":"1","dependencies":{"a":"1",}}'); + +const flags = JSON.parse( + '{"version":3,"flags":{"newUi":true,"beta":false,"darkMode":true},"weights":{"a":0.5,"b":-2,"c":1e3}}', +) as Flags; +for (const [k, v] of Object.entries(flags.flags)) console.log(k, v); +let sum = 0; +for (const k in flags.weights) sum += flags.weights[k]!; +console.log(sum, Object.keys(flags.weights).length, JSON.stringify(flags)); + +// Dictionaries of records and of arrays, nested. +const buckets = JSON.parse( + '{"hot":{"ids":[1,2,3],"label":"h"},"cold":{"ids":[],"label":null},"warm":{"ids":[4],"label":"w"}}', +) as Record; +for (const [name, b] of Object.entries(buckets)) console.log(name, b.ids.length, b.label ?? "nolabel"); +const nested = JSON.parse('{"a":{"x":[1,2],"y":[]},"b":{},"c":{"z":[3]}}') as Record>; +console.log(JSON.stringify(nested), Object.keys(nested.a!).join(","), Object.keys(nested.b!).length); +const list = JSON.parse('[{"k":"v"},{},{"k2":"v2","k3":"v3"}]') as Record[]; +console.log(list.map((d) => Object.keys(d).join("+")).join("|"), JSON.stringify(list)); + +// Declared members beside the index signature: declared ones keep their +// slots, the rest join the overflow in source order. (Native key order lists +// declared members first, so the text does too.) +const labeled = JSON.parse('{"title":"T","zeta":"1","alpha":"2"}') as Labeled; +console.log(labeled.title, labeled["zeta"], labeled["alpha"], Object.keys(labeled).join(","), JSON.stringify(labeled)); +const plain = JSON.parse('{"title":"only"}') as Labeled; +console.log(plain.title, Object.keys(plain).join(","), JSON.stringify(plain)); + +// Writes after the parse. +const deps = JSON.parse('{"a":"1","b":"2"}') as Record; +deps["c"] = "3"; +delete deps["a"]; +deps["b"] = "two"; +console.log(Object.keys(deps).join(","), JSON.stringify(deps)); diff --git a/tests/corpus/json-typed-parse-optional-members.ts b/tests/corpus/json-typed-parse-optional-members.ts new file mode 100644 index 000000000..4b3c9ac35 --- /dev/null +++ b/tests/corpus/json-typed-parse-optional-members.ts @@ -0,0 +1,137 @@ +// JSON.parse(text) as T keeps records with optional and nullable members on +// the one-pass schema parser. A missing optional member is ABSENT (no own +// property), a present one holds its value, and JSON null lands in the null +// arm. Key order, `in`, Object.keys, hasOwnProperty, stringify, and +// later writes and deletes observe exactly what Node's parsed object has. +// Every input is a VALID cast, so Node (where `as` is a no-op) and the native +// binary must agree on both the direct and the checked-dynamic route. + +interface Meta { + source: string; + version: number; +} +interface User { + id: number; + name?: string; + email?: string | null; + age?: number; + verified?: boolean; + metadata: Meta | null; + parent: number | null; + note: string | null; + tags?: string[]; + manager?: Meta | null; +} + +function describe(label: string, u: User): void { + const keys = Object.keys(u).join(","); + const has = ["name", "email", "age", "verified", "tags", "manager"] + .map((k) => `${k}:${k in u ? "y" : "n"}`) + .join(" "); + console.log(label, keys); + console.log(" has", has, Object.prototype.hasOwnProperty.call(u, "age")); + console.log( + " values", + u.id, + u.name === undefined ? "-" : u.name, + u.email === undefined ? "undef" : u.email === null ? "null" : u.email, + u.age ?? -1, + u.verified === undefined ? "?" : u.verified, + u.metadata === null ? "nometa" : `${u.metadata.source}@${u.metadata.version}`, + u.parent ?? "root", + u.note ?? "nonote", + u.tags === undefined ? "notags" : u.tags.join("+"), + u.manager === undefined ? "nomgr" : u.manager === null ? "nullmgr" : u.manager.source, + ); + console.log(" json", JSON.stringify(u)); +} + +function attempt(label: string, text: string): void { + try { + describe(label, JSON.parse(text) as User); + } catch (e) { + console.log("caught", label, e instanceof SyntaxError); + } +} + +// Every member present, in declaration order. +attempt( + "full", + '{"id":1,"name":"ada","email":"a@x.io","age":36,"verified":true,"metadata":{"source":"api","version":2},' + + '"parent":0,"note":"hi","tags":["a","b"],"manager":{"source":"hr","version":1}}', +); +// Only the required members: each optional member is absent. +attempt("required only", '{"id":2,"metadata":null,"parent":null,"note":null}'); +// Absent members at the start, middle and end of the declaration order. +attempt("gaps", '{"id":3,"age":0,"metadata":null,"parent":2,"note":"","manager":null}'); +attempt("tail", '{"id":4,"name":"","metadata":{"source":"","version":-0},"parent":-1.5,"note":null}'); +// Explicit nulls for nullable optional members stay present. +attempt("nulls", '{"id":5,"email":null,"metadata":null,"parent":null,"note":null,"manager":null}'); +// Keys out of declaration order with absent members: Node's key order wins. +attempt("reordered", '{"note":"n","parent":7,"id":6,"metadata":null,"verified":false,"name":"r"}'); +// Undeclared members beside absent ones. +attempt( + "extra", + '{"id":7,"extra":{"deep":[1,null]},"metadata":null,"parent":null,"note":null,"age":44,"more":"x"}', +); +// Escaped keys still match optional members. +attempt("escaped", '{"id":8,"n\\u0061me":"esc","metadata":null,"parent":null,"note":null,"\\u0061ge":3}'); +// Whitespace everywhere. +attempt( + "spaced", + ' {\n "id" : 9 ,\t"tags" : [ ] , "metadata" : { "source" : "s" , "version" : 1 } ,\r\n "parent" : null , "note" : null } ', +); +// Duplicate keys: the last value wins, as in Node. +attempt("duplicate", '{"id":10,"age":1,"metadata":null,"parent":null,"note":null,"age":2}'); +attempt("duplicate null", '{"id":11,"note":"a","metadata":null,"parent":null,"note":null}'); +// Syntax errors throw the same SyntaxError on both sides. +attempt("truncated", '{"id":12,"metadata":null,"parent":null,"note":null'); +attempt("trailing comma", '{"id":13,"metadata":null,"parent":null,"note":null,}'); + +// Arrays of records with optional members, mixing present and absent. +const many = JSON.parse( + '[{"id":1,"metadata":null,"parent":null,"note":null},' + + '{"id":2,"name":"b","metadata":{"source":"x","version":3},"parent":1,"note":"n","age":20},' + + '{"id":3,"verified":true,"metadata":null,"parent":2,"note":null,"tags":[]}]', +) as User[]; +let total = 0; +for (const u of many) { + total += u.id + (u.age ?? 0) + (u.metadata?.version ?? 0); + console.log(u.id, Object.keys(u).length, "name" in u, "verified" in u, JSON.stringify(u)); +} +console.log("total", total); + +// Writes after the parse: assigning undefined keeps the key, delete drops it. +const w = JSON.parse('{"id":20,"name":"w","metadata":null,"parent":null,"note":null}') as User; +w.name = undefined; +console.log("name" in w, Object.keys(w).join(","), JSON.stringify(w)); +delete w.name; +console.log("name" in w, Object.keys(w).join(",")); + +// Nested optional records inside optional records. +interface Node { + label: string; + child?: Node; + next: Node | null; +} +function walk(n: Node, depth: number): void { + console.log(" ".repeat(depth) + n.label, "child" in n, n.next === null); + if (n.child !== undefined) walk(n.child, depth + 1); + if (n.next !== null) walk(n.next, depth); +} +const tree = JSON.parse( + '{"label":"root","child":{"label":"a","next":{"label":"b","child":{"label":"c","next":null},"next":null}},"next":null}', +) as Node; +walk(tree, 0); +console.log(JSON.stringify(tree)); + +// Every member optional: the empty object parses with every member absent. +interface Loose { + a?: number; + b?: string; + c?: boolean; +} +const empty = JSON.parse("{}") as Loose; +console.log(Object.keys(empty).length, "a" in empty, JSON.stringify(empty)); +const some = JSON.parse('{"c":false,"a":1}') as Loose; +console.log(Object.keys(some).join(","), some.a, some.b, some.c, JSON.stringify(some)); diff --git a/tests/corpus/json-typed-parse-union-members.ts b/tests/corpus/json-typed-parse-union-members.ts new file mode 100644 index 000000000..3c5807d2b --- /dev/null +++ b/tests/corpus/json-typed-parse-union-members.ts @@ -0,0 +1,69 @@ +// JSON.parse(text) as T with union-typed record members whose JSON value +// kind selects one arm: scalar unions (stored inline in the record), a +// reference arm with null or undefined, and tagged-box unions mixing +// strings, numbers, booleans, records and arrays. Values, narrowing, key +// presence and stringify must match Node on both parse routes. + +interface Point { + x: number; + y: number; +} +interface Cell { + key: string | number; + flag: boolean | null; + value?: string | number | boolean | null; + shape: Point | string; + list: number[] | string | null; + score: number | null | undefined; +} + +function show(c: Cell): string { + const key = typeof c.key === "string" ? `s:${c.key}` : `n:${c.key * 2}`; + const value = + c.value === undefined + ? "absent" + : c.value === null + ? "null" + : typeof c.value === "string" + ? `str:${c.value}` + : typeof c.value === "number" + ? `num:${c.value + 1}` + : `bool:${c.value ? "t" : "f"}`; + const shape = typeof c.shape === "string" ? c.shape : `(${c.shape.x},${c.shape.y})`; + const list = c.list === null ? "nolist" : typeof c.list === "string" ? c.list : c.list.join("/"); + const score = c.score === undefined ? "u" : c.score === null ? "z" : String(c.score); + return [key, c.flag, value, shape, list, score, "value" in c, "score" in c].join(" "); +} + +const inputs = [ + '{"key":"a","flag":true,"value":"v","shape":{"x":1,"y":2},"list":[1,2,3],"score":9.5}', + '{"key":7,"flag":null,"value":3,"shape":"circle","list":"csv","score":null}', + '{"key":-0,"flag":false,"value":true,"shape":{"y":4,"x":3},"list":null}', + '{"key":"","flag":false,"value":null,"shape":"","list":[],"score":0}', + '{"key":1e21,"flag":true,"shape":{"x":-1,"y":0.5,"z":9},"list":[0.1],"score":-1}', + '{"key":"k","flag":null,"value":false,"shape":"sq","list":"","value":"last"}', +]; +for (const text of inputs) { + const c = JSON.parse(text) as Cell; + console.log(show(c)); + console.log(" ", Object.keys(c).join(","), JSON.stringify(c)); +} + +const cells = JSON.parse("[" + inputs.join(",") + "]") as Cell[]; +console.log(cells.map(show).join(" | ")); + +// Union arrays inside a record member, and a record arm holding unions. +interface Doc { + title: string; + meta: { owner: string | null; rev?: number } | null; + sizes: number[] | null; +} +const docs = JSON.parse( + '[{"title":"a","meta":{"owner":null},"sizes":[1,2]},' + + '{"title":"b","meta":null,"sizes":null},' + + '{"title":"c","meta":{"owner":"o","rev":3},"sizes":[]}]', +) as Doc[]; +for (const d of docs) { + const meta = d.meta === null ? "-" : `${d.meta.owner ?? "nobody"}:${d.meta.rev ?? "r0"}:${"rev" in d.meta}`; + console.log(d.title, meta, d.sizes === null ? "nosizes" : d.sizes.length, JSON.stringify(d)); +} 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/corpus/rc-last-use-moves.ts b/tests/corpus/rc-last-use-moves.ts new file mode 100644 index 000000000..69a87f204 --- /dev/null +++ b/tests/corpus/rc-last-use-moves.ts @@ -0,0 +1,202 @@ +// An owned local or parameter whose last statement reads it once hands its +// reference to that read (a push, a field store, a record field, an owned +// argument) instead of retaining a copy and releasing its own reference at +// scope exit. Constructor parameters that are stored as their last use take +// the owned convention, so a temporary argument moves straight into the +// field. Reads that are not last (aliases derived from the binding, loops, +// repeated reads) keep their own references; each case below reads values +// again after the stores and churns the allocator. + +class Leaf { + tag: string; + constructor(tag: string) { + this.tag = tag; + } +} + +class Node { + readonly left: Node | null; + readonly right: Node | null; + constructor(left: Node | null, right: Node | null) { + this.left = left; + this.right = right; + } + count(): number { + return 1 + (this.left === null ? 0 : this.left.count()) + (this.right === null ? 0 : this.right.count()); + } +} + +function tree(depth: number): Node { + return depth === 0 ? new Node(null, null) : new Node(tree(depth - 1), tree(depth - 1)); +} + +function churn(): number { + let n = 0; + for (let i = 0; i < 32; i++) n += new Leaf(`c${i}`).tag.length; + return n; +} + +// A pushed local moves into the array. +function rows(count: number): Leaf[] { + const out: Leaf[] = []; + for (let i = 0; i < count; i++) { + const row = new Leaf(`row${i}`); + if (i % 2 === 0) row.tag += "!"; + out.push(row); + } + return out; +} + +// A derived alias keeps the source alive past the push. +function derived(): string { + const holder = { leaf: new Leaf("inner") }; + const out: { leaf: Leaf }[] = []; + const leaf = holder.leaf; + out.push(holder); + out.length = 0; + churn(); + return leaf.tag; +} + +// The same local read twice in its last statement keeps its reference. +class Pair { + a: Leaf; + b: Leaf; + constructor(a: Leaf, b: Leaf) { + this.a = a; + this.b = b; + } +} +function twice(): string { + const leaf = new Leaf("both"); + const pair = new Pair(leaf, leaf); + pair.a = new Leaf("first"); + churn(); + return `${pair.a.tag},${pair.b.tag}`; +} + +// A local read inside a loop body is never moved. +function looped(): string { + const leaf = new Leaf("loop"); + const out: Leaf[] = []; + for (let i = 0; i < 3; i++) out.push(leaf); + out[0] = new Leaf("x"); + churn(); + return out.map((l) => l.tag).join(","); +} + +// A conditional last use: the other path releases the local at scope exit. +function conditional(flag: boolean, sink: Leaf[]): number { + const leaf = new Leaf(flag ? "taken" : "skipped"); + if (flag) sink.push(leaf); + churn(); + return sink.length; +} + +// An exception after the move: the moved temp is released exactly once. +function failing(sink: Leaf[], fail: boolean): void { + const leaf = new Leaf("pending"); + sink.push(fail ? (() => { throw new Error("boom"); })() : leaf); +} + +// Owned constructor parameters: a caller still using its argument keeps it. +class Box { + item: Leaf; + note: string; + constructor(item: Leaf, note: string) { + if (note.length === 0) throw new Error("empty note"); + this.note = note; + this.item = item; + } +} +class LabeledBox extends Box { + label: Leaf; + constructor(item: Leaf, label: Leaf) { + super(item, `labeled ${label.tag}`); + this.label = label; + } +} + +// A walk local rebound from a parameter that is stored last. +class Chain { + next: Chain | null = null; + id: number; + constructor(id: number) { + this.id = id; + } +} +class Ends { + head: Chain; + tail: Chain; + constructor(head: Chain) { + let n = head; + while (n.next !== null) n = n.next; + this.tail = n; + this.head = head; + } +} + +// A setter-like method stores its parameter; an override does not. +class Holder { + value: Leaf = new Leaf("initial"); + set(v: Leaf): void { + this.value = v; + } +} +class Ignoring extends Holder { + set(v: Leaf): void { + console.log("ignored", v.tag); + } +} + +console.log(tree(6).count()); +console.log( + rows(5) + .map((r) => r.tag) + .join(","), +); +console.log(derived()); +console.log(twice()); +console.log(looped()); +const sink: Leaf[] = []; +console.log(conditional(true, sink), conditional(false, sink), sink[0]!.tag); +try { + failing(sink, true); +} catch (e) { + console.log("caught", (e as Error).message, sink.length); +} +failing(sink, false); +console.log(sink.map((l) => l.tag).join(",")); + +const shared = new Leaf("shared"); +const box = new Box(shared, "first"); +const again = new Box(shared, "second"); +box.item = new Leaf("replaced"); +churn(); +console.log(box.item.tag, again.item.tag, shared.tag); +try { + new Box(new Leaf("lost"), ""); +} catch (e) { + console.log("caught", (e as Error).message); +} +const labeled = new LabeledBox(new Leaf("item"), new Leaf("lbl")); +console.log(labeled.item.tag, labeled.label.tag, labeled.note); + +const c1 = new Chain(1); +c1.next = new Chain(2); +c1.next.next = new Chain(3); +const ends = new Ends(c1); +c1.next = null; +churn(); +console.log(ends.head.id, ends.tail.id); + +const holders: Holder[] = [new Holder(), new Ignoring()]; +for (const h of holders) { + const v = new Leaf(`v${holders.indexOf(h)}`); + h.set(v); + churn(); + console.log(h.value.tag, v.tag); +} +const current = holders[0]!; +current.set(current.value); +console.log(current.value.tag); diff --git a/tests/corpus/rc-private-array-writes.ts b/tests/corpus/rc-private-array-writes.ts new file mode 100644 index 000000000..c8bbeca2e --- /dev/null +++ b/tests/corpus/rc-private-array-writes.ts @@ -0,0 +1,124 @@ +// Element stores into a function's own fresh array (the map, filter and +// fused helpers' results, and hand-written builders) cannot release an +// element of another array, so elements read from the input stay borrowed +// across callbacks. Arrays that escape, are read back, or are the input +// itself keep the conservative ownership; each "must own" case below makes +// the overwritten slot the last owner, churns the allocator and reads the +// value again. + +class Item { + name: string; + weight: number; + constructor(name: string, weight: number) { + this.name = name; + this.weight = weight; + } + label(): string { + return `${this.name}:${this.weight}`; + } +} + +function churn(): number { + let total = 0; + for (let i = 0; i < 64; i++) total += new Item(`tmp${i}`, i).weight; + return total; +} + +const log: string[] = []; +const scratch: Item[] = []; + +// map/filter callbacks that write unrelated arrays: borrowed elements stay valid. +function mapped(items: Item[]): string[] { + return items.map((item) => { + scratch.push(new Item("s", scratch.length)); + if (scratch.length > 3) scratch.length = 0; + log.push(item.name); + churn(); + return item.label(); + }); +} + +function heavy(items: Item[]): Item[] { + return items.filter((item) => { + scratch[0] = new Item("replaced", item.weight); + churn(); + return item.weight > 1; + }); +} + +// A hand-written builder: a fresh local array that is only stored into and returned. +function build(items: Item[], extra: (item: Item) => Item): Item[] { + const out: Item[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + out.push(extra(item)); + out[out.length - 1] = extra(item); + churn(); + } + return out; +} + +// Must own: the callback overwrites the very array being filtered. +function selfOverwrite(items: Item[]): string[] { + const kept = items.filter((item, i) => { + items[i] = new Item(`new${i}`, -1); + churn(); + return item.weight >= 0 && item.name.length > 0; + }); + return kept.map((item) => item.label()); +} + +// Must own: a fresh array that escapes into a holder before it is written. +const holder: Item[][] = []; +function escaped(): string { + const out: Item[] = [new Item("first", 1)]; + holder.push(out); + const first = holder[holder.length - 1]![0]!; + out[0] = new Item("second", 2); + churn(); + return first.label(); +} + +// Must own: a fresh array whose element is read back before it is overwritten. +function readBack(): string { + const out: Item[] = []; + out.push(new Item("kept", 3)); + const kept = out[0]!; + out[0] = new Item("other", 4); + out.length = 0; + churn(); + return kept.label(); +} + +// Must own: the fresh array is passed to a callee that clears it. +function clear(list: Item[]): void { + list.length = 0; +} +function passed(): string { + const out: Item[] = [new Item("passed", 5)]; + const head = out[0]!; + clear(out); + churn(); + return head.label(); +} + +const base = [new Item("a", 1), new Item("b", 2), new Item("c", 3)]; +console.log(mapped(base).join(",")); +console.log(log.join("")); +console.log( + heavy(base) + .map((item) => item.label()) + .join(","), +); +console.log( + build(base, (item) => new Item(item.name.toUpperCase(), item.weight * 10)) + .map((item) => item.label()) + .join(","), +); +console.log(selfOverwrite([new Item("x", 1), new Item("y", 2)]).join(",")); +console.log(escaped(), holder.length, holder[0]![0]!.label()); +console.log(readBack()); +console.log(passed()); +const numbers = base.map((item) => item.weight * 2); +numbers[0] = 100; +console.log(numbers.join(","), base.map((item) => item.label()).join(",")); diff --git a/tests/corpus/stackless-async-concurrency.ts b/tests/corpus/stackless-async-concurrency.ts new file mode 100644 index 000000000..bd836902b --- /dev/null +++ b/tests/corpus/stackless-async-concurrency.ts @@ -0,0 +1,51 @@ +// Many concurrently suspended stackless async calls: twenty thousand calls +// park on timers and on one shared pending promise at once, chains of +// nested awaits suspend at their leaves, and each continuation still +// resumes in Node's order with its own locals intact. +const count = 20000; +let resolveGate: (v: number) => void = () => {}; +const gate = new Promise((resolve) => { + resolveGate = resolve; +}); +const order: number[] = []; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function parked(i: number): Promise { + const local = i * 3; + const label = "task" + i; + const g = await gate; + if (i % 4000 === 0) order.push(i); + await sleep(i % 3); + return local + g + label.length; +} + +async function chain(depth: number, i: number): Promise { + if (depth === 0) { + const g = await gate; + return g + i; + } + const below = await chain(depth - 1, i); + return below + depth; +} + +async function main(): Promise { + const tasks: Promise[] = []; + for (let i = 0; i < count; i++) tasks.push(parked(i)); + const chains: Promise[] = []; + for (let i = 0; i < 500; i++) chains.push(chain(12, i)); + console.log("spawned", tasks.length, chains.length); + await sleep(1); + resolveGate(5); + let sum = 0; + for (const t of tasks) sum += await t; + let chainSum = 0; + for (const c of chains) chainSum += await c; + console.log("sum", sum, "chains", chainSum, "order", order.join(",")); +} + +void main(); diff --git a/tests/corpus/stackless-async-context.ts b/tests/corpus/stackless-async-context.ts new file mode 100644 index 000000000..8be2673b5 --- /dev/null +++ b/tests/corpus/stackless-async-context.ts @@ -0,0 +1,85 @@ +// AsyncLocalStorage across stackless async bodies: a call inherits the +// caller's context when it starts, keeps it across every await even after +// the caller's run() window closed, nested runs inside the body shadow and +// restore around awaits, enterWith inside a body stays inside that body, +// and concurrent bodies keep their own contexts while their continuations +// interleave on the microtask queue and across timers. The concurrent +// bodies wake from gates that chained timer callbacks open one per turn in +// a fixed order, so their interleaving never depends on timer deadlines. +import { AsyncLocalStorage } from "node:async_hooks"; + +const store = new AsyncLocalStorage(); +const log: string[] = []; +const say = (s: string): void => { + log.push(s); +}; +const current = (): string => store.getStore() ?? "none"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +const opens: (() => void)[] = []; +function gate(): Promise { + return new Promise((resolve) => { + opens.push(resolve); + }); +} + +// Opens the gates in `order`, each from its own timer callback scheduled by +// the previous one, so each woken body runs before the next gate opens. +function openInOrder(order: number[], at: number): void { + if (at === order.length) return; + setTimeout(() => { + say("open gate " + order[at]!); + opens[order[at]!]!(); + openInOrder(order, at + 1); + }, 1); +} + +async function worker(id: number, ready: Promise): Promise { + say("worker " + id + " start " + current()); + await null; + say("worker " + id + " after hop " + current()); + await ready; + say("worker " + id + " after timer " + current()); + const inner = await store.run("inner" + id, async () => { + await null; + return current(); + }); + say("worker " + id + " inner " + inner + " back " + current()); + return current(); +} + +async function entering(): Promise { + say("entering start " + current()); + store.enterWith("entered"); + await Promise.resolve(); + say("entering after await " + current()); + return current(); +} + +async function main(): Promise { + const a = store.run("a", () => worker(1, gate())); + const b = store.run("b", () => worker(2, gate())); + const c = worker(3, gate()); + say("main after spawns " + current()); + openInOrder([1, 2, 0], 0); + const all: Promise[] = [a, b, c]; + const results = await Promise.all(all); + say("results " + results.join(" ")); + const e = await store.run("outer", () => entering()); + say("entering returned " + e + " main sees " + current()); + await store.run("chain", async () => { + const first = await worker(4, sleep(1)); + const second = await worker(5, sleep(2)); + say("chain " + first + " " + second); + }); + say("main end " + current()); +} + +main().then(() => { + for (const line of log) console.log(line); +}); diff --git a/tests/corpus/stackless-async-exceptions.ts b/tests/corpus/stackless-async-exceptions.ts new file mode 100644 index 000000000..44047544f --- /dev/null +++ b/tests/corpus/stackless-async-exceptions.ts @@ -0,0 +1,146 @@ +// Exceptions in stackless async bodies: a throw before the first await +// rejects the promise inside the call, a throw after an await rejects it +// later, rejections re-throw at the awaiting site, try/catch/finally +// regions span awaits (including a finally that awaits and a return +// through a finally), loops break out of awaits, and Promise combinators +// observe the rejections in Node's order. +const log: string[] = []; +const say = (s: string): void => { + log.push(s); +}; + +class AppError extends Error { + readonly code: number; + constructor(message: string, code: number) { + super(message); + this.code = code; + } +} + +async function early(n: number): Promise { + if (n < 0) throw new AppError("negative " + n, 1); + await null; + return n; +} + +async function late(n: number): Promise { + await Promise.resolve(); + if (n > 2) throw new AppError("too big " + n, 2); + await null; + return n * 2; +} + +async function guarded(n: number): Promise { + try { + say("guarded try " + n); + const v = await late(n); + say("guarded value " + v); + return "ok " + v; + } catch (e) { + const err = e as AppError; + say("guarded catch " + err.message + " code " + err.code); + await null; + return "caught " + err.code; + } finally { + say("guarded finally " + n); + await Promise.resolve(); + say("guarded finally after await " + n); + } +} + +async function nested(n: number): Promise { + let attempts = 0; + for (let i = 0; i < 5; i++) { + try { + attempts++; + await late(n + i); + if (i === 1) break; + } catch (e) { + say("nested caught " + (e as Error).message); + await null; + continue; + } finally { + say("nested finally " + i); + } + } + return attempts; +} + +async function rethrow(n: number): Promise { + try { + return await early(n); + } catch (e) { + say("rethrow saw " + (e as Error).message); + throw new AppError("wrapped " + (e as Error).message, 3); + } +} + +async function throwsString(): Promise { + await null; + throw "plain string"; +} + +async function syncFail(n: number): Promise { + if (n > 0) throw new AppError("sync fail " + n, 4); + return -n; +} + +async function inlineAwaits(label: string): Promise { + const ok = await syncFail(-3); + say(label + " ok " + ok); + try { + await syncFail(5); + return "unreachable"; + } catch (e) { + return label + " " + ok + " " + (e as AppError).code + " " + (e as Error).message; + } +} + +async function main(): Promise { + const p = early(-1); + say("early returned a promise"); + p.catch((e: unknown) => say("early catch " + (e as Error).message)); + say("guarded " + (await guarded(1))); + say("guarded " + (await guarded(5))); + say("nested " + (await nested(1))); + say("nested " + (await nested(2))); + try { + await rethrow(-4); + } catch (e) { + const err = e as AppError; + say("main caught " + err.message + " " + err.code + " " + (err instanceof AppError)); + } + try { + await throwsString(); + } catch (e) { + say("string " + String(e)); + } + const attempts: Promise[] = [late(1), late(3), early(-2), early(2)]; + const settled = await Promise.allSettled(attempts); + for (const s of settled) { + if (s.status === "fulfilled") say("fulfilled " + s.value); + else say("rejected " + (s.reason as Error).message); + } + try { + const all: Promise[] = [late(1), late(4), late(5)]; + await Promise.all(all); + } catch (e) { + say("all rejected " + (e as Error).message); + } + const first = await Promise.race([late(2), early(9)]); + say("race " + first); + const anyOf: Promise[] = [late(7), late(1)]; + const any = await Promise.any(anyOf); + say("any " + any); + const inlined: Promise[] = [inlineAwaits("i1"), inlineAwaits("i2")]; + queueMicrotask(() => say("inline microtask")); + const results = await Promise.all(inlined); + say("inline " + results.join(" | ")); +} + +main().then( + () => { + for (const line of log) console.log(line); + }, + (e: unknown) => console.log("main failed", String(e)), +); diff --git a/tests/corpus/stackless-async-ordering.ts b/tests/corpus/stackless-async-ordering.ts new file mode 100644 index 000000000..8f2b8772e --- /dev/null +++ b/tests/corpus/stackless-async-ordering.ts @@ -0,0 +1,190 @@ +// Async functions that suspend only at their own awaits compile to +// stackless coroutines on native targets. Their turns must interleave with +// every other kind of job exactly as Node's: the eager prefix runs inside +// the call, an await of a settled promise or a plain value takes one +// microtask turn, a pending promise resumes its awaiters in subscription +// order, a returned promise is adopted with its extra turns, and +// then-reactions, queueMicrotask, nextTick, timers and immediates keep +// their places around the continuations. Stackless bodies also await and +// are awaited by bodies that keep a fiber (an async generator consumer). +const log: string[] = []; +const say = (s: string): void => { + log.push(s); +}; + +async function plain(label: string): Promise { + say(label + " prefix"); + await null; + say(label + " after null"); + const v = await Promise.resolve(label.length); + say(label + " after settled " + v); + return v * 10; +} + +let open: (v: string) => void = () => {}; +const gate = new Promise((resolve) => { + open = resolve; +}); + +async function waiter(id: number): Promise { + say("waiter " + id + " waits"); + const v = await gate; + say("waiter " + id + " woke " + v); + await undefined; + say("waiter " + id + " hop"); + return v + id; +} + +async function returnsPromise(n: number): Promise { + say("returnsPromise " + n); + return plain("rp" + n); +} + +async function returnAwait(n: number): Promise { + say("returnAwait " + n); + return await plain("ra" + n); +} + +async function flags(n: number): Promise { + await null; + return n % 2 === 0; +} + +async function text(n: number): Promise { + const parts: string[] = []; + for (let i = 0; i < n; i++) { + parts.push(String(await Promise.resolve(i))); + } + return parts.join(","); +} + +interface Pair { + left: number; + right: string; +} + +async function record(n: number): Promise { + const left = await Promise.resolve(n); + const right = await text(n); + return { left, right }; +} + +async function maybe(p: Promise | undefined): Promise { + const v = await p; + say("maybe " + String(v)); + return v; +} + +class Counter { + total = 0; + async add(n: number): Promise { + await null; + this.total += n; + await Promise.resolve(); + return this.total; + } +} + +async function* numbers(limit: number): AsyncGenerator { + for (let i = 0; i < limit; i++) { + await null; + yield i; + } +} + +async function consume(limit: number): Promise { + let sum = 0; + for await (const n of numbers(limit)) { + sum += await flags(n) ? n : 0; + } + say("consume " + sum); + return sum; +} + +async function syncLeaf(n: number): Promise { + say("leaf " + n); + return n + 1; +} + +async function syncThrow(n: number): Promise { + say("sync throw " + n); + throw new Error("sync " + n); +} + +async function relayLeaf(n: number): Promise { + return syncLeaf(n); +} + +async function interleave(label: string): Promise { + say(label + " a " + (await syncLeaf(1))); + try { + await syncThrow(2); + } catch (e) { + say(label + " caught " + (e as Error).message); + } + say(label + " relay " + (await relayLeaf(3))); +} + +async function main(): Promise { + say("main start"); + const a = plain("a"); + const b = plain("bb"); + say("main spawned"); + Promise.resolve().then(() => say("then 1")); + queueMicrotask(() => say("microtask 1")); + process.nextTick(() => say("tick 1")); + const w1 = waiter(1); + const w2 = waiter(2); + const r1 = returnsPromise(1); + const r2 = returnAwait(2); + say("awaiting a " + (await a)); + say("awaiting b " + (await b)); + open("open"); + const w3 = waiter(3); + say("w1 " + (await w1) + " w2 " + (await w2) + " w3 " + (await w3)); + say("r1 " + (await r1) + " r2 " + (await r2)); + say("flags " + (await flags(2)) + " " + (await flags(3))); + say("text " + (await text(4))); + const rec = await record(3); + say("record " + rec.left + " " + rec.right); + say("maybe " + String(await maybe(undefined)) + " " + String(await maybe(Promise.resolve(7)))); + const counter = new Counter(); + const adds: Promise[] = [counter.add(1), counter.add(2), counter.add(3)]; + const sums = await Promise.all(adds); + say("counter " + sums.join(" ") + " total " + counter.total); + say("consumed " + (await consume(5))); + const captured: number[] = []; + let shared = 0; + const bump = async (k: number): Promise => { + shared += k; + await null; + captured.push(shared); + shared += k; + }; + const bumps: Promise[] = [bump(1), bump(10), bump(100)]; + await Promise.all(bumps); + say("closures " + captured.join(" ") + " shared " + shared); + const inline: Promise[] = [interleave("x"), interleave("y")]; + queueMicrotask(() => say("inline microtask")); + Promise.resolve().then(() => say("inline then")); + await Promise.all(inline); + say("inline done"); + // Macrotasks, each scheduled from the one before so their order is fixed: + // the timer's microtask runs before the immediate it schedules. + await new Promise((resolve) => { + setTimeout(() => { + say("timeout"); + setImmediate(() => { + say("immediate"); + resolve(); + }); + Promise.resolve().then(() => say("timeout microtask")); + process.nextTick(() => say("timeout tick")); + }, 0); + }); + say("after macrotasks " + (await plain("m"))); +} + +main().then(() => { + for (const line of log) console.log(line); +}); diff --git a/tests/corpus/stackless-async-unhandled.ts b/tests/corpus/stackless-async-unhandled.ts new file mode 100644 index 000000000..17de064c4 --- /dev/null +++ b/tests/corpus/stackless-async-unhandled.ts @@ -0,0 +1,22 @@ +// @exit: 1 +// A stackless async body that rejects after suspending with nobody +// observing its promise: the rejection reaches Node's unhandled-rejection +// path once the microtask checkpoint completes, after the work already +// queued ahead of it, and the process exits with status 1. +async function failLater(label: string): Promise { + console.log("start", label); + await Promise.resolve(); + console.log("resumed", label); + await null; + throw new Error("unhandled " + label); +} + +async function fine(): Promise { + await null; + console.log("fine done"); +} + +void fine(); +void failLater("x"); +setTimeout(() => console.log("timer never runs"), 5); +console.log("main done"); diff --git a/tests/diagnostics/checked-js-constructor-arguments.js b/tests/diagnostics/checked-js-constructor-arguments.js new file mode 100644 index 000000000..0c20d3373 --- /dev/null +++ b/tests/diagnostics/checked-js-constructor-arguments.js @@ -0,0 +1,12 @@ +// @ts-check +// A JavaScript file TypeScript checks reports the type errors that protect +// its JSDoc types, as a TypeScript file does. +class Account { + /** @param {number} balance */ + constructor(balance) { + this.balance = balance; + } +} +const account = new Account("10"); +console.log(account.balance); +// End with a nonblank context line so diagnostic snapshots carry no trailing whitespace. diff --git a/tests/diagnostics/contract-int32-array-aliases.ts b/tests/diagnostics/contract-int32-array-aliases.ts new file mode 100644 index 000000000..085acd862 --- /dev/null +++ b/tests/diagnostics/contract-int32-array-aliases.ts @@ -0,0 +1,40 @@ +// Every reference to an array that can reach an Int32[] slot is checked: +// a captured binding, a class field, a function result, a nested array, a +// Map value, arrays whose elements a spread copies in (also through an +// alias), and a copy that is changed before its elements are copied back. +import type { Int32 } from "@scriptc/types"; + +class Holder { + values: number[] = []; +} + +function same(xs: number[]): number[] { + return xs; +} + +function viaSnapshot(xs: Int32[]): void { + const snapshot = xs.slice(); + snapshot[0] = 0.5; + xs[0] = snapshot[0]; +} + +const ids: Int32[] = [1, 2, 3]; +const bump = (): void => { + ids[0] = ids[0] + 0.5; +}; +const holder = new Holder(); +const held: Int32[] = holder.values; +holder.values.push(1 / 3); +same(ids).unshift(-0.25); +const grid: number[][] = [ids]; +grid[0][1] = 1e10; +const byName = new Map([["ids", ids]]); +byName.get("ids")!.fill(NaN); +const extra = [7, 8.5]; +ids.push(...extra); +const view: number[] = ids; +const more = [0.75]; +view.unshift(...more); +bump(); +viaSnapshot(ids); +console.log(ids, held, grid.length, byName.size); diff --git a/tests/diagnostics/contract-int32-arrays.ts b/tests/diagnostics/contract-int32-arrays.ts new file mode 100644 index 000000000..53c2bc1d8 --- /dev/null +++ b/tests/diagnostics/contract-int32-arrays.ts @@ -0,0 +1,21 @@ +// Int32[] contracts fail when an element store into an array that can +// reach an Int32[] slot is not provably an int32 (through any alias, before +// or after the array reaches the slot), when the array can come from code +// scriptc cannot see, or when it escapes to such code. +import type { Int32 } from "@scriptc/types"; + +function halveInto(dst: number[], src: Int32[]): void { + for (let i = 0; i < src.length; i++) dst[i] = src[i] / 2; +} + +const fractions: number[] = [0.5, 1]; +const ids: Int32[] = fractions; +const counts: Int32[] = [0, 0, 0]; +const alias: number[] = counts; +alias.push(2.5); +counts.fill(Math.PI); +halveInto(counts, ids); +const doubled: Int32[] = counts.map((x) => x * 2); +const parsed: Int32[] = JSON.parse("[1, 2]"); +const measure = (xs: Int32[]): number => xs.length; +console.log(ids, counts, doubled, parsed, [counts].map(measure)); diff --git a/tests/diagnostics/contract-positions.ts b/tests/diagnostics/contract-positions.ts index 8223e0ae0..d3428a8da 100644 --- a/tests/diagnostics/contract-positions.ts +++ b/tests/diagnostics/contract-positions.ts @@ -1,4 +1,5 @@ -// Contract types are checked where they annotate a declaration directly. +// Contract types are checked where they annotate a declaration directly +// (Int32 also as the element type of a declared array, Int32[]). // Elsewhere they would promise something scriptc does not check, so the // build reports them. import type { Immutable, Int32, NoThrow } from "@scriptc/types"; @@ -9,7 +10,7 @@ interface Point { x: Int32; } -const ids: Int32[] = [1, 2]; +const ids: Int32[][] = [[1, 2]]; const point: Point = { x: 1 }; const id: NodeId = 3; const callback: (x: number) => NoThrow = (x) => x; diff --git a/tests/diagnostics/dataview.ts b/tests/diagnostics/dataview.ts index 01255b47c..c20193279 100644 --- a/tests/diagnostics/dataview.ts +++ b/tests/diagnostics/dataview.ts @@ -1,6 +1,8 @@ -// Stored ArrayBuffers and DataViews lower; Float16 accessors remain unsupported. +// Stored ArrayBuffers and DataViews lower, Float16 accessors included; +// detached accessor methods remain unsupported. const buffer = new ArrayBuffer(16); const view = new DataView(buffer); view.setFloat16(0, 1.5); console.log(view.getFloat16(0)); +const getter = view.getFloat16; // End of the diagnostic fixture. diff --git a/tests/diagnostics/deferred-class-binding/crate.ts b/tests/diagnostics/deferred-class-binding/crate.ts index 7a24ac02e..7dd470efa 100644 --- a/tests/diagnostics/deferred-class-binding/crate.ts +++ b/tests/diagnostics/deferred-class-binding/crate.ts @@ -1,6 +1,6 @@ export class Crate { readonly label: string; - contents: unknown; + contents: any; constructor(label: string) { this.label = label; this.contents = undefined; diff --git a/tests/diagnostics/es2025-fences.ts b/tests/diagnostics/es2025-fences.ts index 128c9d9ed..067da192e 100644 --- a/tests/diagnostics/es2025-fences.ts +++ b/tests/diagnostics/es2025-fences.ts @@ -1,9 +1,8 @@ // The es2025-lib surface WITHOUT a lowering fences by name: resizable // ArrayBuffers and transfer, SharedArrayBuffer.grow, Atomics.waitAsync, -// Float16Array and its Math/DataView companions, first-class iterator -// objects, and the unlowered forms of the implemented statics -// (Promise.try's ...args, groupBy's literal-union keys, a set-like -// non-Set argument). +// first-class iterator objects, and the unlowered forms of the +// implemented statics (Promise.try with fewer arguments than parameters, +// groupBy's literal-union keys, a set-like non-Set argument). // Resizable ArrayBuffer + transfer (ES2024). const ab = new ArrayBuffer(8, { maxByteLength: 16 }); @@ -22,17 +21,11 @@ sab.grow(16); const ia = new Int32Array(4); const waited = Atomics.waitAsync(ia, 0, 0, 1); -// Float16 (ES2025): the typed array, Math.f16round, DataView accessors. -const f16 = new Float16Array(4); -console.log(Math.f16round(1.337)); -const dv = new DataView(new Uint8Array(8).buffer); -dv.setFloat16(0, 1.5); - // First-class iterator objects. const it = Iterator.from([1, 2, 3].values()); -// Promise.try's ...args form (close over the values instead). -const tried = Promise.try((a: number, b: number) => a + b, 1, 2); +// Promise.try with fewer arguments than the callback's parameters. +const tried = Promise.try((a: number, b?: number) => a + (b ?? 0), 1); // Object.groupBy at a literal-union key type (a fixed-field record). const grouped = Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); diff --git a/tests/diagnostics/generic-methods.ts b/tests/diagnostics/generic-methods.ts index f220f330c..6b602cee1 100644 --- a/tests/diagnostics/generic-methods.ts +++ b/tests/diagnostics/generic-methods.ts @@ -45,28 +45,6 @@ const withThis = { }; console.log(withThis.m(2)); -// The defining literal must sit at module scope: compiled instances are -// plain module functions and cannot capture the enclosing frame. -function makeObj(): number { - const local = { m(x: T): T { return x; } }; - return local.m(3); -} -console.log(makeObj()); - -// Static resolution needs a never-reassigned binding: this let is written -// again with a same-typed literal whose body differs — resolving against -// the first would silently call the wrong body. -let rebindable = { m(x: T): T { return x; } }; -console.log(rebindable.m(4)); -rebindable = { m(x: T): T { return x; } }; - -// An interface-typed receiver has no defining object literal to resolve. -interface HasGeneric { - m(x: T): T; -} -const viaInterface: HasGeneric = { m(x: T): T { return x; } }; -console.log(viaInterface.m(5)); - // Generic methods as VALUES: bound-method references stay fenced (class // receivers), and an unpinned object-literal reference keeps the // generic-value fence. diff --git a/tests/diagnostics/generic-value-bindings.ts b/tests/diagnostics/generic-value-bindings.ts index 4e440e7d9..28a8fb366 100644 --- a/tests/diagnostics/generic-value-bindings.ts +++ b/tests/diagnostics/generic-value-bindings.ts @@ -51,6 +51,3 @@ function throughParam(a: Amb): void { console.log(a.m(4)); } throughParam(amb); - -// A generic lambda outside any monomorphizable home (an IIFE). -console.log(((x: T): T => x)(5)); diff --git a/tests/diagnostics/json-dyn.ts b/tests/diagnostics/json-dyn.ts index 77cfd19b1..c5cbac8ea 100644 --- a/tests/diagnostics/json-dyn.ts +++ b/tests/diagnostics/json-dyn.ts @@ -39,7 +39,7 @@ function localCapture(): () => number { return () => local as number; } class Holder { - data: unknown = JSON.parse("{}"); + data: unknown = JSON.parse("{}"); // unknown class fields use checked-dynamic storage — no fence } const anything: any = 5; // checker-`any` bindings ride the checked-dynamic tree now — no fence const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) 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/diagnostics/node-fallback-fence.ts b/tests/diagnostics/node-fallback-fence.ts index 7aca7e649..86034f1ac 100644 --- a/tests/diagnostics/node-fallback-fence.ts +++ b/tests/diagnostics/node-fallback-fence.ts @@ -28,7 +28,7 @@ accessSync("/bin/sh", constants.X_OK); const tmp = mkdtempSync("/tmp/scr-"); const raw = readFileSync("/etc/hosts").toString(tty ? "hex" : "latin1"); // runtime BufferEncoding selection lowers too const intervalWithSignal = promiseInterval(1, "tick", {}); // AbortSignal options stay fenced -const sleepWithSignal = promiseSleep(1, "tick", { signal: AbortSignal.timeout(5) }); // resolve values lower; AbortSignal stays fenced +const sleepWithSignal = promiseSleep(1, "tick", { signal: AbortSignal.timeout(5) }); // resolve values and AbortSignal options lower // The computed encoding is now part of the static Buffer surface. The // one-shot zlib/raw/gzip codecs take Node's options, validated at run // time; Brotli remains an explicit member fence. diff --git a/tests/diagnostics/npm-program-type-error/main.ts b/tests/diagnostics/npm-program-type-error/main.ts new file mode 100644 index 000000000..40132d7ea --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/main.ts @@ -0,0 +1,7 @@ +// Type errors in program code report at their own locations, not as +// SC2013 refusals of the npm packages the program imports. +import { open } from "sqlish"; +import { route } from "routerish"; + +console.log(open(":memory:")); +console.log(route("/users")); diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts new file mode 100644 index 000000000..8832808f8 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.d.ts @@ -0,0 +1 @@ +export declare function route(method: string, path: string): string; diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js new file mode 100644 index 000000000..8cffdfff3 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/index.js @@ -0,0 +1,3 @@ +export function route(method, path) { + return `${method} ${path}`; +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json b/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json new file mode 100644 index 000000000..db9fe562f --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/routerish/package.json @@ -0,0 +1,7 @@ +{ + "name": "routerish", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts new file mode 100644 index 000000000..146b98c02 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.d.ts @@ -0,0 +1 @@ +export declare function connect(path: string): { path: string }; diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js new file mode 100644 index 000000000..9cda88901 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/index.js @@ -0,0 +1,3 @@ +export function connect(path) { + return { path }; +} diff --git a/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json new file mode 100644 index 000000000..26f3b6a08 --- /dev/null +++ b/tests/diagnostics/npm-program-type-error/node_modules/sqlish/package.json @@ -0,0 +1,7 @@ +{ + "name": "sqlish", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "types": "index.d.ts" +} diff --git a/tests/diagnostics/regex-constructor-flags.ts b/tests/diagnostics/regex-constructor-flags.ts index 3d388601c..cbf7de7ee 100644 --- a/tests/diagnostics/regex-constructor-flags.ts +++ b/tests/diagnostics/regex-constructor-flags.ts @@ -1,4 +1,3 @@ -// new RegExp with literal d or v flags refuses like the regex literal -// forms; computed flags reach the runtime, which names the same gap. -const sets = new RegExp("[\\w--\\d]", "v"); +// new RegExp with a literal d flag refuses like the regex literal form; +// computed flags reach the runtime, which names the same gap. const indices = new RegExp("cat", "dg"); diff --git a/tests/diagnostics/regex.ts b/tests/diagnostics/regex.ts index ec29bc70e..e2b3fc4ae 100644 --- a/tests/diagnostics/regex.ts +++ b/tests/diagnostics/regex.ts @@ -1,8 +1,7 @@ -// The d/v flags are outside the native slice; .groups needs a +// The d flag is outside the native slice; .groups needs a // statically-known regex. Named capture groups compile (corpus 2604). // Method values and regex union arms remain unsupported; arrays compile. const indices = /cat/d; -const sets = /[\p{L}]/v; const asValue = /x/.test; const maybe: RegExp | undefined = /a/; function readGroups(re: RegExp): void { 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/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/fixtures/fetch/servers.mjs b/tests/fixtures/fetch/servers.mjs index c0fe8a0d5..d3f04b153 100644 --- a/tests/fixtures/fetch/servers.mjs +++ b/tests/fixtures/fetch/servers.mjs @@ -370,6 +370,7 @@ export async function startFetchServers() { // CONNECT tunnels (undici's ProxyAgent shape — it tunnels even plain // http) splice raw sockets. One count per proxied request either way. let proxiedRequests = 0; + let tunneledRequests = 0; const proxyAuthorizations = []; const proxyRequest = (req, res) => { // Relative-path requests are never proxy traffic — the standalone @@ -410,6 +411,7 @@ export async function startFetchServers() { }; const proxyConnect = (req, clientSocket, head) => { proxiedRequests++; + tunneledRequests++; proxyAuthorizations.push(req.headers["proxy-authorization"] ?? ""); const [host, portStr] = (req.url ?? "").split(":"); const upstream = connect(Number(portStr ?? "80"), host ?? "127.0.0.1", () => { @@ -450,6 +452,7 @@ export async function startFetchServers() { proxyUrl, secureProxyUrl, proxiedRequests: () => proxiedRequests, + tunneledRequests: () => tunneledRequests, proxyAuthorizations: () => proxyAuthorizations.slice(), close: async () => { await new Promise((resolve) => server.close(() => resolve())); diff --git a/tests/fixtures/fetch/static-proxy-error/main.mts b/tests/fixtures/fetch/static-proxy-error/main.mts new file mode 100644 index 000000000..26b57c579 --- /dev/null +++ b/tests/fixtures/fetch/static-proxy-error/main.mts @@ -0,0 +1,25 @@ +// Prints a failed fetch's error and its cause chain: name, message and +// code at each level, plus the system error fields Node sets. +function describe(value: unknown): string { + const error = value as { + name?: unknown; + message?: unknown; + code?: unknown; + errno?: unknown; + syscall?: unknown; + address?: unknown; + port?: unknown; + }; + return `${String(error.name)} | ${String(error.message)} | ${String(error.code)} | ${String(error.errno)} | ${String(error.syscall)} | ${String(error.address)} | ${String(error.port)}`; +} + +try { + const response = await fetch(process.argv[2]); + console.log(response.status, await response.text()); +} catch (error) { + let level: unknown = error; + for (let depth = 0; level !== undefined && depth < 4; depth++) { + console.log(depth, describe(level)); + level = (level as { cause?: unknown }).cause; + } +} 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/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/fixtures/node-types/fenced.ts b/tests/fixtures/node-types/fenced.ts index 3ad6839c8..b884de3ce 100644 --- a/tests/fixtures/node-types/fenced.ts +++ b/tests/fixtures/node-types/fenced.ts @@ -33,10 +33,10 @@ ReadableStream.from(new Set([1, 2])); * typecheck under @types/node and fence with the module-qualified name — * calls and value reads alike. */ import { watchFile } from "fs"; -import { setPriority } from "node:os"; +import { EventEmitter, on } from "node:events"; import { win32 } from "path"; watchFile("x", () => {}); -setPriority(0); +on(new EventEmitter(), "tick"); console.log(win32.sep); /* URL getters and serialization lower under @types/node, but object-URL * registry operations still fence by member name with the supported diff --git a/tests/fixtures/node-types/net-blocklist.ts b/tests/fixtures/node-types/net-blocklist.ts new file mode 100644 index 000000000..6f999bd76 --- /dev/null +++ b/tests/fixtures/node-types/net-blocklist.ts @@ -0,0 +1,23 @@ +// Under @types/node: net.SocketAddress and net.BlockList. +import * as net from "node:net"; + +const address = new net.SocketAddress({ address: "10.0.0.2", port: 8080 }); +console.log(address.address, address.port, address.family, address.flowlabel); +const parsed = net.SocketAddress.parse("[::1]:9000"); +console.log(parsed?.address, parsed?.port, parsed?.family); + +const list = new net.BlockList(); +list.addAddress("1.2.3.4"); +list.addRange("10.0.0.1", "10.0.0.9"); +list.addSubnet("fe80::", 10, "ipv6"); +console.log(list.rules); +console.log(list.check("1.2.3.4"), list.check(address), list.check("fe80::1", "ipv6"), list.check("8.8.8.8")); +console.log(JSON.stringify(list.toJSON())); +const copy = new net.BlockList(); +copy.fromJSON(list.toJSON()); +console.log(net.BlockList.isBlockList(copy), copy.rules.length); +try { + list.addRange("10.0.0.9", "10.0.0.1"); +} catch (err) { + console.log((err as NodeJS.ErrnoException).code); +} diff --git a/tests/fixtures/npm-static/class-factory-cli.ts b/tests/fixtures/npm-static/class-factory-cli.ts new file mode 100644 index 000000000..c2a96f66c --- /dev/null +++ b/tests/fixtures/npm-static/class-factory-cli.ts @@ -0,0 +1,25 @@ +// `class Dog extends Animal.extend("Dog")`: the static `extend` a factory +// class defines is inherited by a program class and builds the next +// subclass from its receiver, as Effect's Schema.Class does. +import { Class } from "class-factory/classes.js"; + +class Animal extends Class("Animal") {} +class Dog extends Animal.extend("Dog") {} +class Puppy extends Dog.extend("Puppy") {} + +const rex = new Dog("Rex"); +const plain = new Animal("Ada"); +const pup = new Puppy("Bit"); +console.log( + JSON.stringify({ + dog: rex instanceof Dog, + animal: rex instanceof Animal, + plainDog: plain instanceof Dog, + plainAnimal: plain instanceof Animal, + kind: rex.kind, + value: rex.value, + plainKind: plain.kind, + }), +); +console.log(pup instanceof Puppy, pup instanceof Dog, pup instanceof Animal, rex instanceof Puppy); +console.log(rex.describe(), plain.describe(), pup.describe()); diff --git a/tests/fixtures/npm-static/class-options-cli.ts b/tests/fixtures/npm-static/class-options-cli.ts new file mode 100644 index 000000000..9d9c4f902 --- /dev/null +++ b/tests/fixtures/npm-static/class-options-cli.ts @@ -0,0 +1,15 @@ +// A package factory that receives its base class through a destructured +// options object builds subclasses of program classes that construct and +// dispatch like Node. +import { Class, extend } from "class-options/classes.js"; + +class Animal extends Class("Animal") {} +class Dog extends extend(Animal, "Dog") {} +class Puppy extends extend(Dog, "Puppy") {} + +const rex = new Dog("Rex"); +const ada = new Animal("Ada"); +const bit = new Puppy("Bit"); +console.log(rex instanceof Dog, rex instanceof Animal, ada instanceof Dog, bit instanceof Animal); +console.log(rex.describe(), ada.describe(), bit.describe()); +console.log(rex.kind, rex.value, ada.kind, bit.label); diff --git a/tests/fixtures/npm-static/node_modules/class-factory/classes.js b/tests/fixtures/npm-static/node_modules/class-factory/classes.js new file mode 100644 index 000000000..38e531b31 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-factory/classes.js @@ -0,0 +1,25 @@ +// A class factory whose classes extend themselves through a static +// method: `extend` builds a subclass of whichever class it is called on. +function factory(base, name) { + const out = class extends base { + constructor(value) { + super(value); + this.kind = name; + } + static extend(name2) { + return factory(this, name2); + } + describe() { + return `${this.kind}:${this.value}`; + } + }; + return out; +} +class Root { + constructor(value) { + this.value = value; + } +} +export function Class(name) { + return factory(Root, name); +} diff --git a/tests/fixtures/npm-static/node_modules/class-factory/package.json b/tests/fixtures/npm-static/node_modules/class-factory/package.json new file mode 100644 index 000000000..521d00d93 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-factory/package.json @@ -0,0 +1 @@ +{ "name": "class-factory", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } } diff --git a/tests/fixtures/npm-static/node_modules/class-options/classes.js b/tests/fixtures/npm-static/node_modules/class-options/classes.js new file mode 100644 index 000000000..e2ae647f8 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-options/classes.js @@ -0,0 +1,31 @@ +// A class factory that takes its base and settings as one options object, +// as Effect's makeClass does: `makeClass({ Base, name })` destructures them +// and builds `class extends Base`. +function makeClass({ Base, name, tag: label }) { + return class extends Base { + constructor(value) { + super(value); + this.kind = name; + this.label = label; + } + describe() { + return `${this.label}:${this.kind}:${this.value}`; + } + // Each class passes itself back as a base: a call site must not + // specialize makeClass on the classes makeClass itself defines. + static extend(name) { + return makeClass({ Base: this, name, tag: label }); + } + }; +} +class Root { + constructor(value) { + this.value = value; + } +} +export function Class(name) { + return makeClass({ Base: Root, name, tag: "root" }); +} +export function extend(Base, name) { + return makeClass({ Base, name, tag: "sub" }); +} diff --git a/tests/fixtures/npm-static/node_modules/class-options/package.json b/tests/fixtures/npm-static/node_modules/class-options/package.json new file mode 100644 index 000000000..3e2fb2b49 --- /dev/null +++ b/tests/fixtures/npm-static/node_modules/class-options/package.json @@ -0,0 +1 @@ +{ "name": "class-options", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } } diff --git a/tests/harness/__snapshots__/checked-js-constructor-arguments.js.txt b/tests/harness/__snapshots__/checked-js-constructor-arguments.js.txt new file mode 100644 index 000000000..a60dc9c4c --- /dev/null +++ b/tests/harness/__snapshots__/checked-js-constructor-arguments.js.txt @@ -0,0 +1,6 @@ +checked-js-constructor-arguments.js:10:29 - error SC0001: Argument of type 'string' is not assignable to parameter of type 'number'. + + 9 | } + 10 | const account = new Account("10"); + | ^~~~ + 11 | console.log(account.balance); \ No newline at end of file diff --git a/tests/harness/__snapshots__/console-fences.ts.txt b/tests/harness/__snapshots__/console-fences.ts.txt index 98865ff68..1841fec65 100644 --- a/tests/harness/__snapshots__/console-fences.ts.txt +++ b/tests/harness/__snapshots__/console-fences.ts.txt @@ -59,7 +59,7 @@ console-fences.ts:17:25 - error SC2020: 'console.dir with the 'showHidden' optio | ^~~~~~~~~~~~~~~~ 18 | - hint: the lowered options are depth (numeric literal or null), colors: false, compact: 3, breakLength: 80, and customInspect: false + hint: the lowered options are depth (numeric literal or null), colors: false, compact (false or a number), breakLength, sorted (true or false), and customInspect: false console-fences.ts:22:35 - error SC2020: 'console.assert with a format string and computed arguments' is part of the standard library types but has no scriptc lowering yet diff --git a/tests/harness/__snapshots__/contract-int32-array-aliases.ts.txt b/tests/harness/__snapshots__/contract-int32-array-aliases.ts.txt new file mode 100644 index 000000000..f8a3fe1c2 --- /dev/null +++ b/tests/harness/__snapshots__/contract-int32-array-aliases.ts.txt @@ -0,0 +1,71 @@ +contract-int32-array-aliases.ts:18:11 - error SC7002: the element stored into 'xs' is not provably an int32: 'snapshot[0]' (the array can reach 'ids', declared Int32[]) + + 17 | snapshot[0] = 0.5; + 18 | xs[0] = snapshot[0]; + | ^~~~~~~~~~~ + 19 | } + + hint: make the int32 conversion explicit, for example 'snapshot[0] | 0', so Node stores the same value + +contract-int32-array-aliases.ts:23:12 - error SC7002: the element stored into 'ids' is not provably an int32: 'ids[0] + 0.5' (the array can reach 'ids', declared Int32[]) + + 22 | const bump = (): void => { + 23 | ids[0] = ids[0] + 0.5; + | ^~~~~~~~~~~~ + 24 | }; + + hint: make the int32 conversion explicit, for example '(ids[0] + 0.5) | 0', so Node stores the same value + +contract-int32-array-aliases.ts:27:20 - error SC7002: the element stored into 'holder.values' is not provably an int32: '1 / 3' (the array can reach 'held', declared Int32[]) + + 26 | const held: Int32[] = holder.values; + 27 | holder.values.push(1 / 3); + | ^~~~~ + 28 | same(ids).unshift(-0.25); + + hint: make the int32 conversion explicit, for example '(1 / 3) | 0', so Node stores the same value + +contract-int32-array-aliases.ts:28:19 - error SC7002: the element stored into 'same(ids)' is not provably an int32: '-0.25' (the array can reach 'ids', declared Int32[]) + + 27 | holder.values.push(1 / 3); + 28 | same(ids).unshift(-0.25); + | ^~~~~ + 29 | const grid: number[][] = [ids]; + + hint: make the int32 conversion explicit, for example '(-0.25) | 0', so Node stores the same value + +contract-int32-array-aliases.ts:30:14 - error SC7002: the element stored into 'grid[0]' is not provably an int32: '1e10' (the array can reach 'ids', declared Int32[]) + + 29 | const grid: number[][] = [ids]; + 30 | grid[0][1] = 1e10; + | ^~~~ + 31 | const byName = new Map([["ids", ids]]); + + hint: make the int32 conversion explicit, for example '1e10 | 0', so Node stores the same value + +contract-int32-array-aliases.ts:32:25 - error SC7002: the element stored into 'byName.get("ids")!' is not provably an int32: 'NaN' (the array can reach 'ids', declared Int32[]) + + 31 | const byName = new Map([["ids", ids]]); + 32 | byName.get("ids")!.fill(NaN); + | ^~~ + 33 | const extra = [7, 8.5]; + + hint: make the int32 conversion explicit, for example 'NaN | 0', so Node stores the same value + +contract-int32-array-aliases.ts:33:19 - error SC7002: the array literal element '8.5' is not provably an int32 (the array can reach 'ids', declared Int32[]) + + 32 | byName.get("ids")!.fill(NaN); + 33 | const extra = [7, 8.5]; + | ^~~ + 34 | ids.push(...extra); + + hint: make the int32 conversion explicit, for example '8.5 | 0', so Node stores the same value + +contract-int32-array-aliases.ts:36:15 - error SC7002: the array literal element '0.75' is not provably an int32 (the array can reach 'ids', declared Int32[]) + + 35 | const view: number[] = ids; + 36 | const more = [0.75]; + | ^~~~ + 37 | view.unshift(...more); + + hint: make the int32 conversion explicit, for example '0.75 | 0', so Node stores the same value \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-int32-arrays.ts.txt b/tests/harness/__snapshots__/contract-int32-arrays.ts.txt new file mode 100644 index 000000000..c326adbb5 --- /dev/null +++ b/tests/harness/__snapshots__/contract-int32-arrays.ts.txt @@ -0,0 +1,71 @@ +contract-int32-arrays.ts:8:49 - error SC7002: the element stored into 'dst' is not provably an int32: 'src[i] / 2' (the array can reach 'counts', declared Int32[]) + + 7 | function halveInto(dst: number[], src: Int32[]): void { + 8 | for (let i = 0; i < src.length; i++) dst[i] = src[i] / 2; + | ^~~~~~~~~~ + 9 | } + + hint: make the int32 conversion explicit, for example '(src[i] / 2) | 0', so Node stores the same value + +contract-int32-arrays.ts:11:30 - error SC7002: the array literal element '0.5' is not provably an int32 (the array can reach 'ids', declared Int32[]) + + 10 | + 11 | const fractions: number[] = [0.5, 1]; + | ^~~ + 12 | const ids: Int32[] = fractions; + + hint: make the int32 conversion explicit, for example '0.5 | 0', so Node stores the same value + +contract-int32-arrays.ts:15:12 - error SC7002: the element stored into 'alias' is not provably an int32: '2.5' (the array can reach 'counts', declared Int32[]) + + 14 | const alias: number[] = counts; + 15 | alias.push(2.5); + | ^~~ + 16 | counts.fill(Math.PI); + + hint: make the int32 conversion explicit, for example '2.5 | 0', so Node stores the same value + +contract-int32-arrays.ts:16:13 - error SC7002: the element stored into 'counts' is not provably an int32: 'Math.PI' (the array can reach 'counts', declared Int32[]) + + 15 | alias.push(2.5); + 16 | counts.fill(Math.PI); + | ^~~~~~~ + 17 | halveInto(counts, ids); + + hint: make the int32 conversion explicit, for example 'Math.PI | 0', so Node stores the same value + +contract-int32-arrays.ts:18:26 - error SC7002: the elements 'counts.map((x) => x * 2)' stores are not provably int32s (the array can reach 'doubled', declared Int32[]) + + 17 | halveInto(counts, ids); + 18 | const doubled: Int32[] = counts.map((x) => x * 2); + | ^~~~~~~~~~~~~~~~~~~~~~~~ + 19 | const parsed: Int32[] = JSON.parse("[1, 2]"); + + hint: callback results are not followed; build the array with a loop that stores 'value | 0' + +contract-int32-arrays.ts:19:15 - error SC7003: the Int32[] contract on 'parsed' cannot be checked: the array can come from dynamic (any or unknown) code (contract-int32-arrays.ts:19:25), whose element stores scriptc does not see + + 18 | const doubled: Int32[] = counts.map((x) => x * 2); + 19 | const parsed: Int32[] = JSON.parse("[1, 2]"); + | ^~~~~ + 20 | const measure = (xs: Int32[]): number => xs.length; + + hint: build the array in typed code (a literal, `new Array(n).fill(0)` or `push`) and pass it along, or remove Int32[] + +contract-int32-arrays.ts:20:22 - error SC7003: the Int32[] contract on 'xs' cannot be checked: the array can come from the callers of 'measure', which is used as a value (its callers are not all visible) (contract-int32-arrays.ts:20:17), whose element stores scriptc does not see + + 19 | const parsed: Int32[] = JSON.parse("[1, 2]"); + 20 | const measure = (xs: Int32[]): number => xs.length; + | ^~~~~ + 21 | console.log(ids, counts, doubled, parsed, [counts].map(measure)); + + hint: build the array in typed code (a literal, `new Array(n).fill(0)` or `push`) and pass it along, or remove Int32[] + +contract-int32-arrays.ts:21:43 - error SC7003: an array that can reach 'counts' (Int32[]) escapes to a call through a function value, where its element stores are not checked: '[counts].map(measure)' + + 20 | const measure = (xs: Int32[]): number => xs.length; + 21 | console.log(ids, counts, doubled, parsed, [counts].map(measure)); + | ^~~~~~~~~~~~~~~~~~~~~ + 22 | + + hint: keep Int32[] arrays in typed code, or pass a copy (`[...xs]`) that no Int32[] slot holds \ No newline at end of file diff --git a/tests/harness/__snapshots__/contract-positions.ts.txt b/tests/harness/__snapshots__/contract-positions.ts.txt index d43cd07e3..59fa86364 100644 --- a/tests/harness/__snapshots__/contract-positions.ts.txt +++ b/tests/harness/__snapshots__/contract-positions.ts.txt @@ -1,53 +1,53 @@ -contract-positions.ts:6:15 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) +contract-positions.ts:7:15 - error SC7006: the Int32 contract is not checked in this position (Int32 and Int32[] are checked on parameters, variables, class instance fields and function results) - 5 | - 6 | type NodeId = Int32; + 6 | + 7 | type NodeId = Int32; | ^~~~~ - 7 | + 8 | - hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + hint: write Int32 or Int32[] directly as the annotation of the declaration; type aliases, interface members and types nested in other types (unions, nested arrays, other type arguments) are not followed -contract-positions.ts:9:6 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) +contract-positions.ts:10:6 - error SC7006: the Int32 contract is not checked in this position (Int32 and Int32[] are checked on parameters, variables, class instance fields and function results) - 8 | interface Point { - 9 | x: Int32; + 9 | interface Point { + 10 | x: Int32; | ^~~~~ - 10 | } + 11 | } - hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + hint: write Int32 or Int32[] directly as the annotation of the declaration; type aliases, interface members and types nested in other types (unions, nested arrays, other type arguments) are not followed -contract-positions.ts:12:12 - error SC7006: the Int32 contract is not checked in this position (Int32 is checked on parameters, variables, class instance fields and function results) +contract-positions.ts:13:12 - error SC7006: the Int32 contract is not checked in this position (Int32 and Int32[] are checked on parameters, variables, class instance fields and function results) - 11 | - 12 | const ids: Int32[] = [1, 2]; + 12 | + 13 | const ids: Int32[][] = [[1, 2]]; | ^~~~~ - 13 | const point: Point = { x: 1 }; + 14 | const point: Point = { x: 1 }; - hint: write Int32 directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed + hint: write Int32 or Int32[] directly as the annotation of the declaration; type aliases, interface members and types nested in other types (unions, nested arrays, other type arguments) are not followed -contract-positions.ts:15:32 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) +contract-positions.ts:16:32 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) - 14 | const id: NodeId = 3; - 15 | const callback: (x: number) => NoThrow = (x) => x; + 15 | const id: NodeId = 3; + 16 | const callback: (x: number) => NoThrow = (x) => x; | ^~~~~~~~~~~~~~~ - 16 | const table: Map> = new Map(); + 17 | const table: Map> = new Map(); hint: write it as the function's return type annotation: function f(): NoThrow -contract-positions.ts:16:26 - error SC7006: the Immutable contract is not checked in this position (Immutable is checked on parameters, variables, class instance fields and function results) +contract-positions.ts:17:26 - error SC7006: the Immutable contract is not checked in this position (Immutable is checked on parameters, variables, class instance fields and function results) - 15 | const callback: (x: number) => NoThrow = (x) => x; - 16 | const table: Map> = new Map(); + 16 | const callback: (x: number) => NoThrow = (x) => x; + 17 | const table: Map> = new Map(); | ^~~~~~~~~~~~~~~~~~~ - 17 | let last: NoThrow = 0; + 18 | let last: NoThrow = 0; hint: write Immutable directly as the annotation of the declaration; type aliases, interface members and types nested in other types (arrays, unions, type arguments) are not followed -contract-positions.ts:17:11 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) +contract-positions.ts:18:11 - error SC7006: the NoThrow contract is not checked in this position (NoThrow wraps a function's written return type) - 16 | const table: Map> = new Map(); - 17 | let last: NoThrow = 0; + 17 | const table: Map> = new Map(); + 18 | let last: NoThrow = 0; | ^~~~~~~~~~~~~~~ - 18 | + 19 | hint: write it as the function's return type annotation: function f(): NoThrow \ No newline at end of file diff --git a/tests/harness/__snapshots__/dataview.ts.txt b/tests/harness/__snapshots__/dataview.ts.txt index f09051126..4f1401ca3 100644 --- a/tests/harness/__snapshots__/dataview.ts.txt +++ b/tests/harness/__snapshots__/dataview.ts.txt @@ -1,17 +1,8 @@ -dataview.ts:4:1 - error SC2020: 'DataView.setFloat16' is part of the standard library types but has no scriptc lowering yet +dataview.ts:7:16 - error SC2020: 'DataView.getFloat16' is part of the standard library types but has no scriptc lowering yet - 3 | const view = new DataView(buffer); - 4 | view.setFloat16(0, 1.5); - | ^~~~~~~~~~~~~~~ - 5 | console.log(view.getFloat16(0)); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -dataview.ts:5:13 - error SC2020: 'DataView.getFloat16' is part of the standard library types but has no scriptc lowering yet - - 4 | view.setFloat16(0, 1.5); - 5 | console.log(view.getFloat16(0)); - | ^~~~~~~~~~~~~~~ - 6 | // End of the diagnostic fixture. + 6 | console.log(view.getFloat16(0)); + 7 | const getter = view.getFloat16; + | ^~~~~~~~~~~~~~~ + 8 | // End of the diagnostic fixture. 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 diff --git a/tests/harness/__snapshots__/date-formatters.ts.txt b/tests/harness/__snapshots__/date-formatters.ts.txt index 881703106..66d98d861 100644 --- a/tests/harness/__snapshots__/date-formatters.ts.txt +++ b/tests/harness/__snapshots__/date-formatters.ts.txt @@ -5,7 +5,7 @@ date-formatters.ts:4:1 - error SC2020: 'Date.prototype.toString' is part of the | ^~~~~~~~~~~~~~~ 5 | date.toLocaleString(); - hint: getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part of the standard library types but has no scriptc lowering yet @@ -14,4 +14,4 @@ date-formatters.ts:5:1 - error SC2020: 'Date.prototype.toLocaleString' is part o | ^~~~~~~~~~~~~~~~~~~~~ 6 | - hint: getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering \ No newline at end of file + hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/deep-binary-nesting.ts.txt b/tests/harness/__snapshots__/deep-binary-nesting.ts.txt index 02cce3cdb..9e645656f 100644 --- a/tests/harness/__snapshots__/deep-binary-nesting.ts.txt +++ b/tests/harness/__snapshots__/deep-binary-nesting.ts.txt @@ -1,6 +1,6 @@ deep-binary-nesting.ts:2:9 - error SC1090: expressions nested deeper than 200 levels are not supported yet 1 | // A ~300-term left-nested binary chain crosses the expression-lowering depth floor (LOWER_EXPR_MAX_DEPTH): the named fence fires with ample stack headroom, where ~3000 terms used to overflow two recursive walks as an ICE (invariant signature 11). - 2 | let x = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1; - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 2 | let x = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + … + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 3 | console.log(x); \ No newline at end of file diff --git a/tests/harness/__snapshots__/deep-nesting-preflight.ts.txt b/tests/harness/__snapshots__/deep-nesting-preflight.ts.txt index 655f881cc..c0eb7534f 100644 --- a/tests/harness/__snapshots__/deep-nesting-preflight.ts.txt +++ b/tests/harness/__snapshots__/deep-nesting-preflight.ts.txt @@ -1,6 +1,6 @@ deep-nesting-preflight.ts:2:1 - error SC1090: expressions nested deeper than 200 levels are not supported yet - 1 | // A ~7000-term left-nested binary chain, the binderBinaryExpressionStress shape: deep enough that the whole-file walks (the checker facade prefetch sweep, the require scans, the nested-var collection) used to overflow the JS stack as a RangeError ICE before the expression-lowering fence could fire — the corpus pair crashed at ~6500 terms, and the bare statement sat within ~50 terms of the nested-var walk's limit. Every such walk is iterative now (walkPreorder), so the program survives to the same named SC1090 fence the ~300-term deep-binary-nesting fixture pins. A BARE expression statement (no initializer position) keeps tsgo's per-node query cost — and this suite — fast; one term per line keeps the snapshot code frame small. 5.9.3's parser (the resolver-parity island sweeps this directory) trampolines binary chains, so the shape parses in both worlds. + 1 | // A ~7000-term left-nested binary chain, the binderBinaryExpressionStress shape: deep enough that the whole-file walks (the checker facade prefetch sweep, the require scans, the nested-var collection) used to overflow the JS stack as a RangeError ICE before the expression-lowering fence could fire — the corpus pair crashed at ~6500 terms, and the bare statement sat within ~50 terms of the nested-… 2 | 1 + | ^~~ 3 | 1 + \ No newline at end of file diff --git a/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt index 4f144907c..c80ae94e9 100644 --- a/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt +++ b/tests/harness/__snapshots__/deferred-class-binding/main.ts.txt @@ -1,7 +1,7 @@ deferred-class-binding/crate.ts:3:3 - error SC1090: 'unknown'-typed class fields are not supported yet 2 | readonly label: string; - 3 | contents: unknown; + 3 | contents: any; | ^~~~~~~~ 4 | constructor(label: string) { diff --git a/tests/harness/__snapshots__/es2025-fences.ts.txt b/tests/harness/__snapshots__/es2025-fences.ts.txt index 0a91648ea..e2dc5f40f 100644 --- a/tests/harness/__snapshots__/es2025-fences.ts.txt +++ b/tests/harness/__snapshots__/es2025-fences.ts.txt @@ -1,125 +1,89 @@ -es2025-fences.ts:9:12 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:8:12 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet - 8 | // Resizable ArrayBuffer + transfer (ES2024). - 9 | const ab = new ArrayBuffer(8, { maxByteLength: 16 }); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 10 | ab.resize(16); + 7 | // Resizable ArrayBuffer + transfer (ES2024). + 8 | const ab = new ArrayBuffer(8, { maxByteLength: 16 }); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 9 | ab.resize(16); hint: fixed-length ArrayBuffers accept a byte length; resizable storage is not supported yet -es2025-fences.ts:15:27 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:14:27 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet - 14 | // fixed-length new Uint8Array(new ArrayBuffer(8)) lowers). - 15 | const rz = new Uint8Array(new ArrayBuffer(8, { maxByteLength: 16 })); + 13 | // fixed-length new Uint8Array(new ArrayBuffer(8)) lowers). + 14 | const rz = new Uint8Array(new ArrayBuffer(8, { maxByteLength: 16 })); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 16 | + 15 | hint: fixed-length ArrayBuffers accept a byte length; resizable storage is not supported yet -es2025-fences.ts:18:13 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:17:13 - error SC2020: 'resizable ArrayBuffer construction' is part of the standard library types but has no scriptc lowering yet - 17 | // SharedArrayBuffer growth. - 18 | const sab = new SharedArrayBuffer(8, { maxByteLength: 16 }); + 16 | // SharedArrayBuffer growth. + 17 | const sab = new SharedArrayBuffer(8, { maxByteLength: 16 }); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 19 | sab.grow(16); + 18 | sab.grow(16); hint: fixed-length ArrayBuffers accept a byte length; resizable storage is not supported yet -es2025-fences.ts:23:7 - error SC2009: values of type '{ async: false; value: "not-equal" | "timed-out"; } | { async: true; value: Promise<"ok" | "timed-out">; }' cannot be compiled: the union shape is supported, but its arm '{ async: false; value: "not-equal" | "timed-out"; }' does not compile +es2025-fences.ts:22:7 - error SC2009: values of type '{ async: false; value: "not-equal" | "timed-out"; } | { async: true; value: Promise<"ok" | "timed-out">; }' cannot be compiled: the union shape is supported, but its arm '{ async: false; value: "not-equal" | "timed-out"; }' does not compile - 22 | const ia = new Int32Array(4); - 23 | const waited = Atomics.waitAsync(ia, 0, 0, 1); + 21 | const ia = new Int32Array(4); + 22 | const waited = Atomics.waitAsync(ia, 0, 0, 1); | ^~~~~~ - 24 | + 23 | hint: the outer shape is supported — the named component is the blocker; restate it inside the compilable set (number, string, boolean, arrays, Maps, Sets, RegExp, functions, classes, records, unions of those, and 'unknown') -es2025-fences.ts:23:16 - error SC2020: 'Atomics.waitAsync' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:22:16 - error SC2020: 'Atomics.waitAsync' is part of the standard library types but has no scriptc lowering yet - 22 | const ia = new Int32Array(4); - 23 | const waited = Atomics.waitAsync(ia, 0, 0, 1); + 21 | const ia = new Int32Array(4); + 22 | const waited = Atomics.waitAsync(ia, 0, 0, 1); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 24 | + 23 | hint: use integer load, store, exchange, compareExchange, add, sub, and, or, xor, wait or notify -es2025-fences.ts:26:7 - error SC2020: 'Float16Array' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:25:7 - error SC2020: 'IteratorObject' is part of the standard library types but has no scriptc lowering yet - 25 | // Float16 (ES2025): the typed array, Math.f16round, DataView accessors. - 26 | const f16 = new Float16Array(4); - | ^~~ - 27 | console.log(Math.f16round(1.337)); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -es2025-fences.ts:26:13 - error SC2020: 'new Float16Array' is part of the standard library types but has no scriptc lowering yet - - 25 | // Float16 (ES2025): the typed array, Math.f16round, DataView accessors. - 26 | const f16 = new Float16Array(4); - | ^~~~~~~~~~~~~~~~~~~ - 27 | console.log(Math.f16round(1.337)); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -es2025-fences.ts:27:13 - error SC2020: 'Math.f16round' is part of the standard library types but has no scriptc lowering yet - - 26 | const f16 = new Float16Array(4); - 27 | console.log(Math.f16round(1.337)); - | ^~~~~~~~~~~~~ - 28 | const dv = new DataView(new Uint8Array(8).buffer); - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -es2025-fences.ts:29:1 - error SC2020: 'DataView.setFloat16' is part of the standard library types but has no scriptc lowering yet - - 28 | const dv = new DataView(new Uint8Array(8).buffer); - 29 | dv.setFloat16(0, 1.5); - | ^~~~~~~~~~~~~ - 30 | - - hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) - -es2025-fences.ts:32:7 - error SC2020: 'IteratorObject' is part of the standard library types but has no scriptc lowering yet - - 31 | // First-class iterator objects. - 32 | const it = Iterator.from([1, 2, 3].values()); + 24 | // First-class iterator objects. + 25 | const it = Iterator.from([1, 2, 3].values()); | ^~ - 33 | + 26 | hint: the type checker sees the full standard library, but only the supported surface compiles (https://scriptc.dev/limitations) -es2025-fences.ts:32:12 - error SC2020: 'Iterator.from' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:25:12 - error SC2020: 'Iterator.from' is part of the standard library types but has no scriptc lowering yet - 31 | // First-class iterator objects. - 32 | const it = Iterator.from([1, 2, 3].values()); + 24 | // First-class iterator objects. + 25 | const it = Iterator.from([1, 2, 3].values()); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 33 | + 26 | hint: first-class iterator objects have no lowering — iterator helpers compile as one chain on an array iterator, consumed in place: arr.values().map(f).take(n).toArray() -es2025-fences.ts:35:15 - error SC2020: 'Promise.try with 3 arguments' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:28:27 - error SC2020: 'Promise.try over a callback of type '(a: number, b?: number | undefined) => number' with 1 argument' is part of the standard library types but has no scriptc lowering yet - 34 | // Promise.try's ...args form (close over the values instead). - 35 | const tried = Promise.try((a: number, b: number) => a + b, 1, 2); - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 36 | + 27 | // Promise.try with fewer arguments than the callback's parameters. + 28 | const tried = Promise.try((a: number, b?: number) => a + (b ?? 0), 1); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 29 | - hint: the ...args form has no lowering — close over the values: Promise.try(() => f(a, b)) + hint: pass one argument per callback parameter: Promise.try((a, b) => a + b, 1, 2) -es2025-fences.ts:38:17 - error SC2020: 'Object.groupBy at this result type' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:31:17 - error SC2020: 'Object.groupBy at this result type' is part of the standard library types but has no scriptc lowering yet - 37 | // Object.groupBy at a literal-union key type (a fixed-field record). - 38 | const grouped = Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); + 30 | // Object.groupBy at a literal-union key type (a fixed-field record). + 31 | const grouped = Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd")); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 39 | + 32 | hint: a string-keyed result (Partial>) is the supported form — a literal-union key type makes a fixed-field record: widen the callback's key type to string (numbers stringify like JS property keys) -es2025-fences.ts:43:19 - error SC2020: 'Set.prototype.union over a set-like argument of type '{ size: number; has: (x: number) => x is 1; keys: () => ArrayIterator; }'' is part of the standard library types but has no scriptc lowering yet +es2025-fences.ts:36:19 - error SC2020: 'Set.prototype.union over a set-like argument of type '{ size: number; has: (x: number) => x is 1; keys: () => ArrayIterator; }'' is part of the standard library types but has no scriptc lowering yet - 42 | const like = { size: 1, has: (x: number) => x === 1, keys: () => [1].values() }; - 43 | const u = s.union(like); + 35 | const like = { size: 1, has: (x: number) => x === 1, keys: () => [1].values() }; + 36 | const u = s.union(like); | ^~~~ - 44 | + 37 | hint: only a real Set or Map lowers (the argument's has/size/keys must be the builtins) — build one first: new Set(...) \ No newline at end of file diff --git a/tests/harness/__snapshots__/generic-methods.ts.txt b/tests/harness/__snapshots__/generic-methods.ts.txt index e0cf6e1c2..4401417ae 100644 --- a/tests/harness/__snapshots__/generic-methods.ts.txt +++ b/tests/harness/__snapshots__/generic-methods.ts.txt @@ -26,55 +26,20 @@ generic-methods.ts:42:18 - error SC1090: references to 'this' in object literal | ^~~~ 43 | return x; -generic-methods.ts:52:10 - error SC1090: object-literal generic methods declared inside functions (the compiled instantiations of 'm' are module functions and cannot capture the enclosing frame — declare the object at module scope) are not supported yet +generic-methods.ts:56:7 - error SC2005: values of type '(x: T) => T' cannot be compiled: the signature keeps its type parameters, and a compiled function is always one concrete signature (generic functions monomorphize per pinned signature — declarations, methods, and never-reassigned bindings holding a generic arrow/function expression, an alias of a registered generic function, or a function initializer under a generic-signature annotation) - 51 | const local = { m(x: T): T { return x; } }; - 52 | return local.m(3); - | ^~~~~~~~~~ - 53 | } - -generic-methods.ts:60:13 - error SC1090: reaching the object-literal generic method 'm' through this receiver (resolution is static, so the receiver must be a never-reassigned binding initialized with the defining literal) is not supported yet - - 59 | let rebindable = { m(x: T): T { return x; } }; - 60 | console.log(rebindable.m(4)); - | ^~~~~~~~~~~~~~~ - 61 | rebindable = { m(x: T): T { return x; } }; - -generic-methods.ts:68:13 - error SC1090: calls of the generic method 'm' through this receiver (the interface declaration is signature-only and generic methods dispatch statically, so the receiver's runtime class must be provable — bind the receiver to a const initialized with its 'new' expression, e.g. 'const r: HasGeneric = new C(...)') is not supported yet - - 67 | const viaInterface: HasGeneric = { m(x: T): T { return x; } }; - 68 | console.log(viaInterface.m(5)); - | ^~~~~~~~~~~~~~~~~ - 69 | - -generic-methods.ts:78:7 - error SC2005: values of type '(x: T) => T' cannot be compiled: the signature keeps its type parameters, and a compiled function is always one concrete signature (generic functions monomorphize per pinned signature — declarations, methods, and never-reassigned bindings holding a generic arrow/function expression, an alias of a registered generic function, or a function initializer under a generic-signature annotation) - - 77 | } - 78 | const bound = new Inst().g; + 55 | } + 56 | const bound = new Inst().g; | ^~~~~ - 79 | const util = { id(x: T): T { return x; } }; + 57 | const util = { id(x: T): T { return x; } }; hint: annotate the destination with a concrete signature (e.g. '(x: number) => number'), instantiate explicitly ('f'), or call the generic function directly -generic-methods.ts:78:15 - error SC1090: values of builtin or unspecialized generic methods are not supported yet +generic-methods.ts:58:7 - error SC2005: values of type '(x: T) => T' cannot be compiled: the signature keeps its type parameters, and a compiled function is always one concrete signature (generic functions monomorphize per pinned signature — declarations, methods, and never-reassigned bindings holding a generic arrow/function expression, an alias of a registered generic function, or a function initializer under a generic-signature annotation) - 77 | } - 78 | const bound = new Inst().g; - | ^~~~~~~~~~~~ - 79 | const util = { id(x: T): T { return x; } }; - -generic-methods.ts:80:7 - error SC2005: values of type '(x: T) => T' cannot be compiled: the signature keeps its type parameters, and a compiled function is always one concrete signature (generic functions monomorphize per pinned signature — declarations, methods, and never-reassigned bindings holding a generic arrow/function expression, an alias of a registered generic function, or a function initializer under a generic-signature annotation) - - 79 | const util = { id(x: T): T { return x; } }; - 80 | const unpinned = util.id; + 57 | const util = { id(x: T): T { return x; } }; + 58 | const unpinned = util.id; | ^~~~~~~~ - 81 | + 59 | - hint: annotate the destination with a concrete signature (e.g. '(x: number) => number'), instantiate explicitly ('f'), or call the generic function directly - -generic-methods.ts:80:18 - error SC1090: generic functions as values without a pinned concrete signature (annotate the destination — e.g. 'const f: (x: number) => number = id' — instantiate explicitly ('id'), or call 'id' directly) is not supported yet - - 79 | const util = { id(x: T): T { return x; } }; - 80 | const unpinned = util.id; - | ^~~~~~~ - 81 | \ No newline at end of file + hint: annotate the destination with a concrete signature (e.g. '(x: number) => number'), instantiate explicitly ('f'), or call the generic function directly \ No newline at end of file diff --git a/tests/harness/__snapshots__/generic-value-bindings.ts.txt b/tests/harness/__snapshots__/generic-value-bindings.ts.txt index 0f21fdf1d..88c886d78 100644 --- a/tests/harness/__snapshots__/generic-value-bindings.ts.txt +++ b/tests/harness/__snapshots__/generic-value-bindings.ts.txt @@ -85,11 +85,4 @@ generic-value-bindings.ts:51:15 - error SC1090: calls of the generic method 'm' 50 | function throughParam(a: Amb): void { 51 | console.log(a.m(4)); | ^~~~~~ - 52 | } - -generic-value-bindings.ts:56:14 - error SC1090: generic arrow/function expressions outside a never-reassigned module-scope binding (only `const f = (x: T) => ...` bindings and top-level generic function declarations monomorphize) is not supported yet - - 55 | // A generic lambda outside any monomorphizable home (an IIFE). - 56 | console.log(((x: T): T => x)(5)); - | ^~~~~~~~~~~~~~~~~ - 57 | \ No newline at end of file + 52 | } \ No newline at end of file diff --git a/tests/harness/__snapshots__/index-signature-values.ts.txt b/tests/harness/__snapshots__/index-signature-values.ts.txt index 9774ab9ae..d1e9395ed 100644 --- a/tests/harness/__snapshots__/index-signature-values.ts.txt +++ b/tests/harness/__snapshots__/index-signature-values.ts.txt @@ -16,7 +16,7 @@ index-signature-values.ts:9:13 - error SC2004: uses of 'bySymbol' inherit the bl hint: the declaration of 'bySymbol' did not compile — fix the diagnostic reported there and these sites clear with it -index-signature-values.ts:13:13 - error SC1090: console.log of '{ [key: string]: Uint8Array }' values (typed arrays inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)) are not supported yet +index-signature-values.ts:13:13 - error SC1090: console.log of '{ [key: string]: Uint8Array }' values (Uint8Array and Buffer values inside composites have no inspect lowering yet (Buffer's form vs Uint8Array's is not recorded in the static type here)) are not supported yet 12 | const buffers: Record = {}; 13 | console.log(buffers); diff --git a/tests/harness/__snapshots__/json-dyn.ts.txt b/tests/harness/__snapshots__/json-dyn.ts.txt index 4b0f60784..4a09378fb 100644 --- a/tests/harness/__snapshots__/json-dyn.ts.txt +++ b/tests/harness/__snapshots__/json-dyn.ts.txt @@ -5,13 +5,6 @@ json-dyn.ts:25:41 - error SC1090: JSON.stringify of '(number) => number' values | ^~~~~~~~~~~~~~~~~~~~ 26 | class Point { -json-dyn.ts:42:3 - error SC1090: 'unknown'-typed class fields are not supported yet - - 41 | class Holder { - 42 | data: unknown = JSON.parse("{}"); - | ^~~~ - 43 | } - json-dyn.ts:46:7 - error SC2007: values of type '{ (text: string, reviver?: ((this: any, key: string, value: any) => any) | undefined): any; (text: string): unknown; (text: string, reviver: (this: unknown, key: string, value: unknown) => unknown): unknown; }' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature 45 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) @@ -26,11 +19,4 @@ json-dyn.ts:46:18 - error SC1090: JSON methods as values (call 'parse' directly) 45 | const dynArray: unknown[] = []; // unknown[] IS the dyn array now — no fence (corpus 2585) 46 | const parseRef = JSON.parse; | ^~~~~~~~~~ - 47 | // The exact `string | undefined` root now lowers: strings serialize while - -json-dyn.ts:63:1 - error SC1090: constructing through a class value whose class has no lowering (the class declaration itself was rejected — see its own diagnostic) is not supported yet - - 62 | // them relevant; these references are what makes them count. - 63 | new Holder(); - | ^~~~~~~~~~~~ - 64 | // End with a nonblank context line so diagnostic snapshots carry no trailing whitespace. \ No newline at end of file + 47 | // The exact `string | undefined` root now lowers: strings serialize while \ No newline at end of file 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 diff --git a/tests/harness/__snapshots__/node-fallback-fence.ts.txt b/tests/harness/__snapshots__/node-fallback-fence.ts.txt index e588873fe..9670ca7d0 100644 --- a/tests/harness/__snapshots__/node-fallback-fence.ts.txt +++ b/tests/harness/__snapshots__/node-fallback-fence.ts.txt @@ -12,19 +12,10 @@ node-fallback-fence.ts:30:28 - error SC2020: 'timers/promises.setInterval with 3 29 | const raw = readFileSync("/etc/hosts").toString(tty ? "hex" : "latin1"); // runtime BufferEncoding selection lowers too 30 | const intervalWithSignal = promiseInterval(1, "tick", {}); // AbortSignal options stay fenced | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 31 | const sleepWithSignal = promiseSleep(1, "tick", { signal: AbortSignal.timeout(5) }); // resolve values lower; AbortSignal stays fenced + 31 | const sleepWithSignal = promiseSleep(1, "tick", { signal: AbortSignal.timeout(5) }); // resolve values and AbortSignal options lower hint: the lowered form is setInterval(delay, value) with an explicit yielded value; AbortSignal options are not supported yet -node-fallback-fence.ts:31:51 - error SC2020: 'timers/promises setTimeout with an AbortSignal' is part of the standard library types but has no scriptc lowering yet - - 30 | const intervalWithSignal = promiseInterval(1, "tick", {}); // AbortSignal options stay fenced - 31 | const sleepWithSignal = promiseSleep(1, "tick", { signal: AbortSignal.timeout(5) }); // resolve values lower; AbortSignal stays fenced - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 32 | // The computed encoding is now part of the static Buffer surface. The - - hint: a promise that ignored its cancellation signal would keep the process alive where Node exits - node-fallback-fence.ts:37:16 - error SC2020: 'zlib.brotliCompressSync' is part of the standard library types but has no scriptc lowering yet 36 | const packed = deflateSync("data", { level: 9, strategy: 1 }); // options lower diff --git a/tests/harness/__snapshots__/node-types-fenced.txt b/tests/harness/__snapshots__/node-types-fenced.txt index dca88df39..29e4a2ee9 100644 --- a/tests/harness/__snapshots__/node-types-fenced.txt +++ b/tests/harness/__snapshots__/node-types-fenced.txt @@ -48,22 +48,22 @@ fenced.ts:38:1 - error SC2020: 'fs.watchFile' is typed by @types/node but has no 37 | import { win32 } from "path"; 38 | watchFile("x", () => {}); | ^~~~~~~~~~~~~~~~~~~~~~~~ - 39 | setPriority(0); + 39 | on(new EventEmitter(), "tick"); hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations) -fenced.ts:39:1 - error SC2020: 'os.setPriority' is typed by @types/node but has no scriptc lowering yet +fenced.ts:39:1 - error SC2020: 'events.on' is typed by @types/node but has no scriptc lowering yet 38 | watchFile("x", () => {}); - 39 | setPriority(0); - | ^~~~~~~~~~~~~~ + 39 | on(new EventEmitter(), "tick"); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | console.log(win32.sep); hint: the type checker sees everything @types/node declares, but only the supported surface compiles (https://scriptc.dev/limitations) fenced.ts:40:13 - error SC2020: 'PlatformPath.sep' is typed by @types/node but has no scriptc lowering yet - 39 | setPriority(0); + 39 | on(new EventEmitter(), "tick"); 40 | console.log(win32.sep); | ^~~~~~~~~ 41 | /* URL getters and serialization lower under @types/node, but object-URL diff --git a/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt b/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt new file mode 100644 index 000000000..885665fc9 --- /dev/null +++ b/tests/harness/__snapshots__/npm-program-type-error/main.ts.txt @@ -0,0 +1,13 @@ +npm-program-type-error/main.ts:3:10 - error SC0001: Module '"sqlish"' has no exported member 'open'. + + 2 | // SC2013 refusals of the npm packages the program imports. + 3 | import { open } from "sqlish"; + | ^~~~ + 4 | import { route } from "routerish"; + +npm-program-type-error/main.ts:7:13 - error SC0001: Expected 2 arguments, but got 1. + + 6 | console.log(open(":memory:")); + 7 | console.log(route("/users")); + | ^~~~~ + 8 | \ No newline at end of file diff --git a/tests/harness/__snapshots__/regex-constructor-flags.ts.txt b/tests/harness/__snapshots__/regex-constructor-flags.ts.txt index 74aa9ab94..e3aaeb1d9 100644 --- a/tests/harness/__snapshots__/regex-constructor-flags.ts.txt +++ b/tests/harness/__snapshots__/regex-constructor-flags.ts.txt @@ -1,17 +1,8 @@ -regex-constructor-flags.ts:3:39 - error SC1120: the regex 'v' flag (unicode sets) is not supported yet +regex-constructor-flags.ts:3:35 - error SC1120: the regex 'd' flag (match indices) is not supported yet - 2 | // forms; computed flags reach the runtime, which names the same gap. - 3 | const sets = new RegExp("[\\w--\\d]", "v"); - | ^~~ - 4 | const indices = new RegExp("cat", "dg"); - - hint: supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates - -regex-constructor-flags.ts:4:35 - error SC1120: the regex 'd' flag (match indices) is not supported yet - - 3 | const sets = new RegExp("[\\w--\\d]", "v"); - 4 | const indices = new RegExp("cat", "dg"); + 2 | // computed flags reach the runtime, which names the same gap. + 3 | const indices = new RegExp("cat", "dg"); | ^~~~ - 5 | + 4 | - hint: supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates \ No newline at end of file + hint: supported: literal regexes with the g/i/m/s/u/v/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates \ No newline at end of file diff --git a/tests/harness/__snapshots__/regex.ts.txt b/tests/harness/__snapshots__/regex.ts.txt index 2db17d9c8..22bdbef90 100644 --- a/tests/harness/__snapshots__/regex.ts.txt +++ b/tests/harness/__snapshots__/regex.ts.txt @@ -3,31 +3,22 @@ regex.ts:4:17 - error SC1120: the regex 'd' flag (match indices) is not supporte 3 | // Method values and regex union arms remain unsupported; arrays compile. 4 | const indices = /cat/d; | ^~~~~~ - 5 | const sets = /[\p{L}]/v; + 5 | const asValue = /x/.test; - hint: supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates + hint: supported: literal regexes with the g/i/m/s/u/v/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates -regex.ts:5:14 - error SC1120: the regex 'v' flag (unicode sets) is not supported yet +regex.ts:5:17 - error SC1090: regex methods as values (call 'test' directly) are not supported yet 4 | const indices = /cat/d; - 5 | const sets = /[\p{L}]/v; - | ^~~~~~~~~~ - 6 | const asValue = /x/.test; - - hint: supported: literal regexes with the g/i/m/s/u/y flags — .test(), .exec()/.match()/.matchAll(), named capture groups (.groups, $ templates, \k), .source/.flags, and string replace/replaceAll/split with string replacement templates - -regex.ts:6:17 - error SC1090: regex methods as values (call 'test' directly) are not supported yet - - 5 | const sets = /[\p{L}]/v; - 6 | const asValue = /x/.test; + 5 | const asValue = /x/.test; | ^~~~~~~~ - 7 | const maybe: RegExp | undefined = /a/; + 6 | const maybe: RegExp | undefined = /a/; -regex.ts:10:22 - error SC2020: 'RegExpExecArray.groups' is part of the standard library types but has no scriptc lowering yet +regex.ts:9:22 - error SC2020: 'RegExpExecArray.groups' is part of the standard library types but has no scriptc lowering yet - 9 | const m = re.exec("2024-07"); - 10 | if (m) console.log(m.groups); + 8 | const m = re.exec("2024-07"); + 9 | if (m) console.log(m.groups); | ^~~~~~~~ - 11 | } + 10 | } hint: '.groups' lowers when the match's regex is STATICALLY known — a regex literal, or a const initialized with one (the group-name table is built at compile time) — and the read is not an optional-chain step: narrow the match instead (if (m) { m.groups } or m!.groups) \ No newline at end of file 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/__snapshots__/stdlib-fence.ts.txt b/tests/harness/__snapshots__/stdlib-fence.ts.txt index 77b0e05d8..337354eec 100644 --- a/tests/harness/__snapshots__/stdlib-fence.ts.txt +++ b/tests/harness/__snapshots__/stdlib-fence.ts.txt @@ -66,7 +66,7 @@ stdlib-fence.ts:25:1 - error SC2020: 'Date.prototype.setUTCFullYear with argumen | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 26 | const sameDate = mutableDate === mutableDate; // scalar storage must not fake object identity - hint: getTime(), valueOf(), toISOString(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering + hint: getTime(), valueOf(), toISOString(), toJSON(), the local/UTC calendar getters, and getTimezoneOffset() are supported; Date setters and locale/string formatters have no lowering stdlib-fence.ts:26:18 - error SC1043: comparing non-number, non-string values are not supported yet diff --git a/tests/harness/__snapshots__/utility-types.ts.txt b/tests/harness/__snapshots__/utility-types.ts.txt index 371215a65..71f4d3083 100644 --- a/tests/harness/__snapshots__/utility-types.ts.txt +++ b/tests/harness/__snapshots__/utility-types.ts.txt @@ -32,20 +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:20:13 - error SC1090: console.log of 'Date' values ('Date' values have no inspect lowering) are not supported yet - - 19 | const boot = dateBag["boot"]; - 20 | console.log(boot); - | ^~~~ - 21 | - -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 f87883acd..88b9415bd 100644 --- a/tests/harness/corpus-costs.json +++ b/tests/harness/corpus-costs.json @@ -1841,9 +1841,82 @@ "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, + "5803-worker-environment-data.ts": 2748, + "5804-worker-environment-data.cjs": 2469, + "5805-worker-receive-message-parent-port.ts": 2839, + "5806-worker-resource-limits.ts": 3153, + "5807-worker-error-values.ts": 3304, + "5808-worker-unhandled-rejections.ts": 3430, + "5809-process-exit-and-rejections.cjs": 3294, + "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, + "5831-util-types-brands.ts": 3347, + "5832-util-types-brands-dynamic.js": 3250, + "5833-url-search-params-iterators.ts": 2678, + "5834-url-search-params-iterators-dynamic.mjs": 2696, + "5835-util-format-integer-specifiers.ts": 2534, + "5836-url-search-params-tuple-pairs.ts": 2118, + "5837-util-format-json-circular.ts": 3117, + "5838-qs-stringify-bigint.ts": 2207, + "5841-json-typed-dyn-members.ts": 5467, + "5842-qs-parse-null-prototype.ts": 1774, + "5843-qs-parse-null-prototype-js.mjs": 1559, + "5844-date-unknown-values.ts": 3969, + "5857-regexp-unicode-sets.ts": 3350, + "5858-disposable-stack.ts": 3050, + "5859-disposable-stack-js.js": 3100, + "5870-worker-thread-names.ts": 3106, + "5871-worker-untransferable.cjs": 2786, + "5874-worker-stdio-streams.ts": 2839, + "5875-worker-stdout-pipe.cjs": 2740, + "5879-worker-stdin.ts": 3158, + "5881-inspect-weak-collections.ts": 2615, + "5882-string-decoder-runtime-encoding.ts": 2541, + "5883-os-set-priority.ts": 2380, + "5884-inspect-layout-options.ts": 3108, + "5886-inspect-dates.ts": 2559, + "5887-inspect-typed-arrays.ts": 3448, + "5888-inspect-sorted.ts": 2998, + "5900-float16-array.ts": 3000, + "5901-float16-array-js.js": 2900, + "5904-async-generator-completion.ts": 2950, + "5905-array-from-async-generator.ts": 2750, + "5906-array-from-async-generator-js.js": 2950, + "5910-set-prototype-of-primitive-object-kinds.ts": 2719, + "5911-dyn-class-to-primitive.ts": 2850, + "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, + "5942-local-class-observed-methods.ts": 5063, + "5950-tuple-predicate-methods.ts": 5990, + "5951-result-never-widening.ts": 4710, + "5955-any-promise-methods.js": 3940, + "5956-erased-generic-members.ts": 6730, + "5958-asserted-union-receiver.ts": 2430, + "5959-date-to-json.ts": 3330, + "5960-generic-mapped-types.ts": 2540, + "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, "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, @@ -2353,6 +2426,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, @@ -4465,9 +4539,82 @@ "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, + "5803-worker-environment-data.ts": 29298, + "5804-worker-environment-data.cjs": 26404, + "5805-worker-receive-message-parent-port.ts": 30268, + "5806-worker-resource-limits.ts": 29711, + "5807-worker-error-values.ts": 35248, + "5808-worker-unhandled-rejections.ts": 33209, + "5809-process-exit-and-rejections.cjs": 30261, + "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, + "5831-util-types-brands.ts": 4380, + "5832-util-types-brands-dynamic.js": 3658, + "5833-url-search-params-iterators.ts": 5339, + "5834-url-search-params-iterators-dynamic.mjs": 3797, + "5835-util-format-integer-specifiers.ts": 2768, + "5836-url-search-params-tuple-pairs.ts": 3193, + "5837-util-format-json-circular.ts": 3408, + "5838-qs-stringify-bigint.ts": 3519, + "5841-json-typed-dyn-members.ts": 3238, + "5842-qs-parse-null-prototype.ts": 7077, + "5843-qs-parse-null-prototype-js.mjs": 5395, + "5844-date-unknown-values.ts": 4543, + "5857-regexp-unicode-sets.ts": 3900, + "5858-disposable-stack.ts": 3750, + "5859-disposable-stack-js.js": 3600, + "5870-worker-thread-names.ts": 31697, + "5871-worker-untransferable.cjs": 27481, + "5874-worker-stdio-streams.ts": 27801, + "5875-worker-stdout-pipe.cjs": 31391, + "5879-worker-stdin.ts": 33232, + "5881-inspect-weak-collections.ts": 2915, + "5882-string-decoder-runtime-encoding.ts": 3854, + "5883-os-set-priority.ts": 3417, + "5884-inspect-layout-options.ts": 3596, + "5886-inspect-dates.ts": 2736, + "5887-inspect-typed-arrays.ts": 2997, + "5888-inspect-sorted.ts": 2861, + "5900-float16-array.ts": 3750, + "5901-float16-array-js.js": 3700, + "5904-async-generator-completion.ts": 3800, + "5905-array-from-async-generator.ts": 3500, + "5906-array-from-async-generator-js.js": 3750, + "5910-set-prototype-of-primitive-object-kinds.ts": 5033, + "5911-dyn-class-to-primitive.ts": 4732, + "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, + "5942-local-class-observed-methods.ts": 6212, + "5950-tuple-predicate-methods.ts": 7790, + "5951-result-never-widening.ts": 6120, + "5955-any-promise-methods.js": 5120, + "5956-erased-generic-members.ts": 8750, + "5958-asserted-union-receiver.ts": 3160, + "5959-date-to-json.ts": 4330, + "5960-generic-mapped-types.ts": 3300, + "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, "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, @@ -4977,6 +5124,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, diff --git a/tests/harness/differential.test.ts b/tests/harness/differential.test.ts index fbf9cb1ec..a2206fd97 100644 --- a/tests/harness/differential.test.ts +++ b/tests/harness/differential.test.ts @@ -22,6 +22,7 @@ import { describe, expect, test } from "vitest"; import ts5 from "typescript"; import { compile } from "@scriptc/compiler"; import { corpusSelect, shardSuffix } from "./shard.js"; +import { oracleEnvKey } from "./oracle-env.js"; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, "../.."); @@ -238,8 +239,8 @@ function programInputs(file: string): string[] { // Node's verdict for a corpus program is a pure function of the program bytes, // the shims, and the Node build (corpus stdout is deterministic by // construction — it must be identical to a non-Node native binary's output). So -// cache it, keyed by all of those plus the invocation shape (SCRIPTC_TEST_ENV -// and the cwd). Only the SPAWN is skipped: the native side always runs live +// cache it, keyed by all of those plus the invocation shape (the inherited +// environment, see oracle-env.ts, and the cwd). Only the SPAWN is skipped: the native side always runs live // and the comparison itself never changes. SCRIPTC_NO_CACHE=1 (or an unset // SCRIPTC_CACHE_DIR) disables the cache in both directions — no reads, no writes. // Storage shares the compile cache's root and its LRU sweep (see native-toolchain.ts). @@ -265,7 +266,7 @@ function oracleKeyBase(): Promise { .update(readFileSync(fileURLToPath(comptimeShim))) .update("\0") .update("\0") - .update(process.env["SCRIPTC_TEST_ENV"] ?? "") + .update(oracleEnvKey(process.env)) .update("\0") .update(process.cwd()) .update("\0") 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/fetch.test.ts b/tests/harness/fetch.test.ts index 4cc3bf86f..6b04acf59 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { globSync, mkdirSync, readFileSync } from "node:fs"; import { createServer as createHttpServer } from "node:http"; import { createServer as createHttpsServer } from "node:https"; +import { createServer as createNetServer, type AddressInfo } from "node:net"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; @@ -461,6 +462,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` NO_PROXY: "", }; const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); const authBefore = servers.proxyAuthorizations().length; const [nodeRes, nativeRes] = await Promise.all([ runBinary("node", [entry, ...argv], env), @@ -470,6 +472,8 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); expectCleanStderr(nativeRes, nodeRes); expect(servers.proxiedRequests() - before).toBe(2); + // Both lanes reach the http:// target through a CONNECT tunnel. + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ expectedProxyAuthorization, expectedProxyAuthorization, @@ -522,6 +526,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` NO_PROXY: "", }; const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); const authBefore = servers.proxyAuthorizations().length; const [nodeRes, nativeRes] = await Promise.all([ runBinary("node", [entry, baseUrl], env), @@ -531,6 +536,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); expectCleanStderr(nativeRes, nodeRes); expect(servers.proxiedRequests() - before).toBe(2); + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ expectedProxyAuthorization, expectedProxyAuthorization, @@ -593,6 +599,244 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` } }, 120_000); + /** An HTTPS target on localhost that counts the requests it serves, + * with the localhost certificate or another fixture certificate. */ + async function secureTarget(cert = "localhost"): Promise<{ + url: string; + requests: () => number; + close: () => Promise; + }> { + const certs = join(fixturesRoot, "../server/certs"); + let requests = 0; + const server = createHttpsServer( + { + key: readFileSync(join(certs, `${cert}-key.pem`)), + cert: readFileSync(join(certs, `${cert}.pem`)), + }, + (request, response) => { + requests++; + response.end(`secure ${request.url} ${request.headers.host}`); + }, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `https://localhost:${port}`, + requests: () => requests, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; + } + + test.for(["llvm"] as const)( + "%s fetch tunnels HTTPS through an http_proxy with CONNECT", + async () => { + const target = await secureTarget(); + try { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-connect"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: authenticatedProxyUrl, + HTTPS_PROXY: authenticatedProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const before = servers.proxiedRequests(); + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toContain("200 secure /text localhost:"); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expectCleanStderr(nativeRes, nodeRes); + expect(servers.proxiedRequests() - before).toBe(2); + expect(target.requests()).toBe(2); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ + expectedProxyAuthorization, + expectedProxyAuthorization, + ]); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch verifies the target host through the CONNECT tunnel", + async () => { + // The alt certificate names alt.localhost only. + const target = await secureTarget("alt"); + try { + const entry = join(fixturesRoot, "static-network-error/main.mts"); + const binary = await build(entry, "proxy-connect-verify"); + const url = target.url; + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: proxyUrl, + HTTPS_PROXY: proxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, url], env), + runBinary(binary, [url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toBe("TypeError fetch failed true\n"); + expect(target.requests()).toBe(0); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch sends proxy credentials only with a username and a password", + async () => { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-user-only"); + const userOnly = proxyUrl.replace("http://", "http://proxy-user@"); + const env = { + ...process.env, + NODE_USE_ENV_PROXY: "1", + http_proxy: userOnly, + HTTP_PROXY: userOnly, + no_proxy: "", + NO_PROXY: "", + }; + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, baseUrl], env), + runBinary(binary, [baseUrl], env), + ]); + expect(nativeRes.stdout.equals(nodeRes.stdout)).toBe(true); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual(["", ""]); + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch failures through a proxy carry Node's cause", + async () => { + // Answers every CONNECT with one fixed status line. + const statusProxy = createNetServer((socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => statusProxy.listen(0, "127.0.0.1", resolve)); + const statusProxyUrl = `http://127.0.0.1:${(statusProxy.address() as AddressInfo).port}`; + try { + const entry = join(fixturesRoot, "static-proxy-error/main.mts"); + const binary = await build(entry, "proxy-error-cause"); + for (const proxy of [statusProxyUrl, refusedUrl]) { + const env = { + ...process.env, + NODE_USE_ENV_PROXY: "1", + https_proxy: proxy, + HTTPS_PROXY: proxy, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, "https://localhost/"], env), + runBinary(binary, ["https://localhost/"], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + } + } finally { + await new Promise((resolve) => statusProxy.close(() => resolve())); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch tunnels HTTPS through an HTTPS proxy with CONNECT", + async () => { + const target = await secureTarget(); + try { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-https-https"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: secureAuthenticatedProxyUrl, + HTTPS_PROXY: secureAuthenticatedProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toContain("200 secure /text localhost:"); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expectCleanStderr(nativeRes, nodeRes); + expect(servers.proxiedRequests() - before).toBe(2); + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); + expect(target.requests()).toBe(2); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ + expectedProxyAuthorization, + expectedProxyAuthorization, + ]); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch verifies the target host through an HTTPS proxy tunnel", + async () => { + // The alt certificate names alt.localhost only. + const target = await secureTarget("alt"); + try { + const entry = join(fixturesRoot, "static-network-error/main.mts"); + const binary = await build(entry, "proxy-https-verify"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: secureProxyUrl, + HTTPS_PROXY: secureProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toBe("TypeError fetch failed true\n"); + expect(target.requests()).toBe(0); + } finally { + await target.close(); + } + }, + 120_000, + ); + test.for(["llvm"] as const)( "%s fetch rejects an invalid opted-in proxy instead of dialing directly", async () => { diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 3825cb3f4..0eb0cf2c3 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -299,6 +299,51 @@ wrap empty: len 2 bytes 60 62 expect(run.stdout).toBe(BUFFERS_EXPECTED); }); + /* ── cstring: NUL-terminated text in, out, and through callbacks ─────── */ + + test("cstring parameters, results, and callback arguments", async () => { + const { archive, outDir } = await buildLibrary("cstrings", emission); + const probe = buildProbe("cstrings", archive, outDir); + const run = runProbe(probe, ["ok"]); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe(`register: 0 +greet: hello, w\u00f6rld! (strlen 14) +length ascii: 3 +length utf8: 4 +length malformed: 3 +length empty: 0 +log: QUIET (strlen 5, n 5) +echo: 1 +done +`); + }); + + test.each([ + ["nullarg", "scriptc: library inbound cstring parameter is NULL\n", "SC4012", "kc_length"], + [ + "nulresult", + "scriptc: TypeError: a cstring value contains U+0000, which a C string cannot hold\n", + "SC4015", + "kc_with_nul", + ], + [ + "nularg", + "scriptc: TypeError: a cstring value contains U+0000, which a C string cannot hold\n", + "SC4015", + "kc_echo_nul", + ], + ])("cstring %s traps through the sink", async (mode, text, code, symbol) => { + const { archive, outDir } = await buildLibrary("cstrings", emission); + const probe = buildProbe("cstrings", archive, outDir); + const run = runProbe(probe, [mode]); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe( + `register: 0\ntext=[${text}]\ncode=[${code}]\nsymbol=[${symbol}]\ntrapped\n`, + ); + }); + /* ── K4: init re-run determinism ─────────────────────────────────────── */ const SESSION = `session start counter=0 diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index fffa88cc3..fc243ccb5 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -160,7 +160,56 @@ describe("library profile validation", () => { }); test("parse error", () => expectSc4001("{ not json", "not valid JSON")); - test("wrong format", () => expectSc4001({ ...good, profile_format: 2 }, "profile_format")); + test("wrong format", () => expectSc4001({ ...good, profile_format: 3 }, "profile_format")); + test("the cstring class requires profile_format 2", () => { + const cstringExport = { + export: "greet", + symbol: "kx_greet", + params: ["cstring"], + returns: "cstring", + }; + const v2 = loadLibraryProfile( + writeProfile({ ...good, profile_format: 2, exports: [...good.exports, cstringExport] }), + ); + expect(v2.ok).toBe(true); + if (v2.ok) { + expect(v2.profile.profileFormat).toBe(2); + expect(v2.profile.exports.at(-1)).toMatchObject({ params: ["cstring"], returns: "cstring" }); + } + expectSc4001( + { ...good, exports: [...good.exports, cstringExport] }, + "'exports[2].params[0]' class 'cstring' requires profile_format 2", + ); + expectSc4001( + { ...good, exports: [...good.exports, { ...cstringExport, params: [] }] }, + "'exports[2].returns' class 'cstring' requires profile_format 2", + ); + const callbackAbi = { ...good.abi, callback_register_symbol: "kx_set_callback" }; + const cstringCallback = { name: "log", params: ["cstring"], returns: "void" }; + expect( + loadLibraryProfile( + writeProfile({ + ...good, + profile_format: 2, + abi: callbackAbi, + callbacks: [cstringCallback], + }), + ).ok, + ).toBe(true); + expectSc4001( + { ...good, abi: callbackAbi, callbacks: [cstringCallback] }, + "'callbacks[0].params[0]' class 'cstring' requires profile_format 2", + ); + expectSc4001( + { + ...good, + profile_format: 2, + abi: callbackAbi, + callbacks: [{ name: "log", params: [], returns: "cstring" }], + }, + "a callback return stays scalar", + ); + }); test("missing name", () => { const { name: _drop, ...rest } = good; expectSc4001(rest, "'name'"); 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 { 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); +}); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 8de3886c8..89bd2e0b8 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -592,6 +592,66 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { }, ); + test.each(["release", "dev"] as const)( + "static methods a factory class defines build subclasses of their receiver (%s)", + async (optimization) => { + const entry = join(pilotRoot, "class-factory-cli.ts"); + const outDir = join(cacheDir, `class-factory-${optimization}-${sanitize ? "san" : "plain"}`); + const result = await compile(entry, { + backend: "llvm", + npmStatic: ["class-factory"], + sanitize, + optimization, + outDir, + outPath: join(outDir, "program"), + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const [reference, native] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(result.binaryPath, []), + ]); + expect(reference.exitCode).toBe(0); + expect(native.stdout).toEqual(reference.stdout); + expect(comparableStderr(native.stderr)).toEqual(reference.stderr); + expect(native.exitCode).toBe(reference.exitCode); + }, + ); + + test.each(["release", "dev"] as const)( + "a factory that destructures its base class from an options object builds static subclasses (%s)", + async (optimization) => { + const entry = join(pilotRoot, "class-options-cli.ts"); + const outDir = join(cacheDir, `class-options-${optimization}-${sanitize ? "san" : "plain"}`); + const result = await compile(entry, { + backend: "llvm", + npmStatic: ["class-options"], + sanitize, + optimization, + outDir, + outPath: join(outDir, "program"), + emitIr: true, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const [reference, native] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(result.binaryPath, []), + ]); + expect(reference.exitCode).toBe(0); + expect(native.stdout).toEqual(reference.stdout); + expect(comparableStderr(native.stderr)).toEqual(reference.stderr); + expect(native.exitCode).toBe(reference.exitCode); + // One makeClass instance per distinct base (Root, Animal, Dog) plus + // the generic one a class's own `extend` passing itself back reuses. + const ir = JSON.parse(readFileSync(result.irPath!, "utf8")) as { + functions: { name: string }[]; + }; + const instances = ir.functions.filter((fn) => /^%[\w.]*makeClass%\d+$/.test(fn.name)); + expect(instances.length).toBeLessThanOrEqual(4); + }, + ); + test.for([ ["escape-string-regexp", "escape-cli.ts"], ["slash", "slash-cli.ts"], @@ -687,17 +747,23 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(nativeRes.exitCode).toBe(nodeRes.exitCode); }, 180_000); - test("picocolors inherited valueOf retains the prototype-method fence", () => { + test("picocolors inherited valueOf matches Node", async () => { const entry = join(pilotRoot, "colors-prototype-cli.ts"); const { coverage } = analyze(entry, { npmStatic: ["picocolors"] }); expect(coverage.npmStatic).toEqual([{ package: "picocolors", status: "static" }]); expect(coverage.preflightFailed).toBe(false); + expect(coverage.diagnostics).toHaveLength(0); expect(coverage.runtimeFences ?? []).toHaveLength(0); - expect(coverage.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(["SC2020"]); - expect(coverage.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ - expect.stringContaining(".valueOf' is part of the standard library types"), + + const binary = await buildStatic(entry, ["picocolors"]); + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry]), + runBinary(binary, []), ]); - }, 120_000); + expect(nativeRes.stdout).toEqual(nodeRes.stdout); + expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr); + expect(nativeRes.exitCode).toBe(nodeRes.exitCode); + }, 180_000); test("picocolors inherited hasOwnProperty matches Node", async () => { const entry = join(pilotRoot, "colors-hasown-cli.ts"); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 51cff197c..a6cdee2b5 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -105,10 +105,6 @@ const KNOWN: Record = { "promise-bridge": { build: "SC1090" }, "self-name": { build: "SC2013" }, "stream-shims": { build: "SC1090" }, - "typed-callbacks": { - divergence: - "crashes converting a cyclic object into a recursive record type; a follow-up fix is in progress", - }, "util-shims": { run: "SC2020" }, "web-streams": { build: "SC2013" }, "workspace-copied": { build: "SC2013" }, diff --git a/tests/harness/oracle-env.test.ts b/tests/harness/oracle-env.test.ts new file mode 100644 index 000000000..da09dde25 --- /dev/null +++ b/tests/harness/oracle-env.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { oracleEnvKey } from "./oracle-env.js"; + +test("the oracle key changes with any inherited variable", () => { + const base = { PATH: "/bin", SCRIPTC_TEST_ENV: "from-harness" }; + expect(oracleEnvKey({ ...base, NODE_ENV: "production" })).not.toBe( + oracleEnvKey({ ...base, NODE_ENV: "test" }), + ); + expect(oracleEnvKey({ ...base, NODE_ENV: "test" })).not.toBe(oracleEnvKey(base)); + expect(oracleEnvKey({ ...base, FOO: "a=b" })).not.toBe( + oracleEnvKey({ ...base, FOO: "a", B: "" }), + ); +}); + +test("the oracle key ignores the runner's worker numbers and variable order", () => { + expect(oracleEnvKey({ A: "1", B: "2", VITEST_WORKER_ID: "1", VITEST_POOL_ID: "1" })).toBe( + oracleEnvKey({ + VITEST_POOL_ID: "7", + B: "2", + TINYPOOL_WORKER_ID: "3", + A: "1", + VITEST_WORKER_ID: "9", + }), + ); +}); diff --git a/tests/harness/oracle-env.ts b/tests/harness/oracle-env.ts new file mode 100644 index 000000000..5f66f26bb --- /dev/null +++ b/tests/harness/oracle-env.ts @@ -0,0 +1,15 @@ +/* The environment part of the differential oracle cache key. The Node + * oracle runs with the harness's inherited environment, so a program whose + * output reads a variable (NODE_ENV, SCRIPTC_TEST_ENV, ...) must not reuse a + * verdict recorded under a different value. The variables that number the + * test runner's own workers differ between workers of one run and are left + * out, so every worker shares the cache. */ +const WORKER_VARIABLES = new Set(["VITEST_POOL_ID", "VITEST_WORKER_ID", "TINYPOOL_WORKER_ID"]); + +export function oracleEnvKey(env: NodeJS.ProcessEnv): string { + return Object.keys(env) + .filter((name) => !WORKER_VARIABLES.has(name) && env[name] !== undefined) + .sort() + .map((name) => `${name}=${env[name]}\0`) + .join(""); +} diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index 9d7bc4c12..bbd6045e5 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -200,6 +200,24 @@ test("node-types: socket.address and resetAndDestroy under @types/node", async ( expect(comparableStderr(native.stderr)).toBe(node.stderr); }); +test("node-types: net.SocketAddress and net.BlockList under @types/node", async () => { + const outDir = outDirFor("net-blocklist"); + const entry = join(nodeTypesDir, "net-blocklist.ts"); + const result = await compile(entry, { + outPath: join(outDir, process.platform === "win32" ? "net-blocklist.exe" : "net-blocklist"), + outDir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const [native, node] = await Promise.all([ + execFileAsync(result.binaryPath), + execFileAsync(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", entry]), + ]); + expect(native.stdout).toBe(node.stdout); + expect(comparableStderr(native.stderr)).toBe(node.stderr); +}); + test("node-types: timers/promises resolve values and setInterval iteration under @types/node", async () => { const outDir = outDirFor("timers-promises"); const entry = join(nodeTypesDir, "timers-promises.ts"); diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 64227e7a8..722c3fede 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -359,6 +359,28 @@ test( { mode: 0o755 }, ); const output = join(directory, "three.wasm"); + const missing = spawnSync( + probe, + ["build", "--lib", "--profile", threeProfile, "-o", output], + { + ...nativeOptions, + cwd: directory, + env: { + ...nativeOptions.env, + SCRIPTC_TARGET: "wasm32-wasi", + SCRIPTC_LINKER: linker, + SCRIPTC_RUNTIME_PACK: undefined, + SCRIPTC_NO_CACHE: "1", + }, + }, + ); + expect(missing.status).toBe(1); + expect(missing.stderr.toString()).toContain( + "error SC3003: runtime pack for wasm32-wasi is not installed; install @scriptc/runtime-wasm32-wasi@", + ); + expect(missing.stderr.toString()).toContain( + "hint: scriptc includes the runtime pack for its own platform only", + ); // Install the cross-target pack after relocating the native compiler. // Its resolver must discover project dependencies without Node on PATH. const scope = join(directory, "node_modules/@scriptc"); diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts index 178ed2191..0cb55f690 100644 --- a/tests/harness/wasi-differential.test.ts +++ b/tests/harness/wasi-differential.test.ts @@ -277,8 +277,11 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => { "5087-promise-allsettled-any-dyn.js", "5088-promise-combinator-thenables.js", "2018-generators-uncaught.ts", + "5839-timers-promises-scheduler.ts", "2019-generators-loops.ts", "2093-timers-promises.ts", + "5830-timers-promises-signal.ts", + "5889-timers-scheduler-signal.ts", "2683-fs-rename-js.cjs", "2906-bigint-wasi-width.ts", "4900-bigint-division-cross-check.ts", diff --git a/tests/harness/wasm-library.test.ts b/tests/harness/wasm-library.test.ts index 8659c03d9..d0bccdc3d 100644 --- a/tests/harness/wasm-library.test.ts +++ b/tests/harness/wasm-library.test.ts @@ -272,6 +272,85 @@ describe.skipIf(!hasZig).each(["node", "native"] as const)( }, ); + test("cstring parameters, results, and callback arguments use NUL-terminated pointers", async () => { + const entry = join(directory, "cstring-lib.ts"); + await writeFile( + entry, + [ + "declare function log(text: string): void;", + "export function greet(name: string): string {", + " log(name.toUpperCase());", + " return `hello, ${name}!`;", + "}", + "export function withNul(): string {", + ' return "a\\u0000b";', + "}", + "", + ].join("\n"), + ); + const profilePath = join(directory, "cstring.json"); + await writeFile( + profilePath, + JSON.stringify({ + profile_format: 2, + name: "wasm-cstring", + entry, + emission: "llvm", + abi: { + prefix: "cs_", + init_symbol: "cs_init", + sink_register_symbol: "cs_sink", + callback_register_symbol: "cs_callback", + collect_symbol: null, + result_reset_symbol: null, + }, + callbacks: [{ name: "log", params: ["cstring"], returns: "void" }], + exports: [ + { export: "greet", symbol: "cs_greet", params: ["cstring"], returns: "cstring" }, + { export: "withNul", symbol: "cs_with_nul", params: [], returns: "cstring" }, + ], + }), + ); + const result = await compileLibrary({ profilePath, outDir: directory }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + const wasi = new WASI({ version: "preview1" }); + let api: Api; + const logs: string[] = []; + const panics: string[] = []; + const cString = (ptr: number): string => { + const memory = new Uint8Array(api.memory.buffer); + let end = ptr; + while (memory[end] !== 0) end++; + return new TextDecoder().decode(memory.subarray(ptr, end)); + }; + const instance = new WebAssembly.Instance( + new WebAssembly.Module(await readFile(result.archivePath)), + { + ...wasi.getImportObject(), + scriptc: { + log: (ptr: number) => logs.push(cString(ptr)), + panic: (ptr: number, len: number) => { + const message = new TextDecoder().decode(new Uint8Array(api.memory.buffer, ptr, len)); + panics.push(message); + throw new Error(message); + }, + }, + }, + ); + api = instance.exports as Api; + wasi.initialize(instance); + api.cs_init(); + const input = new TextEncoder().encode("w\u00f6rld\0"); + const ptr = api.scriptc_alloc(input.length); + new Uint8Array(api.memory.buffer, ptr, input.length).set(input); + expect(cString(api.cs_greet(ptr))).toBe("hello, w\u00f6rld!"); + expect(logs).toEqual(["W\u00d6RLD"]); + api.scriptc_free(ptr); + expect(() => api.cs_with_nul()).toThrow(); + expect(panics).toHaveLength(1); + expect(panics[0]).toContain("a cstring value contains U+0000"); + }); + test("refuses collisions with built-in Wasm exports and imports", async () => { for (const change of [ { diff --git a/tests/library-mode/cstrings/lib.ts b/tests/library-mode/cstrings/lib.ts new file mode 100644 index 000000000..1016beb90 --- /dev/null +++ b/tests/library-mode/cstrings/lib.ts @@ -0,0 +1,26 @@ +// cstring conformance fixture: NUL-terminated text in both directions and +// through a callback channel. `withNul` and `echoNul` produce text holding +// U+0000, which a C string cannot carry. +declare function log(text: string, n: number): void; + +export function greet(name: string): string { + return `hello, ${name}!`; +} + +export function length(text: string): number { + return text.length; +} + +export function echo(text: string): number { + log(text.toUpperCase(), text.length); + return 1; +} + +export function withNul(): string { + return "a\u0000b"; +} + +export function echoNul(): number { + log("x\u0000y", 3); + return 1; +} diff --git a/tests/library-mode/cstrings/probe.c b/tests/library-mode/cstrings/probe.c new file mode 100644 index 000000000..ba802f84a --- /dev/null +++ b/tests/library-mode/cstrings/probe.c @@ -0,0 +1,76 @@ +/* cstring probe, mode-selected by argv[1]: + * ok — cstring parameters copy in (UTF-8, U+FFFD for malformed + * bytes), cstring results are NUL-terminated arena copies, + * and a cstring callback argument arrives NUL-terminated + * nullarg — a NULL cstring parameter is the SC4012 host-contract trap + * nulresult — a result holding U+0000 traps instead of truncating + * nularg — a callback argument holding U+0000 traps the same way + */ +#include +#include +#include +#include + +extern void kc_init(void); +extern void kc_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx); +extern void kc_collect(void); +extern int32_t kc_set_callback(const char *name, void (*fn)(void), void *ctx); +extern const char *kc_greet(const char *name); +extern double kc_length(const char *text); +extern double kc_echo(const char *text); +extern const char *kc_with_nul(void); +extern double kc_echo_nul(void); + +static jmp_buf trap_jmp; + +static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) { + (void)ctx; (void)addr; + /* structured: 0x01 text 0x1F code 0x1F symbol */ + const uint8_t *p = msg + 1, *end = msg + len; + const char *names[3] = {"text", "code", "symbol"}; + for (int field = 0; field < 3 && p <= end; field++) { + const uint8_t *sep = memchr(p, 0x1f, (size_t)(end - p)); + const uint8_t *stop = sep != NULL ? sep : end; + printf("%s=[%.*s]\n", names[field], (int)(stop - p), (const char *)p); + if (sep == NULL) break; + p = sep + 1; + } + longjmp(trap_jmp, 1); +} + +static void on_log(void *ctx, const char *text, double n) { + (void)ctx; + printf("log: %s (strlen %zu, n %.0f)\n", text, strlen(text), n); +} + +int main(int argc, char **argv) { + const char *mode = argc > 1 ? argv[1] : "ok"; + kc_set_panic_sink(sink, NULL); + kc_init(); + printf("register: %d\n", (int)kc_set_callback("log", (void (*)(void))on_log, NULL)); + if (setjmp(trap_jmp) != 0) { + printf("trapped\n"); + return 0; + } + if (strcmp(mode, "ok") == 0) { + const char *g = kc_greet("w\xC3\xB6rld"); + printf("greet: %s (strlen %zu)\n", g, strlen(g)); + printf("length ascii: %.0f\n", kc_length("abc")); + printf("length utf8: %.0f\n", kc_length("caf\xC3\xA9")); + printf("length malformed: %.0f\n", kc_length("a\xFF" "b")); + printf("length empty: %.0f\n", kc_length("")); + printf("echo: %.0f\n", kc_echo("quiet")); + kc_collect(); + printf("done\n"); + } else if (strcmp(mode, "nullarg") == 0) { + kc_length(NULL); + printf("UNREACHABLE\n"); + } else if (strcmp(mode, "nulresult") == 0) { + kc_with_nul(); + printf("UNREACHABLE\n"); + } else if (strcmp(mode, "nularg") == 0) { + kc_echo_nul(); + printf("UNREACHABLE\n"); + } + return 0; +} diff --git a/tests/library-mode/cstrings/profile.json b/tests/library-mode/cstrings/profile.json new file mode 100644 index 000000000..c5a36e424 --- /dev/null +++ b/tests/library-mode/cstrings/profile.json @@ -0,0 +1,22 @@ +{ + "profile_format": 2, + "name": "conformance-cstrings", + "entry": "lib.ts", + "emission": "llvm", + "abi": { + "prefix": "kc_", + "init_symbol": "kc_init", + "sink_register_symbol": "kc_set_panic_sink", + "collect_symbol": "kc_collect", + "result_reset_symbol": null, + "callback_register_symbol": "kc_set_callback" + }, + "callbacks": [{ "name": "log", "params": ["cstring", "f64"], "returns": "void" }], + "exports": [ + { "export": "greet", "symbol": "kc_greet", "params": ["cstring"], "returns": "cstring" }, + { "export": "length", "symbol": "kc_length", "params": ["cstring"], "returns": "f64" }, + { "export": "echo", "symbol": "kc_echo", "params": ["cstring"], "returns": "f64" }, + { "export": "withNul", "symbol": "kc_with_nul", "params": [], "returns": "cstring" }, + { "export": "echoNul", "symbol": "kc_echo_nul", "params": [], "returns": "f64" } + ] +}