diff --git a/.gitignore b/.gitignore index 0662b29a1..5c86cabbf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules/ !tests/corpus/*/node_modules/ !tests/fixtures/npm-static/node_modules/ !tests/coverage-fixtures/wasm/node_modules/ +!tests/coverage-fixtures/dynamic-tier/package/node_modules/ !tests/fixtures/npm-static/nested/inner/node_modules/ !tests/fixtures/npm-static/node_modules/*/node_modules/ !tests/fixtures/npm/store/*/node_modules/ diff --git a/RELEASING.md b/RELEASING.md index 9746f4f09..55ed7fae2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,6 +16,8 @@ CI (`.github/workflows/release.yml`) compares the version in `packages/cli/packa The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI bundles its TypeScript checker, LLVM helper, runtime pack, declarations, and comptime evaluator. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds. +Release builds of the native CLI optimize the compiler with the committed profile `packages/compiler/native/cli.profdata` on every platform. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. Refresh the profile on a quiet Linux x64 host when compiler code has changed substantially since the last refresh: `node scripts/refresh-cli-profile.mjs --tsc-ts ` builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Commit the new profile on its own. + Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. Each published package must have a GitHub Actions trusted publisher for `release.yml` and the `Release` environment. A missing configuration fails diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 9b6e88628..8c57d2e2a 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -109,13 +109,13 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
Primary artifact path. An explicit path is exact. Defaults are .scriptc/<name>.ir.json, .ll, .s, .o, or the platform executable name.
--emit <ir|llvm|asm|obj|exe>
-
Select the primary artifact: serialized typed IR (ir), textual LLVM IR (llvm), assembly (asm), a relocatable object (obj), or an executable (exe, the default). Every kind except exe is produced without an external compiler, linker, or SDK.
+
Select the primary artifact: serialized typed IR (ir), textual LLVM IR (llvm), assembly (asm), a relocatable object (obj), or an executable (exe, the default). Every kind except exe is produced without an external compiler, linker, or SDK. Assembly and object output is compiled as one module; only executables are divided into partitions that compile in parallel (see Build parallelism).
--print <native-link-info|diagnostics>
native-link-info: build an object (equivalent to --emit=obj) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. diagnostics: for build and coverage, print the versioned JSON diagnostics document described in Coverage Reports instead of the human output.
-
--fail-on <blockers|divergences>
-
Coverage only. Exit with status 1 when the entry has blockers that would fail a build, or with divergences, also when it has Node divergence warnings. See Gating builds and CI.
+
--fail-on <blockers|divergences|dynamic>
+
Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, or with dynamic, also when it has the SC6101 warning for untyped code that likely runs often. See Gating builds and CI.
--ffi <file>
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
@@ -127,7 +127,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.
--optimization <release|dev|speed>
-
Select native optimization for executable, assembly, or object output. The default is release (-O2), which emits string retains and the retains and releases of dynamic values inline and lets LLVM inline small runtime functions that keep no internal runtime state into executable code; programs large enough to be compiled in parallel partitions skip that inlining to keep build time and size in check. dev uses -O0 and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see Native debugging for the supported representations. macOS executable builds also produce an adjacent .dSYM bundle. speed is -O2 plus optimizations that trade binary size and build time for run-time speed: it lets LLVM inline more small runtime functions into program code, emits the remaining reference counting inline, and on x86-64 starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than release; how much faster a program runs depends on how much of its time is spent in the runtime. Runtime inlining needs a runtime pack built with the LLVM helper; where a runtime pack lacks it, programs link the same runtime without it and speed links the release runtime and keeps inline reference counting. Selecting speed never changes what release builds produce, and the two never share cached artifacts. Library profiles accept "optimization": "speed" for inline reference counting.
+
Select native optimization for executable, assembly, or object output. The default is release (-O2), which emits string retains and the retains and releases of dynamic values inline. dev uses -O0 and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see Native debugging for the supported representations. macOS executable builds also produce an adjacent .dSYM bundle. speed is -O2 plus optimizations that trade binary size and build time for run-time speed: it emits the remaining reference counting inline and, on x86-64, starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than release; how much faster a program runs depends on how much of its time is spent in the runtime. Where a runtime pack has no speed runtime, speed links the release runtime and keeps inline reference counting. Selecting speed never changes what release builds produce, and the two never share cached artifacts. Library profiles accept "optimization": "speed" for inline reference counting. In executables large enough to be divided into partitions, dev compiles each partition on its own, and release and speed add a step that lets partitions import small functions from each other; see Build parallelism.
--windows-subsystem <console|gui>
Select the subsystem of a Windows executable. console is the default; gui prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.
@@ -139,7 +139,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where %p expands to the process ID; the LLVM_PROFILE_FILE environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with --optimization=release or speed.
--profile-use <file.profdata>
-
Optimize the executable with a profile from scriptc merge-profiles: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. Valid only for executable builds with --optimization=release or speed.
+
Optimize the executable with a profile from scriptc merge-profiles: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. Valid only for executable builds with --optimization=release or speed.
--npm-static <pkg[,pkg…]|auto>
Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing ..., as in fs-extra..., also names every package that package depends on. auto uses the default automatic selection. Packages that can't compile produce diagnostics. See npm Dependencies.
@@ -156,6 +156,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--explain-dynamic[=<name>]
Coverage only. After the report, list the functions that already operate on values typed only at runtime and the bindings, fields, functions, classes and modules that unsupported constructs would move to the dynamic tier, each with the diagnostic behind it. With a name such as x, Point, or Point.x, show only the matching units. See Dynamic tier.
+
--no-dynamic-tier-warning
+
Build and coverage. Leave out SC6101, the warning that names functions that likely run often and use untyped values. Not allowed with --fail-on=dynamic. See Untyped code that likely runs often.
+
--wasm <file.wasm>
Coverage only. Report a WebAssembly module the program loads from a path its sources do not spell out. The report already lists modules whose paths the compiler resolves, modules inlined as base64 literals, and .wasm files shipped by packages that use WebAssembly; a scriptc.wasm array in package.json adds files the same way as this flag. Each module is listed with its size, whether it validates, and the WebAssembly proposals it uses. Builds translate the modules they find to native code; a build reads the scriptc.wasm array but not this flag. Repeatable.
@@ -182,7 +185,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
SCRIPTC_CACHE_DIR
-
Override the persistent build-cache root. By default it is $XDG_CACHE_HOME/scriptc/build when set, $HOME/Library/Caches/scriptc/build on macOS, %LOCALAPPDATA%\scriptc\cache\build on Windows, and $HOME/.cache/scriptc/build elsewhere. Cached frontend results and LLVM artifacts are verified against their source, helper, target, and compilation settings. Native outputs also depend on the selected runtime pack and linker inputs. FFI objects, archives, and system libraries relink against their current dependencies. Sanitizer development builds additionally cache instrumented runtime objects and verify external compiler and header inputs. An existing POSIX override must already be private; otherwise caching is bypassed without changing the directory's permissions.
+
Override the persistent build-cache root. By default it is $XDG_CACHE_HOME/scriptc/build when set, $HOME/Library/Caches/scriptc/build on macOS, %LOCALAPPDATA%\scriptc\cache\build on Windows, and $HOME/.cache/scriptc/build elsewhere. Cached frontend results and LLVM artifacts, including the compiled partitions of large executables, are verified against their source, helper, target, and compilation settings. Native outputs also depend on the selected runtime pack and linker inputs. FFI objects, archives, and system libraries relink against their current dependencies. Sanitizer development builds additionally cache instrumented runtime objects and verify external compiler and header inputs. An existing POSIX override must already be private; otherwise caching is bypassed without changing the directory's permissions.
SCRIPTC_NO_CACHE
Set to 1 to bypass all build-cache reads and writes. An explicitly empty SCRIPTC_CACHE_DIR has the same effect.
@@ -223,7 +226,7 @@ $ scriptc merge-profiles -o app.profdata profiles/*.profraw $ scriptc build app.ts --optimization=speed --profile-use app.profdata ``` -Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. +Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time. ## LLVM backend diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index 1bb645bd9..d2fc345ad 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -62,6 +62,25 @@ Code that operates on values whose types are known only at runtime, such as `any `--tier=static-only` keeps every unsupported construct a blocker instead of moving it to the dynamic tier; code that already operates on runtime-typed values compiles as before. Today `auto`, the default, behaves the same way. +### Untyped code that likely runs often + +When functions of the program's own files use untyped values and likely run often, `scriptc build` and coverage report them as an `SC6101` warning. For the Benchmarks Game n-body program in plain JavaScript: + +```console +$ scriptc build nbody.js -o nbody +warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code: + advance (nbody.js:22): untyped JavaScript file, in a loop + energy (nbody.js:40): untyped JavaScript file, in a loop + offsetMomentum (nbody.js:12): untyped JavaScript file, in a loop + hint: add types to compile these statically; see scriptc coverage --explain-dynamic + +The program compiled. Adding types to these functions lets scriptc compile them to faster code. +``` + +Without a profile, a dynamic-tier function counts as likely to run often when a dynamic operation runs on every iteration of one of its loops (a `for` loop's initializer and a `for...of` loop's iterable run once), when a loop calls it, or when it calls itself. A function is named only when its source says why its values are untyped: a value typed `any` (for a `for...of` variable, the value it iterates), an untyped `JSON.parse` result, or an untyped JavaScript file. Dynamic code that runs once, such as setup code that reads an `any` configuration, is not reported, and neither are functions that are dynamic for an operation with no static lowering, which more types would not change. + +The warning names at most five functions, most likely to run often first, and gives the total. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it. + ## Diagnostic codes Coverage and `scriptc build` report the same diagnostic codes. Common ones include: @@ -88,7 +107,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che $ scriptc coverage src/main.ts --fail-on=blockers ``` -`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences. A program that doesn't type-check always exits with status 1. +`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1. Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off. @@ -174,7 +193,7 @@ For editors, CI annotations, and other tools, `scriptc coverage --print=diagnost category - invalid-source (the program has a TypeScript or JSON error), unsupported (valid code scriptc cannot compile yet), divergence (compiles, but can behave differently from Node.js), environment (project configuration, scriptc's type world, toolchain, or target), or internal (a compiler bug). + invalid-source (the program has a TypeScript or JSON error), unsupported (valid code scriptc cannot compile yet), divergence (compiles, but can behave differently from Node.js), performance (compiles and behaves as under Node.js, but runs slower than typed code would), environment (project configuration, scriptc's type world, toolchain, or target), or internal (a compiler bug). severity diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index 5c6d7be63..f1b64948d 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -27,11 +27,70 @@ 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. 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. +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; see [Build parallelism](#build-parallelism). 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. `comptime` calls run during the frontend stage in an isolated evaluator inside the compiler, based on QuickJS. Their results are embedded in the program as constants. +## Build parallelism + +Most of a build runs on one CPU core. The LLVM helper uses more cores only when it compiles an executable in partitions. + +### Frontend + +Parsing, type checking, lowering, and LLVM IR generation run one after another on one thread. The type checker runs in a separate process that the compiler waits on. When none of a build's inputs has changed since an earlier build, the frontend's results come from the build cache and this stage is skipped. A change to any input runs the whole stage again. + +### Partitions + +The LLVM helper divides an executable's LLVM IR into partitions by its size: one partition per 4 MiB of LLVM text, rounded up to a power of two, and at most 64. `--emit=llvm` writes this text, so its file size shows how many partitions a program gets. + + + + + + + + + + + + + + +
LLVM textPartitions
Up to 4 MiB1
Over 4 MiB, up to 8 MiB2
Over 8 MiB, up to 16 MiB4
Over 16 MiB, up to 32 MiB8
Over 32 MiB, up to 64 MiB16
Over 64 MiB, up to 128 MiB32
Over 128 MiB64
+ +The count depends only on the size of the LLVM text, so a program has the same partitions, and builds the same executable, on every host. A function's partition follows from its name, and a small internal function goes with the one function that calls it, so an edit leaves the other functions in their partitions. + +These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler. + +The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with two partitions uses at most two cores in the backend. A program of one partition is optimized and compiled on one thread. + +How partitions are compiled depends on `--optimization`: + +- `dev` compiles each partition on its own, all in parallel. +- `release` and `speed` optimize each partition and write a summary of its functions, all in parallel. Then a serial step reads every summary and decides which small functions each partition imports from the others, so that calls between partitions can still be inlined. Last, the partitions are optimized with their imports and compiled, again in parallel. + +Reading the LLVM text and dividing it into partitions, and the import step, run on one thread. + +### Rebuilds + +With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition. + +A program of one partition has no partition cache: a rebuild after an edit compiles the whole program again, and a rebuild that leaves the LLVM text unchanged takes the compiled code from the build cache. + +When the LLVM text grows past a partition boundary (4, 8, 16 MiB, and so on), the partition count doubles, functions move to new partitions, and the next build compiles every partition again. + +### Profile-guided optimization + +Building with a profile adds these costs (see [Profile-guided optimization](/docs/cli#profile-guided-optimization)): + +- `--profile-generate` builds an instrumented executable, which runs slower while it records the profile. +- The profile's contents are part of every partition's cache key. A new or changed profile compiles every partition again; builds without a profile keep their own cache entries. +- With a profile, the import step lets a hot call import a function of up to twice the usual size limit. LLVM's default limits for profiled calls are 10 and 100 times as large, and on large programs they tripled the build's time and memory without making the program measurably faster. +- The profile does not change which partition a function is in. + +Collecting a profile takes an instrumented build, the training runs, and the merge. With a committed profile, later builds skip those steps and are one optimized build each. + ## Inspect compiler output The following examples use this program: diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx index 05f9d329a..5be38a924 100644 --- a/docs/content/docs/limitations.mdx +++ b/docs/content/docs/limitations.mdx @@ -82,6 +82,8 @@ title: "Limitations" ../limitations/builtin-apis/filesystem-flags.mdx +../limitations/builtin-apis/filesystem-url-paths.mdx + ../limitations/builtin-apis/copy.mdx ../limitations/builtin-apis/glob.mdx diff --git a/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx new file mode 100644 index 000000000..d5079ed5a --- /dev/null +++ b/docs/content/limitations/builtin-apis/filesystem-url-paths.mdx @@ -0,0 +1,3 @@ +- **Filesystem URL paths:** Synchronous `fs` functions accept `file:` URL paths and convert them as `fileURLToPath` does. + `existsSync` and the `fs.promises` functions refuse URL paths; pass `fileURLToPath(url)` instead. + A URL path after a string path, or with options that are not literals, is refused, because Node checks those arguments before it converts the URL. diff --git a/docs/content/limitations/native-worker-threads.mdx b/docs/content/limitations/native-worker-threads.mdx index e45d12ad3..5bd259114 100644 --- a/docs/content/limitations/native-worker-threads.mdx +++ b/docs/content/limitations/native-worker-threads.mdx @@ -18,6 +18,7 @@ An unhandled rejection whose reason is not error-like arrives as an `UnhandledPr 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. +A worker's messages to its parent, and the parent's messages in the worker, also arrive as one batch before `setImmediate` callbacks, each followed by its microtasks, as in Node: the messages queued when delivery starts, and those that arrive meanwhile until the batch holds 1000. `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()`. diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index ed717c35e..66b0ba3dd 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -68082,9 +68082,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" } }, { @@ -68289,9 +68289,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-internal.json b/internal/compatibility/generated/node-v24-internal.json index bbf177d52..c6a7a102c 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -95364,7 +95364,9 @@ "tests/corpus/2331-tls-server-options-typed.ts", "tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2557-tls-ca-store.ts", - "tests/corpus/2596-net-arg-ladders.cjs" + "tests/corpus/2596-net-arg-ladders.cjs", + "tests/corpus/6721-tls-alpn-protocols.ts", + "tests/corpus/6722-tls-min-max-version.ts" ] }, "anchorSource": "chapter" @@ -96365,8 +96367,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-feature:tls.tlsSocket.getProtocol", + "tests": [ + "tests/corpus/6722-tls-min-max-version.ts" + ] }, "anchorSource": "exact" }, @@ -96572,8 +96577,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:tls" + "status": "partial", + "evidence": "compiler-feature:tls.alpnProtocol", + "tests": [ + "tests/corpus/6721-tls-alpn-protocols.ts" + ] }, "anchorSource": "ancestor" }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 1f713630b..23c7a159b 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -365,6 +365,8 @@ { "chapter": "stream", "signatures": ["`writable` Type: {boolean}"], "status": "partial", "evidence": ["tests/corpus/2893-child-stdin-duplex.ts", "tests/corpus/2895-child-stdin-write-after-end.ts"] }, { "chapter": "stream", "signatures": ["Event: `'drain'`"], "status": "partial", "evidence": ["tests/corpus/2894-child-stdin-backpressure.ts"] }, { "chapter": "console", "signatures": ["Class: `Console`"], "status": "partial", "evidence": ["tests/corpus/4900-console-instances.ts", "tests/corpus/4901-console-instance-errors.ts"] }, + { "chapter": "tls", "signatures": ["`alpnProtocol` Type: {string|boolean|null}"], "status": "partial", "evidence": ["tests/corpus/6721-tls-alpn-protocols.ts"] }, + { "chapter": "tls", "signatures": ["`tlsSocket.getProtocol()`"], "status": "partial", "evidence": ["tests/corpus/6722-tls-min-max-version.ts"] }, { "chapter": "webstreams", "signatures": ["`readableStream.values([options])`"], "status": "partial", "evidence": ["tests/corpus/2862-web-readable-async-iteration.ts"] }, { "chapter": "webstreams", "signatures": ["`streamConsumers.text(stream)`", "`streamConsumers.json(stream)`", "`streamConsumers.buffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/2629-stream-consumers.ts", "tests/corpus/2630-stream-consumers-errors.ts"] }, { "chapter": "webstreams", "signatures": ["`streamConsumers.arrayBuffer(stream)`"], "status": "partial", "evidence": ["tests/corpus/4875-stream-consumers-array-buffer.ts"] }, diff --git a/native/llvm-codegen/README.md b/native/llvm-codegen/README.md index 8debaeeaf..0e8abbc47 100644 --- a/native/llvm-codegen/README.md +++ b/native/llvm-codegen/README.md @@ -39,7 +39,7 @@ WebAssembly code generation differs from LLVM's default pipeline in two ways tha `--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. +`--import-bitcode `, given once per runtime-pack unit that ships bitcode, imports runtime functions for inlining; scriptc's program builds do not pass it. 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. Runtime-pack builds emit each unit of the executable `speed` flavor through the helper: diff --git a/package.json b/package.json index 88709a137..d46e0b0fc 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:test262": "tsx tests/test262/run.ts", "test:test262:fetch": "node tests/test262/fetch.mjs", "test:cache-identity": "node tests/harness/cache-identity.mjs", + "test:lowering-order": "node scripts/lowering-order.mjs --fail-on content", "test:sandbox": "node scripts/sandbox-test.mjs", "test:sandbox:image": "node scripts/sandbox-image.mjs", "test:watch": "vitest", diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 7847e60ed..9eb49f823 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -163,6 +163,10 @@ async function tryFastPath(): Promise { if (hit === null) return null; if (!values["keep-llvm"]) await rm(hit.llvmPath, { force: true }); if (command === "build") { + // A cached build repeats its performance warning (SC6101). + const performance = hit.performance ?? []; + if (performance.length > 0 && values["dynamic-tier-warning"] !== false) + process.stderr.write(startup.renderPerformanceWarnings(performance) + "\n"); process.stdout.write(`${outPath}\n`); return 0; } @@ -220,6 +224,7 @@ async function coverageFastPath( const hit = await startup.readCoverageVerdict(resolve(inputArg), {}).catch(() => null); if (hit === null) return null; const { coverage, sourceTexts } = hit; + if (values["dynamic-tier-warning"] === false) delete coverage.performance; if (print === "diagnostics") { const envelope = startup.coverageEnvelope(coverage, { compilerVersion: packageVersion(), diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 99d0f78e3..8203b959e 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { analyzeCached, + collectGarbageUnderHeapPressure, compile, compileLibrary, mergeProfiles, @@ -26,6 +27,9 @@ function version(): string { return manifest.version ?? "unknown"; } +// The compiler process collects its heap as lowering and LLVM emission grow it. +collectGarbageUnderHeapPressure(); + process.exitCode = await runCli(process.argv.slice(2), { version, analyze: analyzeCached, diff --git a/packages/cli/test/dynamic-tier-warning.test.ts b/packages/cli/test/dynamic-tier-warning.test.ts new file mode 100644 index 000000000..67de774d4 --- /dev/null +++ b/packages/cli/test/dynamic-tier-warning.test.ts @@ -0,0 +1,120 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const repoRoot = join(import.meta.dirname, "../../.."); +const cliEntry = join(repoRoot, "packages/cli/src/main.ts"); +const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); +const fixtures = join(repoRoot, "tests/coverage-fixtures/dynamic-tier"); + +const HEADER = + "warning SC6101: 3 functions that likely run often use untyped values, which runs slower than typed code:"; +const CLOSING = + "The program compiled. Adding types to these functions lets scriptc compile them to faster code."; + +async function cli( + args: string[], + cacheRoot: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const { stdout, stderr } = await execFileAsync( + process.execPath, + ["--import", tsxLoader, cliEntry, ...args], + { + env: { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot, SCRIPTC_TIMING: "1" }, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return { code: 0, stdout, stderr }; + } catch (err) { + const e = err as { code?: number; stdout?: string; stderr?: string }; + return { code: e.code ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +test("a build names its likely-hot untyped functions, also when it comes from the cache", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-warning-")); + const cacheRoot = join(dir, "cache"); + const outPath = join(dir, process.platform === "win32" ? "program.exe" : "program"); + try { + await mkdir(cacheRoot, { mode: 0o700 }); + const build = (extra: string[] = []) => + cli(["build", join(fixtures, "hot.js"), "-o", outPath, ...extra], cacheRoot); + + const first = await build(); + expect(first.code).toBe(0); + expect(first.stderr).toContain("scriptc lowering"); + expect(first.stderr).toContain( + [ + HEADER, + " centroid (hot.js:7): untyped JavaScript file, in a loop", + " spread (hot.js:23): untyped JavaScript file, in a loop", + " distance (hot.js:17): untyped JavaScript file, called in a loop", + " hint: add types to compile these statically; see scriptc coverage --explain-dynamic", + "", + CLOSING, + ].join("\n"), + ); + + // The cache hit skips lowering and repeats the warning. + const cached = await build(); + expect(cached.code).toBe(0); + expect(cached.stderr).not.toContain("scriptc lowering"); + expect(cached.stderr).toContain(HEADER); + expect(cached.stderr).toContain(CLOSING); + + const silenced = await build(["--no-dynamic-tier-warning"]); + expect(silenced.code).toBe(0); + expect(silenced.stderr).not.toContain("SC6101"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 300_000); + +test("untyped setup code builds without the warning", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-quiet-")); + try { + await mkdir(join(dir, "cache"), { mode: 0o700 }); + const result = await cli( + ["build", join(fixtures, "quiet.ts"), "-o", join(dir, "program")], + join(dir, "cache"), + ); + expect(result.code).toBe(0); + expect(result.stderr).not.toContain("SC6101"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 300_000); + +test("coverage --fail-on=dynamic fails on the warning, and --no-dynamic-tier-warning cannot hide it", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-dynamic-tier-fail-on-")); + try { + await mkdir(join(dir, "cache"), { mode: 0o700 }); + const coverage = (file: string, extra: string[]) => + cli(["coverage", join(fixtures, file), ...extra], join(dir, "cache")); + + const hot = await coverage("hot.js", ["--fail-on=dynamic"]); + expect(hot.code).toBe(1); + expect(hot.stdout).toContain("untyped and likely hot"); + expect((await coverage("hot.js", ["--fail-on=divergences"])).code).toBe(0); + expect((await coverage("quiet.ts", ["--fail-on=dynamic"])).code).toBe(0); + + const silenced = await coverage("hot.js", ["--no-dynamic-tier-warning"]); + expect(silenced.code).toBe(0); + expect(silenced.stdout).not.toContain("untyped and likely hot"); + + const conflict = await coverage("hot.js", ["--fail-on=dynamic", "--no-dynamic-tier-warning"]); + expect(conflict.code).toBe(1); + expect(conflict.stderr).toContain( + "--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out", + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 300_000); diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 329f066fc..dbb3cd8fd 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1379,13 +1379,13 @@ interface ImportMeta { * `recursive`); empty directories go through `rmdirSync`. */ declare module "node:fs" { import { Readable, Writable } from "stream"; - export function readFileSync(path: string | number, encoding: BufferEncoding): string; + export function readFileSync(path: string | number | URL, encoding: BufferEncoding): string; export function readFileSync( - path: string | number, + path: string | number | URL, options: { encoding: BufferEncoding }, ): string; - export function readFileSync(path: string | number): Buffer; - export function readFileSync(path: string, options: unknown): string | Buffer; + export function readFileSync(path: string | number | URL): Buffer; + export function readFileSync(path: string | URL, options: unknown): string | Buffer; /* Error-first callbacks select string or Buffer data from their encoding. * Unknown options retain that union until the caller validates the value. */ export function readFile( @@ -1397,7 +1397,7 @@ declare module "node:fs" { * Node — an existing file keeps its permissions) and/or the utf8 * encoding spelling. */ export function writeFileSync( - path: string, + path: string | URL, data: string, /* The options-record stance (see http.RequestOptions): flag fences * by name at the call site; undocumented keys drop like Node. */ @@ -1409,14 +1409,17 @@ declare module "node:fs" { }, ): void; /* The bare-encoding spelling — the options record's encoding key alone. */ - export function writeFileSync(path: string, data: string, encoding: "utf8" | "utf-8"): void; - export function writeFileSync(path: string, data: string | Uint8Array): void; - export function appendFileSync(path: string, data: string | Uint8Array): void; + export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void; + export function writeFileSync(path: string | URL, data: string | Uint8Array): void; + export function appendFileSync(path: string | URL, data: string | Uint8Array): void; export function existsSync(path: string): boolean; - export function mkdirSync(path: string): void; - export function mkdirSync(path: string, options: { recursive?: boolean; mode?: number }): void; - export function unlinkSync(path: string): void; - export function chmodSync(path: string, mode: number): void; + export function mkdirSync(path: string | URL): void; + export function mkdirSync( + path: string | URL, + options: { recursive?: boolean; mode?: number }, + ): void; + export function unlinkSync(path: string | URL): void; + export function chmodSync(path: string | URL, mode: number): void; export function fchmodSync(fd: number, mode: number): void; export function fsyncSync(fd: number): void; export function linkSync( @@ -1514,29 +1517,29 @@ declare module "node:fs" { path: string | Uint8Array | URL, options: string | { encoding?: string | null } | null, ): string | Buffer; - export function chownSync(path: string, uid: number, gid: number): void; - export function lchownSync(path: string, uid: number, gid: number): void; + export function chownSync(path: string | URL, uid: number, gid: number): void; + export function lchownSync(path: string | URL, uid: number, gid: number): void; export function fchownSync(fd: number, uid: number, gid: number): void; - export function truncateSync(path: string, len?: number): void; + export function truncateSync(path: string | URL, len?: number): void; /* The 2-argument form only (Node's mode flags have no lowering). The * destination is created or truncated carrying the SOURCE's mode. */ - export function copyFileSync(src: string, dest: string): void; + export function copyFileSync(src: string | URL, dest: string | URL): void; export function rename( oldPath: string, newPath: string, callback: (err: NodeJS.ErrnoException | null) => void, ): void; - export function renameSync(oldPath: string, newPath: string): void; - export function rmSync(path: string): void; + export function renameSync(oldPath: string | URL, newPath: string | URL): void; + export function rmSync(path: string | URL): void; /* maxRetries/retryDelay are accepted no-ops (Node retries around * EBUSY-class races; the synchronous lowering has no re-entrancy that * needs them). */ export function rmSync( - path: string, + path: string | URL, options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number }, ): void; - export function rmdirSync(path: string): void; - export function readdirSync(path: string): string[]; + export function rmdirSync(path: string | URL): void; + export function readdirSync(path: string | URL): string[]; /* The withFileTypes form: Dirent rows (name + parentPath + the type * probes — the honest subset of Node's Dirent surface). The type is * interned explicitly in type-mapper.ts (the record's hidden %dtype field @@ -1550,7 +1553,7 @@ declare module "node:fs" { isSymbolicLink(): boolean; } export function readdirSync( - path: string, + path: string | URL, /* The options-record stance (see http.RequestOptions): recursive * fences by name at the call site; undocumented keys drop like Node. */ options: { withFileTypes: true; recursive?: boolean; [option: string]: unknown }, @@ -1599,7 +1602,7 @@ declare module "node:fs" { encoding?: "utf8" | "utf-8"; bufferSize?: number; } - export function opendirSync(path: string, options?: OpenDirOptions): Dir; + export function opendirSync(path: string | URL, options?: OpenDirOptions): Dir; /* fs.ReadStream / fs.WriteStream: runtime Readable and Writable streams * over a file (scr_fs_stream.c). The options are checked at runtime * with Node's rules; flags and mode are checked when the file opens, so @@ -1641,8 +1644,8 @@ declare module "node:fs" { path: string | Buffer | URL, options?: BufferEncoding | WriteStreamOptions, ): WriteStream; - export function accessSync(path: string, mode?: number): void; - export function mkdtempSync(prefix: string): string; + export function accessSync(path: string | URL, mode?: number): void; + export function mkdtempSync(prefix: string | URL): string; export const constants: { readonly F_OK: number; readonly R_OK: number; @@ -1745,34 +1748,34 @@ declare module "node:fs" { bigint?: boolean; throwIfNoEntry?: boolean; } - export function statSync(path: string, options?: undefined): Stats; + export function statSync(path: string | URL, options?: undefined): Stats; export function statSync( - path: string, + path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; export function statSync( - path: string, + path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function statSync(path: string, options: StatSyncOptions & { bigint: true }): Stats; - export function statSync(path: string, options: StatSyncOptions): Stats | undefined; - export function lstatSync(path: string, options?: undefined): Stats; + export function statSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function statSync(path: string | URL, options: StatSyncOptions): Stats | undefined; + export function lstatSync(path: string | URL, options?: undefined): Stats; export function lstatSync( - path: string, + path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry: false }, ): Stats | undefined; export function lstatSync( - path: string, + path: string | URL, options: StatSyncOptions & { bigint?: false; throwIfNoEntry?: true }, ): Stats; /* bigint: true type-checks so the lowering can name it as unsupported. */ - export function lstatSync(path: string, options: StatSyncOptions & { bigint: true }): Stats; - export function lstatSync(path: string, options: StatSyncOptions): Stats | undefined; + export function lstatSync(path: string | URL, options: StatSyncOptions & { bigint: true }): Stats; + export function lstatSync(path: string | URL, options: StatSyncOptions): Stats | undefined; /* The fd pair behind spawn's fd-stdio form: openSync(path, flags) → * the raw fd. Numeric flags use an inline OR of the O_* constants. */ - export function openSync(path: string, flags: string): number; - export function openSync(path: string, flags: number, mode?: number): number; + export function openSync(path: string | URL, flags: string): number; + export function openSync(path: string | URL, flags: number, mode?: number): number; export function closeSync(fd: number): void; export function fdatasyncSync(fd: number): void; export function ftruncateSync(fd: number, len?: number): void; @@ -1853,9 +1856,9 @@ declare module "node:fs" { export function fstatSync(fd: number): Stats; /* realpath(3) — resolves symlinks, `.`/`..`, throwing Node's fs error * shapes for missing paths. */ - export function realpathSync(path: string): string; + export function realpathSync(path: string | URL): string; export namespace realpathSync { - function native(path: string): string; + function native(path: string | URL): string; } /* statfs(2)/statvfs(3) — the filesystem-capacity snapshot (the fields * Node's statfsSync reports; bavail × bsize is the free-space probe). */ diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz new file mode 100644 index 000000000..55523e765 Binary files /dev/null and b/packages/compiler/native/cli.profdata.gz differ diff --git a/packages/compiler/src/backend/llvm/array-search.ts b/packages/compiler/src/backend/llvm/array-search.ts index 74d79f7be..d61d91e5c 100644 --- a/packages/compiler/src/backend/llvm/array-search.ts +++ b/packages/compiler/src/backend/llvm/array-search.ts @@ -23,6 +23,7 @@ * is little-endian, which the short-string words assume. */ import type { IrType } from "../../ir/ir.js"; +import { SCR_ARR_FIELD } from "./common.js"; import { FN_ATTRS } from "./shapes.js"; export type ArraySearchKind = "ptr" | "str"; @@ -72,7 +73,7 @@ export function emitArraySearchHelpers( ` %dense = icmp eq ${sz} %f, %len`, ` br i1 %dense, label %scan, label %slow`, `scan:`, - ` %dp = getelementptr inbounds %ScrArr, ptr %a, i32 0, i32 8`, + ` %dp = getelementptr inbounds %ScrArr, ptr %a, i32 0, i32 ${SCR_ARR_FIELD.data}`, ` %data = load ptr, ptr %dp`, ); if (kind === "ptr") { diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index e6fc56f6f..822ee29e2 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -21,9 +21,7 @@ export const SCR_ARR_FIELD = { cyc: 4, spare: 5, filled: 6, - ops: 7, - data: 8, - ext: 9, + data: 7, } as const; /** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index 4e818eb31..ba3aea21d 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -1,4 +1,5 @@ /* LlEmitter's module-level output: module parts, helper definitions, library declarations and FFI callback definitions. */ +import { relieveHeapPressure } from "../../heap-pressure.js"; import { emitWasmModules } from "./wasm-modules.js"; import { type LlEmitter, ffiCallbackDummyLl, llStrBytes } from "./emitter.js"; import { JS_IC_WORDS } from "./js-ic.js"; @@ -66,6 +67,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { const def = emitter.emitFunction(fn); fnDefs.push(def); emitter.fnDefText.set(fn.name, def); + relieveHeapPressure(); } emitter.selectStacklessAsync(fnDefs); const errorViews: string[] = []; @@ -450,15 +452,17 @@ export function emitModuleParts(emitter: LlEmitter): string[] { `%ScrFfiTable = type { ptr, ${emitter.sizeType}, ${emitter.sizeType}, ptr, i8, ptr, ptr, ${emitter.sizeType}, ${emitter.sizeType}, ${emitter.sizeType}, ptr, ptr }`, `%ScrRegex = type { ${emitter.sizeType}, ptr, ptr, ptr, double, ptr, ptr, ptr }`, // ScrArr mirrors scr_runtime.h field-for-field: { rc, len (i32), cap - // (i32), elem, cyc, spare, filled, ops, data, ext } (field numbers in + // (i32), elem, cyc, spare, filled, data } (field numbers in // SCR_ARR_FIELD), where the dense states follow the cap value slots at - // data, filled bounds the hole-free prefix, ops points at the element type's constant RC descriptor - // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse, - // property and metadata storage (NULL until first used). Live dynamic - // stream commits swap its dense, presence and out-of-line storage while + // data, filled bounds the hole-free prefix, and spare holds the storage + // flags and the registry id of the element type's RC descriptor + // (%ScrArrOps, SCR_ELEM_REF only; the runtime assigns ids). An array + // with out-of-line sparse, property or metadata storage keeps a pointer + // to it in the word before its dense allocation. Live dynamic stream + // commits swap its dense, presence and out-of-line storage while // preserving the target object's identity. - `%ScrArr = type { ${emitter.sizeType}, i32, i32, i8, i8, i16, i32, ptr, ptr, ptr }`, - `%ScrArrOps = type { ptr, ptr, ptr }`, + `%ScrArr = type { ${emitter.sizeType}, i32, i32, i8, i8, i16, i32, ptr }`, + `%ScrArrOps = type { ptr, ptr, ptr, i32 }`, // The ScrMap prefix { rc, key_kind, val_kind, traced } — the inline // RC fast paths test the traced byte (shapes.ts). ...(emitter.rcHelpers === null ? [] : [`%ScrMapRc = type { ${emitter.sizeType}, i8, i8, i8 }`]), @@ -720,7 +724,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] { wide ? `ptr ${s}` : `<{ ptr, i32 }> <{ ptr ${s}, i32 0 }>`; out.push( `@${inst.sym}_data = internal constant <{ [${n} x ${slot}], [${n} x i8] }> <{ [${n} x ${slot}] [ ${inst.slots.map(slotValue).join(", ")} ], [${n} x i8] ${present} }>, align 8`, - `@${inst.sym} = internal global %ScrArr { ${emitter.sizeType} -1, i32 ${n}, i32 ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr null, ptr @${inst.sym}_data, ptr null }`, + `@${inst.sym} = internal global %ScrArr { ${emitter.sizeType} -1, i32 ${n}, i32 ${n}, i8 2, i8 0, i16 0, i32 ${n}, ptr @${inst.sym}_data }`, ); } if (emitter.templateStringsInstances.size > 0) out.push(``); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 05e297304..4504ae58e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1,3 +1,4 @@ +import { relieveHeapPressure } from "../../heap-pressure.js"; import { wasmBoundsChecks } from "./wasm-modules.js"; import { jsTruthy } from "./jsval.js"; import { JsIcSites } from "../../ir/js-ic.js"; @@ -458,6 +459,8 @@ export function emitLlvmModuleSource( input: IrModule, options: LlvmTargetOptions = {}, ): string | readonly string[] { + // IR validation, contracts and the frontend's disposal run after lowering without checks. + relieveHeapPressure(true); const mod = unshareNodes(input); const parts = new LlEmitter( options.debugSources === undefined @@ -2493,8 +2496,14 @@ export class LlEmitter { * releases the dummy (NULL for refcounted kinds) harmlessly. A scalar * call result of the function's return type may be passed as the unwind's * dummy value (see emitUnwind). `poll` marks a worker termination poll - * (function entry or loop budget), whose test must stay ordered. */ - emitPendingCheck(passThrough?: { name: string; type: IrType }, poll = false): void { + * (function entry or loop budget), whose test must stay ordered. A + * `guard` (an i1) limits the test to the paths where it is true: false + * where the joined call could not have left anything pending. */ + emitPendingCheck( + passThrough?: { name: string; type: IrType }, + poll = false, + guard?: string, + ): void { const through = passThrough !== undefined && this.llType(passThrough.type) === this.llType(this.currentReturnType) && @@ -2503,6 +2512,13 @@ export class LlEmitter { : undefined; const B = this.B; if (B.isTerminated()) return; + let skip: string | undefined; + if (guard !== undefined) { + const test = B.newLabel("exc.t"); + skip = B.newLabel("exc.n"); + B.condBr(guard, test, skip); + B.startBlock(test); + } if (this.mod.lib !== undefined) { this.declare(`declare zeroext i1 @scr_exc_pending()`); const p = B.tmp(); @@ -2513,6 +2529,10 @@ export class LlEmitter { B.startBlock(lu); this.emitUnwind(through); B.startBlock(lk); + if (skip !== undefined) { + B.br(skip); + B.startBlock(skip); + } return; } // Executables test the active exception cell's kind inline (its first @@ -2585,6 +2605,10 @@ export class LlEmitter { B.startBlock(lu); this.emitUnwind(through); B.startBlock(lk); + if (skip !== undefined) { + B.br(skip); + B.startBlock(skip); + } } /** After a runtime call that ALWAYS leaves an exception pending (the diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index d02c43c32..81c44d5a6 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -921,9 +921,18 @@ export function emitCallExpr( ? `@${mangleBorrowedFunction(known!.name)}` : `@${mangleBorrowingWrapper(known!.name)}` : undefined; + // A known target outside the may-throw set leaves nothing pending, so + // only the entry through the closure's own pointer needs the test. + // Worker programs keep it on every path: there it also observes + // termination, which loops count on (callTestsPending). + const quiet = + known !== undefined && !host.mayThrow.has(known.name) && host.mod.workers !== true; + let pending = host.indirectMayThrow; + let pendingGuard: string | undefined; let t: string; if (direct && e.callee.kind === "closure") { t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!)); + if (quiet) pending = false; } else { const fnp = B.tmp(), fn = B.tmp(); @@ -983,17 +992,21 @@ export function emitCallExpr( B.line( `${t} = phi ${host.llType(e.type)} [ ${specialized}, %${fastEnd} ], [ ${indirect}, %${fallbackEnd} ]`, ); + if (quiet && pending) { + pendingGuard = B.tmp(); + B.line(`${pendingGuard} = phi i1 [ false, %${fastEnd} ], [ true, %${fallbackEnd} ]`); + } } } if (e.type.kind === "void") { - if (host.indirectMayThrow) host.emitPendingCheck(); + if (pending) host.emitPendingCheck(undefined, false, pendingGuard); if (lent) host.releaseTemps(host.frames.pop()!); return { name: "", type: e.type }; } // The check runs AFTER the result temp joins the frame: an unwind // releases it (the dummy is NULL for refcounted returns). const out = host.own({ name: t, type: e.type }); - if (host.indirectMayThrow) host.emitPendingCheck(); + if (pending) host.emitPendingCheck(undefined, false, pendingGuard); if (lent) { host.toFrame(out); host.releaseTemps(host.frames.pop()!); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index bf8d502a1..8970aa3be 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -45,6 +45,8 @@ import { emitBorrowedInput } from "./borrowed-inputs.js"; import { emitFusedMapSet, rememberMapEntry } from "./map-entry-fusion.js"; import { emitScratchStringSlice, emitStringSliceRead } from "./string-slices.js"; import { + STRING_CHAR_AT_DECL, + STRING_CHAR_AT_INT_MARKER, STRING_CHAR_CODE_AT_DECL, STRING_CHAR_CODE_AT_INT_MARKER, STRING_UTF16_LEN_DECL, @@ -325,14 +327,21 @@ export function emitStrIntrinsic( B.line(`${t} = call double @sc_str_char_code_at(ptr ${r.name}, double ${args[0]!.name})`); return { name: t, type: e.type }; } - case "charAt": - return call( - "scr_str_char_at", - "ptr (ptr, double)", - `ptr ${r.name}, double ${args[0]!.name}`, - "ptr", - true, - ); + case "charAt": { + // sc_str_char_at (string-reads.ts) answers proven-ASCII strings inline. + host.declare(STRING_CHAR_AT_DECL); + const t = B.tmp(); + const index = e.args[0] ? exactInteger(host, args[0]!, e.args[0]) : null; + if (index) { + host.declare(STRING_CHAR_AT_INT_MARKER); + const wide = widenInteger(host, index); + B.line(`${t} = call ptr @sc_str_char_at_int(ptr ${r.name}, i64 ${wide})`); + } else { + const at = args[0]?.name ?? f64Lit(0); + B.line(`${t} = call ptr @sc_str_char_at(ptr ${r.name}, double ${at})`); + } + return host.own({ name: t, type: e.type }); + } case "indexOf": return call( "scr_str_index_of", @@ -638,10 +647,12 @@ export function emitArrIntrinsic( return { name: t, type: e.type }; } case "concatSpread": { + // Appends to concat's fresh result: its storage grows to exactly + // the combined length (scr_arr_concat_copy_exact). const src = emitBorrowedInput(host, e.args[0]!); - host.declare(`declare double @scr_arr_concat_copy(ptr, ptr)`); + host.declare(`declare double @scr_arr_concat_copy_exact(ptr, ptr)`); const t = B.tmp(); - B.line(`${t} = call double @scr_arr_concat_copy(ptr ${r.name}, ptr ${src.name})`); + B.line(`${t} = call double @scr_arr_concat_copy_exact(ptr ${r.name}, ptr ${src.name})`); return { name: t, type: e.type }; } case "unshift": { diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 69f4def2e..d6a33ceeb 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -524,8 +524,15 @@ export function emitOperatorExpr( case "seqExpr": { // Statements mid-expression: each emits in place (its own frame, // exactly statement position); the result is an ordinary temp of - // the current frame. No jump can leave the expression's region. - for (const s of e.stmts) host.emitStmt(s); + // the current frame. No jump can leave the expression's region. A + // statement can still end the block (a call that always throws): + // what follows then emits in a block with no predecessors, so every + // value it reads is defined there, and LLVM deletes it. + for (const s of e.stmts) { + if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead")); + host.emitStmt(s); + } + if (host.B.isTerminated()) host.B.startBlock(host.B.newLabel("seq.dead")); return host.emitExpr(e.result); } default: { @@ -855,11 +862,15 @@ export function emitContainerExpr( const elem = e.type.elem; const arr = B.tmp(); const small = host.appendedArrayLiterals.has(e); - const capacity = small ? 0 : (e.capacity ?? e.elems.length); + const spreadSet = new Set(e.spreads ?? []); + // With spreads, storage starts at the plain elements before the first + // spread, and each spread reserves exactly its source's length plus + // the plain elements up to the next one (scr_arr_reserve_spread). + const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i)); + const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread)); B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); const out = host.own({ name: arr, type: e.type }); const acc = elemAccess(elem); - const spreadSet = new Set(e.spreads ?? []); let pending: LlValue[] = []; let buffer: string | undefined; const flush = (): void => { @@ -874,6 +885,12 @@ export function emitContainerExpr( if (spreadSet.has(i)) { flush(); const v = host.emitExpr(el); + let plain = 0; + for (let j = i + 1; j < e.elems.length && !spreadSet.has(j); j++) plain++; + host.declare(`declare void @scr_arr_reserve_spread(ptr, ptr, ${host.sizeType})`); + B.line( + `call void @scr_arr_reserve_spread(ptr ${arr}, ptr ${v.name}, ${host.sizeType} ${plain})`, + ); host.emitArrayCopyLoop(arr, v.name, acc); return; } @@ -895,6 +912,8 @@ export function emitContainerExpr( const n = host.emitExpr(e.length); const arr = B.tmp(); B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, "0")}`); + host.declare(`declare void @scr_arr_reserve_len(ptr, double)`); + B.line(`call void @scr_arr_reserve_len(ptr ${arr}, double ${n.name})`); const out = host.own({ name: arr, type: e.type }); const acc = elemAccess(elem); let fill = acc === "f64" ? f64Lit(0) : acc === "bool" ? "false" : "null"; diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 6b51f9afa..a4f2ed749 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -36,6 +36,7 @@ import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context import type { NullableRefField } from "./nullable-fields.js"; import { emitUnionPeek, emitUnionTag } from "./union-repr.js"; import { inlineUnionCopyLines } from "./inline-unions.js"; +import { recordMayKeepSource } from "./record-sources.js"; /** An owned (+1) union value for a nullable-pointer class field slot `p` * (the unit arm's immortal instance, or a fresh box around a retained @@ -176,6 +177,7 @@ export function streamTypedRefCommitAdapter( const check = host.dyn.dynCheckHelper(t); host.declare(`declare void @scr_arr_release(ptr)`); host.declare(`declare void @scr_arr_outline(ptr)`); + host.declare(`declare void @scr_arr_adopt_view_properties(ptr, ptr, ptr)`); const lines = [ `define internal void @${commit}(ptr %target, ptr %d) ${FN_ATTRS} { ; commit live stream element ${typeKey(t)}`, `entry:`, @@ -183,6 +185,8 @@ export function streamTypedRefCommitAdapter( ` %missing = icmp eq ptr %next, null`, ` br i1 %missing, label %done, label %swap`, `swap:`, + // Properties move with the storage (in its out-of-line part). + ` call void @scr_arr_adopt_view_properties(ptr %next, ptr %d, ptr %target)`, // Inline slots live in each header's block; move them out first. ` call void @scr_arr_outline(ptr %target)`, ` call void @scr_arr_outline(ptr %next)`, @@ -193,7 +197,10 @@ export function streamTypedRefCommitAdapter( { name: "cap", index: SCR_ARR_FIELD.cap, type: "i32" }, { name: "data", index: SCR_ARR_FIELD.data, type: "ptr" }, { name: "filled", index: SCR_ARR_FIELD.filled, type: "i32" }, - { name: "ext", index: SCR_ARR_FIELD.ext, type: "ptr" }, + // The out-of-line storage's pointer lives before the dense allocation + // and moves with data; its flag is in spare, whose element ops id the + // two arrays share. + { name: "spare", index: SCR_ARR_FIELD.spare, type: "i16" }, ]; for (const member of storageMembers) { lines.push( @@ -533,6 +540,12 @@ export function streamTypedRefCommitAdapter( ` store ${member.type} %old${index}, ptr %np${index}`, ); }); + // The conversion kept the view's undeclared properties for %next. Shapes + // with accessor slots keep their accessors in the view instead. + if (recordMayKeepSource(shape) && !shapeHasAccessorSlots(shape)) { + host.declare(`declare void @scr_record_source_move(ptr, ptr)`); + lines.push(` call void @scr_record_source_move(ptr %next, ptr %target)`); + } lines.push( ` call void ${releaseSym(host.shapeHost, t)}(ptr %next)`, ` br label %done`, diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 0d6f1a0a2..435cc3eb3 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -758,6 +758,14 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const inline = emitInlineAtomicsOp(host, e); if (inline) return inline; } + if (e.fn === "bytes.isAbsent") { + // A borrowed read: retaining an absent Buffer would dereference NULL. + const value = host.emitReadReceiver(e.args[0]!); + const t = host.B.tmp(); + host.B.line(`${t} = icmp eq ptr ${value.name}, null`); + return { name: t, type: e.type }; + } + if (e.fn === "bytes.absent") return host.own({ name: "null", type: e.type }); const prefix = e.fn.slice(0, e.fn.indexOf(".")) as LibCallPrefix; // events.once builds typed listener adapters beside the emitter calls. if (e.fn === "events.once" || e.fn === "events.onceSignal") @@ -864,6 +872,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { case "events": case "blob": case "wasm": + case "array": case "record": case "disposable": case "punycode": diff --git a/packages/compiler/src/backend/llvm/lib-http2.ts b/packages/compiler/src/backend/llvm/lib-http2.ts index 0254db555..791987f86 100644 --- a/packages/compiler/src/backend/llvm/lib-http2.ts +++ b/packages/compiler/src/backend/llvm/lib-http2.ts @@ -3,6 +3,7 @@ import type { IrType } from "../../ir/ir.js"; import { typeKey } from "../../ir/ir.js"; import type { LibCallExpr, LlValue, LlvmEmitterContext } from "./expr-context.js"; import { + type AbiArgument, abiValue, callbackAdapter, callbackType, @@ -84,6 +85,23 @@ function sniAnswer(host: LlvmEmitterContext, type: IrType): string { return symbol; } +/** The (closure, answer thunk) pair of an SNICallback argument: a function, + * or a function | undefined union whose undefined arm passes no callback. */ +export function sniCallbackArgs(host: LlvmEmitterContext, cb: LlValue): AbiArgument[] { + let type = cb.type, + value = cb.name; + if (type.kind === "union") { + const arms = host.unionsById.get(type.unionId)?.arms ?? []; + const tag = arms.findIndex((arm) => arm.kind === "func"); + if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm"); + type = arms[tag]!; + value = host.unwrapNullableClosure(value, tag); + } else host.toHeap(cb); + if (type.kind !== "func" || type.params[1] === undefined) + throw new InternalCompilerError("SNI callback lacks its answer function"); + return [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))]; +} + export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const args = e.args.map((arg) => host.emitExpr(arg)), a = args.map((arg) => abiValue(host, arg)); @@ -160,21 +178,7 @@ export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVa let handler = [ptr(), ptr()], sni = [ptr(), ptr()]; if (e.fn === "http2.createSecureServerReq") handler = requestHandler(host, args[2]!); - if (e.fn === "http2.createSecureServerSni") { - const cb = args[2]!; - let type = cb.type, - value = cb.name; - if (type.kind === "union") { - const arms = host.unionsById.get(type.unionId)?.arms ?? []; - const tag = arms.findIndex((arm) => arm.kind === "func"); - if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm"); - type = arms[tag]!; - value = host.unwrapNullableClosure(value, tag); - } else host.toHeap(cb); - if (type.kind !== "func" || type.params[1] === undefined) - throw new InternalCompilerError("SNI callback lacks its answer function"); - sni = [ptr(value), ptr("@" + sniAnswer(host, type.params[1]))]; - } + if (e.fn === "http2.createSecureServerSni") sni = sniCallbackArgs(host, args[2]!); return finishRuntimeCall(host, e, "scr_http2_create_secure_server_allow_http1", [ ...rawBytes(host, args[0]!), ...rawBytes(host, args[1]!), diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 9d85547eb..fc8ffa60d 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1495,6 +1495,7 @@ export const LIB_FN_SYMS: Record = { "dyn.bagGet": "scr_dyn_bag_get", "dyn.bagSet": "scr_dyn_bag_set", "dyn.objCreate": "scr_dyn_obj_create", + "dyn.nameClassPrototype": "scr_dyn_name_class_prototype", "dyn.objCreateWithProperties": "scr_dyn_obj_create_with_properties", "dyn.getPrototype": "scr_dyn_get_prototype", "dyn.setPrototype": "scr_dyn_set_prototype", @@ -1521,6 +1522,8 @@ export const LIB_FN_SYMS: Record = { "regex.groupNumber": "scr_regex_group_number", "regex.groupParseInt": "scr_regex_group_parse_int", "regex.resultProp": "scr_regex_result_prop", + "array.namedKeys": "scr_arr_named_keys", + "array.hasNamedKey": "scr_arr_has_named_key", // queueMicrotask's checked-dynamic form (borrowed dyn; a non-function // throws synchronously), the minted setImmediate value, and the // timers/promises immediate — all mark the loop live. diff --git a/packages/compiler/src/backend/llvm/lib-stream.ts b/packages/compiler/src/backend/llvm/lib-stream.ts index a319d0257..d3b4b1f5b 100644 --- a/packages/compiler/src/backend/llvm/lib-stream.ts +++ b/packages/compiler/src/backend/llvm/lib-stream.ts @@ -274,10 +274,9 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV .filter((a) => a.tag >= 0); if (present.length === 0) throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`); - const tagP = B.tmp(); - const tag = B.tmp(); - B.line(`${tagP} = getelementptr inbounds %ScrUnion, ptr ${args[1]!.name}, i64 0, i32 1`); - B.line(`${tag} = load i32, ptr ${tagP}`); + // A tagged box, or the arm pointer itself for a nullable union + // (`string | null`, `Buffer | null`). + const tag = host.unionTag(args[1]!.name, t.unionId); const slot = B.slot(); B.entryAllocas.push(`${slot} = alloca i1`); const lj = B.newLabel("scu.j"); @@ -290,10 +289,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV B.line(`store i1 ${r}, ptr ${slot}`); return; } - const pk = B.tmp(); - const pv = B.tmp(); - B.line(`${pk} = getelementptr inbounds %ScrUnion, ptr ${args[1]!.name}, i64 0, i32 5`); - B.line(`${pv} = load ptr, ptr ${pk} ; borrowed payload`); + const pv = host.unionPeek(args[1]!.name, t.unionId); // borrowed payload const r = B.tmp(); if (pushing) { host.declare(`declare zeroext i1 @${entry}(ptr, ptr)`); diff --git a/packages/compiler/src/backend/llvm/lib-tls.ts b/packages/compiler/src/backend/llvm/lib-tls.ts index 18fc006d4..c5c77d3a6 100644 --- a/packages/compiler/src/backend/llvm/lib-tls.ts +++ b/packages/compiler/src/backend/llvm/lib-tls.ts @@ -11,6 +11,7 @@ import { rawBytes, requestHandler, } from "./lib-abi.js"; +import { sniCallbackArgs } from "./lib-http2.js"; import { emitAlwaysThrowLibCall } from "./lib-shared.js"; export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { @@ -43,6 +44,27 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu [...rawBytes(host, args[0]!), ...rawBytes(host, args[1]!), ...handler], ); } + case "tls.createServerSni": + case "tls.createServerSniCb": { + const cb = args[3]; + const handler = + cb === undefined + ? [ptr(), ptr()] + : callbackAdapter( + host, + cb, + callbackType(cb).params.length === 0 + ? "scr_net_conn_thunk0" + : "scr_net_conn_thunk_sock", + ["ptr", "ptr"], + ); + return finishRuntimeCall(host, e, "scr_tls_create_server_sni", [ + ...rawBytes(host, args[0]!), + ...rawBytes(host, args[1]!), + ...handler, + ...sniCallbackArgs(host, args[2]!), + ]); + } case "tls.createServerDyn": case "tls.createServerDynCb": case "https.createServerDyn": diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index a0a153da1..d31f14d12 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -105,6 +105,8 @@ const INERT_LIB_CALLS = new Set([ "regex.groupNumber", "regex.groupParseInt", "regex.resultProp", + "array.namedKeys", + "array.hasNamedKey", "process.cwd", "process.argv", "process.execArgv", diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 8844921c5..58295e42b 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -1074,15 +1074,16 @@ export function arrNewCall( return `call ptr @scr_arr_new_ops(ptr ${arrOps(host, elem)}, ${host.sizeType} ${capText})`; } -/** The constant %ScrArrOps descriptor (retain, release, trace) of a REF - * element type, defined once per module and shared by all its arrays. */ +/** The %ScrArrOps descriptor (retain, release, trace, registry id) of a REF + * element type, defined once per module and shared by all its arrays. It is + * writable: the runtime stores its registry id on the first array. */ export function arrOps(host: ShapeHost, elem: IrType): string { const v = vAdapters(host, elem); const trace = traceArg(host, elem); const part = (sym: string): string => sym.replace(/^@/, "").replace(/[^A-Za-z0-9_.]/g, "_"); const name = `@sc_arrops.${part(v.retain)}.${part(v.release)}.${part(trace)}`; host.declare( - `${name} = internal constant %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace} }`, + `${name} = internal global %ScrArrOps { ptr ${v.retain}, ptr ${v.release}, ptr ${trace}, i32 0 }`, ); return name; } diff --git a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts index 8622085b4..f85addcf8 100644 --- a/packages/compiler/src/backend/llvm/string-lifetimes.test.ts +++ b/packages/compiler/src/backend/llvm/string-lifetimes.test.ts @@ -311,7 +311,8 @@ const methodCases: { // enters through the double ABI. { method: "charCodeAt", args: [num(1)], result: F64, target: "char_code_at_int", inline: true }, { method: "charCodeAt", args: [num(1.5)], result: F64, target: "char_code_at", inline: true }, - { method: "charAt", args: [num(1)], result: STRING, target: "char_at" }, + { method: "charAt", args: [num(1)], result: STRING, target: "char_at_int", inline: true }, + { method: "charAt", args: [num(1.5)], result: STRING, target: "char_at", inline: true }, { method: "indexOf", args: [ref("argument"), num(1)], result: F64, target: "index_of" }, { method: "includes", args: [ref("argument")], result: BOOL, target: "includes" }, { diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index d7bbed272..03a9fa282 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -18,6 +18,8 @@ */ import { exactIndexLines, sizeToDouble } from "./common.js"; +import { wtf8Bytes } from "../literals.js"; +import { literalCapWord, literalCapWord32 } from "./string-key-hash.js"; export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)"; export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)"; @@ -31,6 +33,9 @@ const READ_CALL_ATTRS = "cold nocallback nounwind willreturn"; /** Requests the integer-index helper below; an LLVM comment line among the * declarations, so it never declares anything itself. */ export const STRING_CHAR_CODE_AT_INT_MARKER = "; uses @sc_str_char_code_at_int"; +export const STRING_CHAR_AT_DECL = "declare ptr @scr_str_char_at(ptr, double)"; +/** Requests the integer-index charAt helper, as the marker above does. */ +export const STRING_CHAR_AT_INT_MARKER = "; uses @sc_str_char_at_int"; /** Load the proven-ASCII flag of `%s` into `%ascii`. */ function asciiTest(): string[] { @@ -85,6 +90,34 @@ function scanRead(): string[] { ]; } +/** The one-character strings of the ASCII range as immortal literals + * ({ rc = SIZE_MAX, len = 1, capacity word, char, NUL }), indexed by + * character code: the inline charAt result. Their bytes, capacity words + * and key hashes equal those of the runtime's single-character statics, so + * they are interchangeable with the strings the runtime returns. */ +function asciiCharTable(sz: string): string[] { + const type = `{ ${sz}, ${sz}, ${sz}, [2 x i8] }`; + const entries: string[] = []; + for (let c = 0; c < 128; c++) { + const bytes = wtf8Bytes(String.fromCharCode(c)); + const word = sz === "i64" ? literalCapWord(bytes) : literalCapWord32(bytes); + entries.push(`${type} { ${sz} -1, ${sz} 1, ${sz} ${word}, [2 x i8] [i8 ${c}, i8 0] }`); + } + return [`@sc_ascii_chars = internal constant [128 x ${type}] [${entries.join(", ")}]`]; +} + +/** The table entry for byte `%byte` of a proven-ASCII string. */ +function asciiCharRead(sz: string): string[] { + return [ + ` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`, + ` %p = getelementptr inbounds i8, ptr %data, i64 %k`, + ` %byte = load i8, ptr %p`, + ` %code = zext i8 %byte to i64`, + ` %char = getelementptr inbounds [128 x { ${sz}, ${sz}, ${sz}, [2 x i8] }], ptr @sc_ascii_chars, i64 0, i64 %code`, + ` ret ptr %char`, + ]; +} + export interface StringReadHelper { decl: string; define(sz: string, attrs: string, scan: boolean): string[]; @@ -200,4 +233,57 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [ `}`, ], }, + { + // charAt of a proven-ASCII string at an exact in-range index is the + // table entry for its byte; every other case calls the runtime, which + // keeps the complete semantics. The table is defined with the helper. + decl: STRING_CHAR_AT_DECL, + define: (sz, attrs) => { + let n = 0; + const exactIndex = exactIndexLines("%i", "%len", sz, () => `%ix${n++}`); + return [ + ...asciiCharTable(sz), + ``, + `define internal ptr @sc_str_char_at(ptr %s, double %i) ${attrs} {`, + `entry:`, + ...asciiTest(), + ` br i1 %ascii, label %bounds, label %slow`, + `bounds:`, + ...loadLength(sz), + ...exactIndex.lines.map((line) => ` ${line}`), + ` br i1 ${exactIndex.ok}, label %read, label %slow`, + `read:`, + ` %k = add i64 ${exactIndex.wide}, 0`, + ...asciiCharRead(sz), + `slow:`, + ` %r = call ptr @scr_str_char_at(ptr %s, double %i) ${READ_CALL_ATTRS}`, + ` ret ptr %r`, + `}`, + ]; + }, + }, + { + // An exact integer index, as for sc_str_char_code_at_int. Requested + // together with STRING_CHAR_AT_DECL, which defines the table. + decl: STRING_CHAR_AT_INT_MARKER, + define: (sz, attrs) => [ + `define internal ptr @sc_str_char_at_int(ptr %s, i64 %i) ${attrs} {`, + `entry:`, + ...asciiTest(), + ` br i1 %ascii, label %bounds, label %slow`, + `bounds:`, + ...loadLength(sz), + ...(sz === "i64" ? [] : [` %len64 = zext ${sz} %len to i64`]), + ` %ok = icmp ult i64 %i, ${sz === "i64" ? "%len" : "%len64"}`, + ` br i1 %ok, label %read, label %slow`, + `read:`, + ` %k = add i64 %i, 0`, + ...asciiCharRead(sz), + `slow:`, + ` %d = sitofp i64 %i to double`, + ` %r = call ptr @scr_str_char_at(ptr %s, double %d) ${READ_CALL_ATTRS}`, + ` ret ptr %r`, + `}`, + ], + }, ]; diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index 21c74e51c..545711a60 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -168,30 +168,6 @@ test("resolves a package helper, emits atomically, and caches by all native inpu expect(secondArtifact.dependencies).toEqual(firstArtifact.dependencies); }); -test("runtime bitcode imports reach the helper and key the native cache", async () => { - const pkg = await fakePackage(); - const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o")); - await emitNativeArtifact(base); - const imported = { - ...request(pkg.root, pkg.packageJson, join(pkg.root, "imported.o")), - importBitcode: { - paths: ["/pack/a.bc", "/pack/b.bc"], - digests: ["a".repeat(64), "b".repeat(64)], - }, - }; - await emitNativeArtifact(imported); - await emitNativeArtifact({ - ...imported, - outputPath: join(pkg.root, "changed.o"), - importBitcode: { ...imported.importBitcode, digests: ["a".repeat(64), "c".repeat(64)] }, - }); - await emitNativeArtifact({ ...imported, outputPath: join(pkg.root, "again.o") }); - const calls = (await readFile(pkg.log, "utf8")).trim().split("\n"); - expect(calls).toHaveLength(3); - expect(calls[0]).not.toContain("--import-bitcode"); - expect(calls[1]).toContain("--import-bitcode /pack/a.bc --import-bitcode /pack/b.bc"); -}); - test("profiles reach the helper and key the native cache by their bytes", async () => { const pkg = await fakePackage(); const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o")); diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index 39e570cd5..5b91c60e7 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -111,8 +111,6 @@ export interface NativeCodegenOptions { cacheRoot?: string | null; /** Program partitions, written to nativePartitionPaths(outputPath). */ partitions?: number; - /** Runtime bitcode whose small functions the helper imports for inlining. */ - importBitcode?: { paths: readonly string[]; digests: readonly string[] }; /** Profile-guided optimization: instrumentation or an indexed profile. */ profile?: NativeProfileOptions; } @@ -339,11 +337,6 @@ function cacheKey( .update("\0") .update(options.sourcePath) .update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "") - .update( - options.importBitcode === undefined - ? "" - : `\0import:${options.importBitcode.digests.join(",")}`, - ) .update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`) .digest("hex"); } @@ -482,7 +475,6 @@ export async function emitNativeArtifact( "json", "--source-path", options.sourcePath, - ...(options.importBitcode?.paths ?? []).flatMap((path) => ["--import-bitcode", path]), ...(partitionCache === null ? [] : ["--cache-dir", partitionCache]), ...nativeProfileHelperArgs(options.profile), ]); diff --git a/packages/compiler/src/backend/optimization.ts b/packages/compiler/src/backend/optimization.ts index f26b99dcc..05e275afd 100644 --- a/packages/compiler/src/backend/optimization.ts +++ b/packages/compiler/src/backend/optimization.ts @@ -3,8 +3,8 @@ * - `release` (default): -O2 program code against the release runtime flavor. * - `dev`: -O0, source line tables, and cached object shards. * - `speed`: -O2 plus optimizations that trade binary size and build time for - * run-time speed (a size-for-speed runtime flavor, runtime bitcode import, - * inline reference counting). Release output never depends on them. + * run-time speed (a size-for-speed runtime flavor, inline reference + * counting). Release output never depends on them. * * Speed is an optimized posture everywhere release is: it shares -O2, release * linker flags, and release-only program partitioning. Cache keys spell it diff --git a/packages/compiler/src/backend/runtime-pack.test.ts b/packages/compiler/src/backend/runtime-pack.test.ts index 4fd53c01e..ee2301172 100644 --- a/packages/compiler/src/backend/runtime-pack.test.ts +++ b/packages/compiler/src/backend/runtime-pack.test.ts @@ -9,7 +9,6 @@ import type { NativeLinkFeatures } from "./native-link-info.js"; import { effectiveRuntimeFeatures, evaluateRuntimePredicate, - loadRuntimeBitcode, loadRuntimePack, parseRuntimePackManifest, RuntimePackError, @@ -262,80 +261,10 @@ describe("runtime pack manifests", () => { expect(fileHandle.archives.map((path) => path.split("/").at(-1))).toEqual(["zlib.a"]); }); - test("runtime bitcode follows the selected flavor variant and is verified", async () => { - const { root, packagePath, manifest } = await fixture(); - const bitcode = async (path: string, bytes: string) => { - await writeFile(join(root, path), bytes); - return { - path, - sha256: createHash("sha256").update(bytes).digest("hex"), - size: Buffer.byteLength(bytes), - }; - }; - const unit = manifest.flavors.release!.runtime_units[0]!; - const withBitcode: RuntimePackManifest = { - ...manifest, - flavors: { - ...manifest.flavors, - speed: { - optimization: "-O2", - runtime_units: [ - { - ...unit, - variants: [ - { ...unit.variants[0]!, bitcode: await bitcode("artifacts/base.bc", "base-bc") }, - { - ...unit.variants[1]!, - bitcode: await bitcode("artifacts/legacy.bc", "legacy-bc"), - }, - unit.variants[2]!, - ], - }, - ], - }, - }, - }; - const resolver = () => packagePath; - const speed = { target: MACOS_ARM64_TARGET, optimization: "speed" } as const; - // Release programs import their own units' bitcode. Speed programs - // never import release bitcode: without a speed flavor they import none. - await writeFile( - join(root, "runtime-pack.json"), - JSON.stringify({ - ...withBitcode, - flavors: { ...withBitcode.flavors, release: withBitcode.flavors.speed, speed: undefined }, - }), - ); - expect(await loadRuntimeBitcode({ ...speed, features: BASE, resolver })).toBeNull(); - const release = await loadRuntimeBitcode({ - target: MACOS_ARM64_TARGET, - optimization: "release", - features: BASE, - resolver, - }); - expect(release?.paths.map((path) => basename(path))).toEqual(["base.bc"]); - await writeFile(join(root, "runtime-pack.json"), JSON.stringify(withBitcode)); - const legacy = await loadRuntimeBitcode({ - ...speed, - features: { ...BASE, textDecoderLegacy: true }, - resolver, - }); - expect(legacy?.paths.map((path) => basename(path))).toEqual(["legacy.bc"]); - expect(legacy?.digests).toEqual([createHash("sha256").update("legacy-bc").digest("hex")]); - // A variant without bitcode imports nothing. - expect( - await loadRuntimeBitcode({ - ...speed, - features: { ...BASE, fileHandle: true }, - resolver, - }), - ).toBeNull(); - await writeFile(join(root, "artifacts/base.bc"), "damaged"); - await expect(loadRuntimeBitcode({ ...speed, features: BASE, resolver })).rejects.toThrow( - "hash mismatch", - ); - const malformed = structuredClone(withBitcode); - malformed.flavors.speed!.runtime_units[0]!.variants[0]!.bitcode = { + test("runtime unit bitcode must stay inside the pack", async () => { + const { manifest } = await fixture(); + const malformed = structuredClone(manifest); + malformed.flavors.release!.runtime_units[0]!.variants[0]!.bitcode = { path: "../escape.bc", sha256: "0".repeat(64), size: 1, diff --git a/packages/compiler/src/backend/runtime-pack.ts b/packages/compiler/src/backend/runtime-pack.ts index 5f813802c..2bfe71756 100644 --- a/packages/compiler/src/backend/runtime-pack.ts +++ b/packages/compiler/src/backend/runtime-pack.ts @@ -46,8 +46,6 @@ export interface RuntimePackSelection { sourceDependencies: NativeArtifactDependency[]; selectedRuntimeArtifacts: RuntimePackArtifact[]; selectedArchiveArtifacts: RuntimePackArtifact[]; - /** Bitcode of the selected runtime units that ship it, in unit order. */ - selectedRuntimeBitcode: RuntimePackArtifact[]; } async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise { @@ -249,49 +247,5 @@ export async function loadRuntimePack(options: { sourceDependencies, selectedRuntimeArtifacts: selectedVariants, selectedArchiveArtifacts: selectedArchives, - selectedRuntimeBitcode: selectedVariants.flatMap((variant) => - variant.bitcode === undefined ? [] : [variant.bitcode], - ), - }; -} - -export interface RuntimeBitcodeImport { - paths: string[]; - /** Content digests, in path order, for cache keys. */ - digests: string[]; - dependencies: NativeArtifactDependency[]; -} - -/** Verified bitcode for the runtime units of the given flavor that a program - * links, for the helper's available_externally import. Release units carry - * their compiled bitcode reduced to the bodies that need no unit-local - * state; speed units carry their promoted bitcode. Null when the selected - * units ship no bitcode (a pack built without the helper, or no speed - * flavor). */ -export async function loadRuntimeBitcode(options: { - target: NativeTargetSpec; - features: NativeLinkFeatures; - optimization: "release" | "speed"; - env?: NodeJS.ProcessEnv; - resolver?: (specifier: string) => string; -}): Promise { - const pack = await loadRuntimePack(options); - // Without a speed flavor, speed programs link release units; only the - // speed flavor's promoted objects match speed bitcode. - if (options.optimization === "speed" && pack.manifest.flavors.speed === undefined) return null; - if (pack.selectedRuntimeBitcode.length === 0) return null; - const paths = await Promise.all( - pack.selectedRuntimeBitcode.map((artifact) => verifyArtifact(pack.root, artifact)), - ); - let dependencies: NativeArtifactDependency[]; - try { - dependencies = await snapshotDependencies([pack.manifestPath, ...paths]); - } catch { - throw new RuntimePackError("runtime pack bitcode changed during selection", "invalid"); - } - return { - paths, - digests: pack.selectedRuntimeBitcode.map((artifact) => artifact.sha256), - dependencies, }; } diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index 81693e095..21ddd6e5b 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -13,6 +13,7 @@ import { import { TIER_MODES, type TierMode } from "../frontend/tier-analysis.js"; import { buildEnvelope } from "../diagnostics/envelope.js"; import { renderWarnings } from "../diagnostics/render.js"; +import { isPerformanceCode, renderPerformanceWarnings } from "../diagnostics/dynamic-tier.js"; import { isNativeOptimization } from "../backend/optimization.js"; import { setProvenanceSources } from "../frontend/provenance-registry.js"; import type { CliHost, NativeCacheWarmProfile } from "./host.js"; @@ -284,8 +285,20 @@ async function main(args: string[], host: CliHost): Promise { fail(`--print=diagnostics is a build/coverage option\n\n${USAGE}`); } const failOnRaw = values["fail-on"]; - if (failOnRaw !== undefined && failOnRaw !== "blockers" && failOnRaw !== "divergences") { - fail(`unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences)\n\n${USAGE}`); + if ( + failOnRaw !== undefined && + failOnRaw !== "blockers" && + failOnRaw !== "divergences" && + failOnRaw !== "dynamic" + ) { + fail( + `unknown --fail-on value "${failOnRaw}" (supported: blockers, divergences, dynamic)\n\n${USAGE}`, + ); + } + // --no-dynamic-tier-warning leaves out SC6101 (diagnostics/dynamic-tier.ts). + const dynamicTierWarning = values["dynamic-tier-warning"] !== false; + if (failOnRaw === "dynamic" && !dynamicTierWarning) { + fail(`--fail-on=dynamic checks the warning --no-dynamic-tier-warning leaves out\n\n${USAGE}`); } if (failOnRaw !== undefined && command !== "coverage") { fail(`--fail-on is a coverage option (a build already fails on blockers)\n\n${USAGE}`); @@ -430,6 +443,7 @@ async function main(args: string[], host: CliHost): Promise { ...(explainDynamic !== undefined ? { explainDynamic: true } : {}), ...(wasmFiles.length > 0 ? { wasm: wasmFiles } : {}), }); + if (!dynamicTierWarning) delete coverage.performance; if (printDiagnostics) { // Keep stdout pure JSON for tooling. const envelope = coverageEnvelope(coverage, { @@ -525,7 +539,9 @@ async function main(args: string[], host: CliHost): Promise { process.stderr.write(`\n${n} error${n === 1 ? "" : "s"}.\n`); throw new CliExit(1); } - const warnings = result.warnings ?? []; + const warnings = (result.warnings ?? []).filter( + (d) => dynamicTierWarning || !isPerformanceCode(d.code), + ); if (printDiagnostics) { diagnosticsEnvelope = buildEnvelope({ compilerVersion: host.version(), @@ -538,12 +554,20 @@ async function main(args: string[], host: CliHost): Promise { }); } else if (warnings.length > 0 && command === "build") { // Divergence warnings never fail a build; they explain where the - // native program can behave differently from Node. `run` keeps - // stderr for the program's own output. + // native program can behave differently from Node. The performance + // warning names likely-hot functions that use untyped values. `run` + // keeps stderr for the program's own output. const color = process.stderr.isTTY ?? false; - process.stderr.write( - renderWarnings(warnings, result.sourceTexts ?? new Map(), { color }) + "\n", - ); + const divergences = warnings.filter((d) => !isPerformanceCode(d.code)); + const performance = warnings.filter((d) => isPerformanceCode(d.code)); + if (divergences.length > 0) + process.stderr.write( + renderWarnings(divergences, result.sourceTexts ?? new Map(), { color }) + "\n", + ); + if (performance.length > 0) { + if (divergences.length > 0) process.stderr.write("\n"); + process.stderr.write(renderPerformanceWarnings(performance) + "\n"); + } } if (result.artifact.kind === "exe") { if (!values["keep-llvm"]) rmSync(result.artifact.translationUnitPath, { force: true }); diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 14adfbe22..ca3ade8f6 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -31,10 +31,11 @@ Options: (native-link-info implies --emit=obj and never links; diagnostics prints the versioned JSON diagnostics envelope for build and coverage) - --fail-on + --fail-on coverage only: exit 1 when the entry path has blockers (a build would fail), or also when it has Node - divergences + divergences, or also when it has likely-hot functions + that use untyped values (SC6101) --backend code generator (llvm) --optimization native optimization posture (default: release/-O2). dev @@ -105,6 +106,9 @@ Options: values and the bindings, fields, functions, classes and modules the dynamic tier would take over, with the refusal behind each; a name shows only that unit's chain + --no-dynamic-tier-warning + build and coverage: leave out SC6101, the warning that + names likely-hot functions that use untyped values --wasm coverage only: report a WebAssembly module the program loads whose path its sources do not spell (repeatable; @@ -137,6 +141,7 @@ export const CLI_OPTIONS = { "external-types": { type: "string", multiple: true }, tier: { type: "string" }, "explain-dynamic": { type: "string" }, + "dynamic-tier-warning": { type: "boolean", default: true }, wasm: { type: "string", multiple: true }, lib: { type: "boolean", default: false }, profile: { type: "string" }, diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts index 89733e255..37f77f4c6 100644 --- a/packages/compiler/src/coverage/report.ts +++ b/packages/compiler/src/coverage/report.ts @@ -42,6 +42,10 @@ export interface CoverageInput { * the program compiles, but these sites can behave differently natively. * Never blockers; `--fail-on=divergences` opts into failing on them. */ divergences?: ScrDiagnostic[]; + /** The performance warning (SC6101): likely-hot functions the source + * leaves untyped. Never blockers; `--fail-on=dynamic` opts into failing + * on it. */ + performance?: ScrDiagnostic[]; /** Each dependency's native compilation outcome, with the first refusal * reason for a refused package. */ npmStatic?: NpmStaticStatus[]; @@ -333,6 +337,17 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions ); renderRows(groupBlockers(divergences), YELLOW); }; + // The performance warning: its message names the functions. + const performance = input.performance ?? []; + const renderPerformance = () => { + for (const d of performance) { + out.push(""); + const [head, ...rest] = d.message.split("\n"); + out.push(` ${c(YELLOW, "untyped and likely hot")} ${head}`); + for (const line of rest) out.push(` ${line}`); + if (d.hint !== undefined) out.push(` ${c(DIM, `hint: ${d.hint}`)}`); + } + }; const unreachedDiags = [...(un?.diagnostics ?? []), ...(un?.runtimeFences ?? [])]; if (failed === 0 && input.diagnostics.length === 0 && unreachedDiags.length === 0) { @@ -341,6 +356,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(""); renderDivergences(); } + renderPerformance(); return out.join("\n"); } @@ -381,6 +397,7 @@ export function renderCoverage(input: CoverageInput, opts: CoverageRenderOptions out.push(""); renderDivergences(); } + renderPerformance(); return out.join("\n"); } @@ -449,8 +466,9 @@ function displayPath(file: string, root: string | undefined): string { } /** What `coverage --fail-on` checks: reached blockers (they fail a build) - * and, optionally, divergences. Unreached blockers never fail. */ -export type CoverageFailOn = "blockers" | "divergences"; + * and, optionally, divergences or the performance warning. Unreached + * blockers never fail. */ +export type CoverageFailOn = "blockers" | "divergences" | "dynamic"; /** Whether the analysis passes the selected gate. Preflight failures * always fail; `failOn` undefined means only preflight fails (the report's @@ -462,6 +480,7 @@ export function coveragePasses(input: CoverageInput, failOn?: CoverageFailOn): b // runtime fences build (the statement throws only if it executes). if (input.diagnostics.length > 0) return false; if (failOn === "divergences" && (input.divergences?.length ?? 0) > 0) return false; + if (failOn === "dynamic" && (input.performance?.length ?? 0) > 0) return false; return true; } @@ -492,6 +511,7 @@ export function coverageEnvelope( add(input.unreached?.diagnostics, "unreached"); add(input.unreached?.runtimeFences, "deferred"); add(input.divergences, "reached"); + add(input.performance, "reached"); } const un = input.unreached; const total = input.stats.statementsTotal + (un?.stats.statementsTotal ?? 0); diff --git a/packages/compiler/src/coverage/verdict-cache.ts b/packages/compiler/src/coverage/verdict-cache.ts index 422bf5e4d..e5fa7242e 100644 --- a/packages/compiler/src/coverage/verdict-cache.ts +++ b/packages/compiler/src/coverage/verdict-cache.ts @@ -38,6 +38,7 @@ export interface CachedCoverage { runtimeFences?: ScrDiagnostic[]; unreached?: { stats: LowerStats; diagnostics: ScrDiagnostic[]; runtimeFences?: ScrDiagnostic[] }; divergences?: ScrDiagnostic[]; + performance?: ScrDiagnostic[]; npmStatic?: NpmStaticStatus[]; wasmModules?: WasmModuleReport[]; preflightFailed: boolean; @@ -149,6 +150,7 @@ function cacheable(coverage: AnalyzeResult["coverage"]): CachedCoverage | null { if (coverage.runtimeFences !== undefined) out.runtimeFences = coverage.runtimeFences; if (coverage.unreached !== undefined) out.unreached = coverage.unreached; if (coverage.divergences !== undefined) out.divergences = coverage.divergences; + if (coverage.performance !== undefined) out.performance = coverage.performance; if (coverage.npmStatic !== undefined) out.npmStatic = coverage.npmStatic; if (coverage.wasmModules !== undefined) out.wasmModules = coverage.wasmModules; return out; @@ -166,6 +168,7 @@ function restore(cached: CachedCoverage, sources: CachedSource[]): AnalyzeResult if (cached.runtimeFences !== undefined) coverage.runtimeFences = cached.runtimeFences; if (cached.unreached !== undefined) coverage.unreached = cached.unreached; if (cached.divergences !== undefined) coverage.divergences = cached.divergences; + if (cached.performance !== undefined) coverage.performance = cached.performance; if (cached.npmStatic !== undefined) coverage.npmStatic = cached.npmStatic; if (cached.wasmModules !== undefined) coverage.wasmModules = cached.wasmModules; return { coverage, sourceTexts }; diff --git a/packages/compiler/src/coverage/verdict-semantic.ts b/packages/compiler/src/coverage/verdict-semantic.ts index cc1c1f414..ad2f4d180 100644 --- a/packages/compiler/src/coverage/verdict-semantic.ts +++ b/packages/compiler/src/coverage/verdict-semantic.ts @@ -7,6 +7,9 @@ import { rebaseSourceLocations, semanticallyEqualSource } from "../library/seman import type { SemanticReplay } from "./verdict-cache.js"; export const commentOnlyReplay: SemanticReplay = (probes, sources, coverage) => { + // The performance warning's text names file:line positions, which moving + // its locations would not update: such a verdict is analyzed again. + if (coverage.performance !== undefined) return null; const previous = new Map(); for (const source of sources) previous.set(source.path, source.text); const match = frontendInputsSemanticallyMatch(probes, previous, semanticallyEqualSource); diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index 222b20fd3..63b7553da 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -61,7 +61,10 @@ * collation does not cover (SC6003), a call feeding an instance * to a cast to an unrelated class (SC6004); SC6005 (Date strings * outside the native parser's grammar) is retired — the runtime - * parses date strings with V8's grammar + * parses date strings with V8's grammar. SC61xx are performance + * notes, also warnings (coverage --fail-on=dynamic opts in): + * likely-hot functions that use untyped values (SC6101, + * diagnostics/dynamic-tier.ts) * SC8xxx Node islands: the program calls into Node.js, and the build * finds no usable libnode pack (SC8005) * SC9xxx internal compiler errors (still source-anchored) diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts new file mode 100644 index 000000000..7970d3f42 --- /dev/null +++ b/packages/compiler/src/diagnostics/dynamic-tier.ts @@ -0,0 +1,99 @@ +/* SC6101, the dynamic-tier performance warning: its text and how it prints. + * All of the warning's wording is here. The functions it names come from + * frontend/lowering/dynamic-tier.ts; this module only formats them, and it + * imports nothing heavy so the CLI's routed cache hit can print a stored + * warning without loading the compiler. */ +import type { SrcLoc } from "../ir/ir.js"; +import type { ScrDiagnostic } from "./diagnostic.js"; + +/** The code of the warning; SC61xx are performance notes, SC60xx divergences. */ +export const DYNAMIC_TIER_CODE = "SC6101"; + +/** At most this many functions are named; the count covers all of them. */ +export const DYNAMIC_TIER_SHOWN = 5; + +/** Why a function counts as likely to run often. */ +export type HotReason = "loop" | "called-in-loop" | "recursive"; + +const HOT_PHRASE: Record = { + loop: "in a loop", + "called-in-loop": "called in a loop", + recursive: "recursive", +}; + +/** Why a function's values are untyped, as the warning words it. */ +export type UntypedCause = + | { kind: "any"; name: string } + | { kind: "json-binding"; name: string } + | { kind: "json" } + | { kind: "javascript" }; + +export function causePhrase(cause: UntypedCause): string { + switch (cause.kind) { + case "any": + return `\`${cause.name}\` is \`any\``; + case "json-binding": + return `\`${cause.name}\` holds an untyped \`JSON.parse\` result`; + case "json": + return "untyped `JSON.parse` result"; + case "javascript": + return "untyped JavaScript file"; + } +} + +export interface DynamicTierEntry { + /** Display name (`advance`, `Parser.next`). */ + name: string; + /** `file:line`, relative to the entry's directory. */ + where: string; + cause: UntypedCause; + hot: HotReason; +} + +export interface DynamicTierPackageCount { + name: string; + functions: number; +} + +/** The SC6101 diagnostic for likely-hot functions with untyped values, or + * null when there are none. `loc` is the first function's declaration. */ +export function dynamicTierDiag( + entries: readonly DynamicTierEntry[], + packages: readonly DynamicTierPackageCount[], + loc: SrcLoc, +): ScrDiagnostic | null { + const n = entries.length; + if (n === 0) return null; + const shown = entries.slice(0, DYNAMIC_TIER_SHOWN); + const lines = [ + `${n} function${n === 1 ? "" : "s"} that likely run${n === 1 ? "s" : ""} often use${n === 1 ? "s" : ""} untyped values, which runs slower than typed code:`, + ...shown.map((e) => ` ${e.name} (${e.where}): ${causePhrase(e.cause)}, ${HOT_PHRASE[e.hot]}`), + ...(n > shown.length ? [` and ${n - shown.length} more`] : []), + ...(packages.length === 0 + ? [] + : [ + ` untyped code in npm packages: ${packages + .map((p) => `${p.name} (${p.functions} function${p.functions === 1 ? "" : "s"})`) + .join(", ")}`, + ]), + ]; + return { + code: DYNAMIC_TIER_CODE, + message: lines.join("\n"), + loc, + hint: "add types to compile these statically; see scriptc coverage --explain-dynamic", + }; +} + +export function isPerformanceCode(code: string): boolean { + return /^SC61\d{2}$/.test(code); +} + +/** The warning as a build prints it: no source excerpt (it names several + * functions), then its closing line. */ +export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): string { + const blocks = diags.map( + (d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`, + ); + return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`; +} diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts index c986fc5b1..59c285ead 100644 --- a/packages/compiler/src/diagnostics/envelope.ts +++ b/packages/compiler/src/diagnostics/envelope.ts @@ -16,6 +16,7 @@ * bumps it. */ import { readFileSync } from "node:fs"; import type { ScrDiagnostic } from "./diagnostic.js"; +import { isPerformanceCode } from "./dynamic-tier.js"; import type { WasmModuleReport } from "../wasm/discover.js"; export const DIAGNOSTICS_SCHEMA = "scriptc-diagnostics"; @@ -25,12 +26,14 @@ export const DIAGNOSTICS_SCHEMA_VERSION = 1; * - invalid-source: the program itself is wrong (TypeScript errors, bad JSON modules) * - unsupported: valid TypeScript/JavaScript scriptc cannot compile yet * - divergence: compiles, but the native program behaves differently from Node + * - performance: compiles and behaves as under Node, but runs slower than typed code would * - environment: project configuration, scriptc's type world, toolchain or target * - internal: a compiler bug */ export type DiagnosticCategory = | "invalid-source" | "unsupported" | "divergence" + | "performance" | "environment" | "internal"; @@ -149,12 +152,13 @@ export interface DiagnosticsEnvelope { /** The divergence band: compiles, but the program can behave differently * from Node. Warnings, never build failures. */ export function isDivergenceCode(code: string): boolean { - return /^SC6\d{3}$/.test(code); + return /^SC60\d{2}$/.test(code); } export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory { const code = diag.code; if (isDivergenceCode(code)) return "divergence"; + if (isPerformanceCode(code)) return "performance"; if (code === "SC0001") return diag.typeWorld === true ? "environment" : "invalid-source"; if (code === "SC0003") return "invalid-source"; if (code === "SC0002" || code === "SC0004") return "environment"; @@ -274,7 +278,7 @@ export function locateDiagnostic( return { code: diag.code, category: categoryOf(diag), - severity: isDivergenceCode(diag.code) ? "warning" : "error", + severity: isDivergenceCode(diag.code) || isPerformanceCode(diag.code) ? "warning" : "error", scope, file: diag.loc.file, line: start.line, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 656c62963..67d171b4f 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -11,6 +11,7 @@ import { writeFile, } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; +import type { ScrDiagnostic } from "../diagnostics/diagnostic.js"; import type { FrontendInputSnapshot } from "../frontend/input-tracker.js"; import { frontendInputsStillMatch, validFrontendInputSnapshot } from "../frontend/input-tracker.js"; import { compilerReleaseVersion } from "../library/sidecar.js"; @@ -113,6 +114,8 @@ interface EarlyExecutableCacheStamp { }; nativeDependencies: NativeArtifactDependency[] | null; native: EarlyExecutableNativeFeatures; + /** The build's performance warnings (SC61xx), which a cache hit repeats. */ + performance?: ScrDiagnostic[]; integrity: string; } @@ -185,6 +188,8 @@ export interface EarlyExecutableCacheHit { executableRestored: boolean; native: EarlyExecutableNativeFeatures; frontend: FrontendInputSnapshot; + /** The performance warnings the build reported (SC61xx), repeated on a hit. */ + performance?: ScrDiagnostic[]; } export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit { @@ -616,6 +621,9 @@ async function readExecutableCache( native: stamp.native, executableRestored, frontend: stamp.frontend, + ...(Array.isArray(stamp.performance) && stamp.performance.length > 0 + ? { performance: stamp.performance } + : {}), ...(ir === null ? {} : { irPath: paths.irPath }), }; } catch { @@ -799,6 +807,9 @@ export async function publishEarlyExecutableCache( }, nativeDependencies: executable === null ? null : result.nativeDependencies!, native: result.native, + ...(result.performance === undefined || result.performance.length === 0 + ? {} + : { performance: result.performance }), }; const stamp: EarlyExecutableCacheStamp = { ...unsigned, integrity: stampIntegrity(unsigned) }; await writeFile(join(stage, "stamp.json"), `${JSON.stringify(stamp)}\n`, { mode: 0o600 }); diff --git a/packages/compiler/src/executable/prepare.ts b/packages/compiler/src/executable/prepare.ts index 4afae7c7d..1771f98f5 100644 --- a/packages/compiler/src/executable/prepare.ts +++ b/packages/compiler/src/executable/prepare.ts @@ -122,6 +122,6 @@ export function prepareExecutableModule( mod: lowered.module!, sourceTexts, stats: lowered.stats, - warnings: lowered.divergences ?? [], + warnings: [...(lowered.divergences ?? []), ...(lowered.performance ?? [])], }; } diff --git a/packages/compiler/src/frontend-process.ts b/packages/compiler/src/frontend-process.ts index a3d59516f..dc2388038 100644 --- a/packages/compiler/src/frontend-process.ts +++ b/packages/compiler/src/frontend-process.ts @@ -8,11 +8,14 @@ try { } catch { // Bytecode caching only speeds up loading. } -const { runFrontendProcess } = await import("./index.js"); +const { collectGarbageUnderHeapPressure, runFrontendProcess } = await import("./index.js"); let input = ""; for await (const chunk of process.stdin) input += String(chunk); const [requestPath, resultPath] = input.split("\n"); -if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined) +if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined) { + // As in the CLI process, lowering and LLVM emission collect the heap as they grow it. + collectGarbageUnderHeapPressure(); await runFrontendProcess(requestPath, resultPath); +} // The frontend's own helpers (the checker process, timers) must not keep this process alive. process.exit(0); diff --git a/packages/compiler/src/frontend/analysis.ts b/packages/compiler/src/frontend/analysis.ts index 43d0aa2bb..776492eb1 100644 --- a/packages/compiler/src/frontend/analysis.ts +++ b/packages/compiler/src/frontend/analysis.ts @@ -106,6 +106,7 @@ export function analyzeWithFrontend( diagnostics: [...preflight, ...lowered.diagnostics], ...(lowered.runtimeFences.length > 0 ? { runtimeFences: lowered.runtimeFences } : {}), ...(lowered.divergences !== undefined ? { divergences: lowered.divergences } : {}), + ...(lowered.performance !== undefined ? { performance: lowered.performance } : {}), ...(lowered.unreached ? { unreached: lowered.unreached } : {}), ...(fe.npmStatic.length > 0 ? { npmStatic: fe.npmStatic } : {}), // --provenance-sources: the per-package attribution inputs (the diff --git a/packages/compiler/src/frontend/lowering/builtins/arguments.ts b/packages/compiler/src/frontend/lowering/builtins/arguments.ts index 5e21f6cb3..7f862f603 100644 --- a/packages/compiler/src/frontend/lowering/builtins/arguments.ts +++ b/packages/compiler/src/frontend/lowering/builtins/arguments.ts @@ -20,6 +20,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { lowerAbsenceProbe } from "../lower-exprs.js"; +import { bodyReadsArguments } from "../../arguments-usage.js"; import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js"; import { voidizedCallback } from "../lower-server.js"; export function optionalStringTags( @@ -238,6 +239,25 @@ export function errorFirstBytesCallback( ); } +/** Whether `type` is an error-first callback's error parameter type: + * Error | null, optionally with undefined, or dyn. */ +export function isErrorFirstErrorType(lowerer: Lowerer, type: IrType): boolean { + if (type.kind === "dyn") return true; + if (type.kind !== "union") return false; + const def = lowerer.unions.get(type.unionId); + return ( + !!def && + def.arms.some((arm) => arm.kind === "nullT") && + def.arms.some((arm) => arm.kind === "object" && arm.className === "%Error") && + def.arms.every( + (arm) => + arm.kind === "nullT" || + arm.kind === "undefinedT" || + (arm.kind === "object" && arm.className === "%Error"), + ) + ); +} + /** An error-first callback (error, value) whose error parameter is * Error | null or dyn and whose value parameter is dyn or accepted by * `acceptValue`; `values` names the accepted values in the refusal. */ @@ -248,6 +268,17 @@ export function errorFirstCallback( acceptValue: (value: IrType) => boolean, values: string, ): IrExpr { + // A literal here is called only by the callback adapter, which passes + // null as the error whenever it passes a result (lowerUndefinedResultBody). + // A named function expression can also call itself, and a function + // expression can reach itself through `arguments.callee`. + let literal: ts.Expression = node; + while (ts.isParenthesizedExpression(literal)) literal = literal.expression; + if ( + ts.isArrowFunction(literal) || + (ts.isFunctionExpression(literal) && literal.name === undefined && !bodyReadsArguments(literal)) + ) + lowerer.errorFirstLiterals.add(literal); let callback = lowerer.lowerExpr(node); if (callback.type.kind === "dyn") { callback = { @@ -261,24 +292,8 @@ export function errorFirstCallback( lowerer.unsupported("SC1090", node, `${api} callbacks must accept at most (error, buffer)`); } const error = callback.type.params[0]; - if (error !== undefined && error.kind !== "dyn") { - if (error.kind !== "union") { - lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`); - } - const def = lowerer.unions.get(error.unionId); - const valid = - !!def && - def.arms.some((arm) => arm.kind === "nullT") && - def.arms.some((arm) => arm.kind === "object" && arm.className === "%Error") && - def.arms.every( - (arm) => - arm.kind === "nullT" || - arm.kind === "undefinedT" || - (arm.kind === "object" && arm.className === "%Error"), - ); - if (!valid) - lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`); - } + if (error !== undefined && !isErrorFirstErrorType(lowerer, error)) + lowerer.unsupported("SC1090", node, `${api} callback error parameters must be Error | null`); const value = callback.type.params[1]; if (value !== undefined && value.kind !== "dyn" && !acceptValue(value)) { lowerer.unsupported( diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts index 4de69a3f6..d147a6208 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem-calls.ts @@ -1,6 +1,7 @@ import { dynUndefinedExpr, boolLit, numLit, varRef } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; +import { lowerFsPath } from "./fs-paths.js"; import { locOf } from "../../program.js"; import { type BuiltinModuleFn, @@ -231,7 +232,7 @@ export function lowerFsNumericOpenCall( "use an inline bitwise OR of fs.constants.O_RDONLY/O_WRONLY/O_RDWR/O_CREAT/O_EXCL/O_NOFOLLOW/O_NONBLOCK/O_TRUNC/O_APPEND", ); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const mode = expr.arguments.nodes[2] ? lowerer.lowerExprExpecting(expr.arguments.nodes[2]!, F64) : ({ kind: "numLit", value: 0o666, type: F64, loc } satisfies IrExpr); @@ -640,7 +641,9 @@ export function lowerFsEncodedRead( return null; } const sourceType = descriptor ? F64 : STRING; - const source = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, sourceType); + const source = descriptor + ? lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, sourceType) + : lowerFsPath(lowerer, expr.arguments.nodes[0]!); const encoding: IrExpr = optionsType.kind === "string" ? lowerer.lowerExprExpecting(options, STRING) @@ -742,7 +745,7 @@ export function lowerFsOpendirCall( ) { lowerer.noLowering(`${member} with ${expr.arguments.nodes.length} arguments`, expr); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); let bufferSize: IrExpr = { kind: "numLit", value: 32, type: F64, loc }; const optsNode = expr.arguments.nodes[1]; if (optsNode && !(ts.isIdentifier(optsNode) && optsNode.text === "undefined")) { @@ -812,7 +815,7 @@ export function lowerFsMkdirSyncCall( ); } const { recursive, modeNode } = options; - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const mode: IrExpr | null = modeNode ? lowerer.lowerExprExpecting(modeNode, F64) : null; if (!recursive) { return mode @@ -891,7 +894,7 @@ export function lowerFsRemoveCall( "the recursive/force flags must be boolean literals; maxRetries/retryDelay are numbers; other options have no lowering", ); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const recursive = boolLit(opts.bools["recursive"] === true, loc); const force = boolLit(opts.bools["force"] === true, loc); @@ -949,7 +952,7 @@ export function lowerFsWriteBytesCall( "byte writes take Uint8Array/Buffer data", ); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const data = lowerer.lowerExprExpecting(expr.arguments.nodes[1]!, BYTES_U8); if (bi.module === "fs/promises") { return { @@ -989,7 +992,7 @@ export function lowerFsWriteOptionsCall( // values in object-literal order before issuing the write; otherwise a // call/getter statically typed as the accepted utf8 literal can vanish. const finishWrite = (optionValues: WriteOptionValue[]): IrExpr => { - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const data = lowerer.lowerExprExpecting(expr.arguments.nodes[1]!, STRING); const pathLocal = lowerer.declareHiddenLocal("%writePath", STRING); const dataLocal = lowerer.declareHiddenLocal("%writeData", STRING); diff --git a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts index 49875e004..0e1c062c5 100644 --- a/packages/compiler/src/frontend/lowering/builtins/filesystem.ts +++ b/packages/compiler/src/frontend/lowering/builtins/filesystem.ts @@ -1,6 +1,7 @@ import { dynUndefinedExpr, boolLit, numLit, varRef } from "../../../ir/build.js"; import * as ts from "../../ts7/adapter.js"; import { type Lowerer, ladderFenceExpr } from "../lowerer.js"; +import { lowerFsPath } from "./fs-paths.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { builtinModuleFnOf, @@ -513,7 +514,7 @@ export function lowerStringOrBytesWrite( kind: "call", callee: helper, args: [ - lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING), + lowerFsPath(lowerer, call.arguments.nodes[0]!), lowerer.lowerExprExpecting(call.arguments.nodes[1]!, dataType), ], type: VOID, @@ -1376,7 +1377,7 @@ export function lowerFsReaddirTypesCall( ) { fence(); } - const path = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, call.arguments.nodes[0]!); return { kind: "libCall", fn: promiseForm ? "fsp.readdirTypes" : "fs.readdirTypesSync", @@ -1405,7 +1406,7 @@ export function lowerFsStatOptionsCall( const spelling = noFollow ? "lstatSync" : "statSync"; const plainFn: IrLibFn = noFollow ? "fs.lstatSync" : "fs.statSync"; const optsNode = call.arguments.nodes[1]!; - const path = lowerer.lowerExprExpecting(call.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, call.arguments.nodes[0]!); // The path binds first, then the options' effects, then the stat runs. const plainAfter = (effect: IrExpr | null): IrExpr => { if (effect === null) return { kind: "libCall", fn: plainFn, args: [path], type: STATS_T, loc }; diff --git a/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts new file mode 100644 index 000000000..38583025d --- /dev/null +++ b/packages/compiler/src/frontend/lowering/builtins/fs-paths.ts @@ -0,0 +1,225 @@ +/* fs path arguments given as file: URLs. Node converts a URL path with + * fileURLToPath inside each function (getValidatedPath), after every + * argument has been evaluated, and validates some arguments before the + * path and others after it. A URL-typed path lowers to the same + * conversion (url.fileURLToPathUrl). The later arguments through the last + * one with side effects are evaluated into hidden locals before the + * conversion, in source order, so they run even when it throws and each + * reads what it reads in Node. The conversion keeps Node's error order when every + * earlier path is a URL too and each argument Node validates before the + * path is absent or a literal that passes that validation; other URL + * forms fence with SC2020, as do the fs.promises functions, whose + * conversion errors are rejections rather than throws, and existsSync, + * which answers false for a URL it cannot convert. */ +import * as ts from "../../ts7/adapter.js"; +import { type Lowerer } from "../lowerer.js"; +import { locOf } from "../../program.js"; +import { sideEffectFreeOptionValue } from "../surfaces.js"; +import { stripTypeWrappers } from "../typed-arguments.js"; +import { type IrExpr, type IrStmt, STRING, URL_T } from "../../../ir/ir.js"; + +/** The fs functions whose path parameters accept a URL: the path + * parameter indexes, and whether Node validates the paths before every + * other argument (otherwise the options come first). */ +const FS_URL_PATHS: Readonly< + Record +> = { + accessSync: { paths: [0], pathsFirst: true }, + appendFileSync: { paths: [0], pathsFirst: false }, + chmodSync: { paths: [0], pathsFirst: true }, + chownSync: { paths: [0], pathsFirst: true }, + copyFileSync: { paths: [0, 1], pathsFirst: true }, + lchownSync: { paths: [0], pathsFirst: true }, + lstatSync: { paths: [0], pathsFirst: true }, + mkdirSync: { paths: [0], pathsFirst: false }, + mkdtempSync: { paths: [0], pathsFirst: false }, + openSync: { paths: [0], pathsFirst: true }, + opendirSync: { paths: [0], pathsFirst: true }, + readFileSync: { paths: [0], pathsFirst: false }, + readdirSync: { paths: [0], pathsFirst: false }, + realpathSync: { paths: [0], pathsFirst: false }, + "realpathSync.native": { paths: [0], pathsFirst: false }, + renameSync: { paths: [0, 1], pathsFirst: true }, + rmSync: { paths: [0], pathsFirst: false }, + rmdirSync: { paths: [0], pathsFirst: true }, + statSync: { paths: [0], pathsFirst: true }, + truncateSync: { paths: [0], pathsFirst: true }, + unlinkSync: { paths: [0], pathsFirst: true }, + writeFileSync: { paths: [0], pathsFirst: false }, +}; + +/** The fs.promises functions with path parameters, by path indexes. */ +const FSP_PATHS: Readonly> = { + access: [0], + appendFile: [0], + chmod: [0], + chown: [0], + copyFile: [0, 1], + lchown: [0], + lstat: [0], + mkdir: [0], + mkdtemp: [0], + open: [0], + opendir: [0], + readFile: [0], + readdir: [0], + realpath: [0], + rename: [0, 1], + rm: [0], + rmdir: [0], + stat: [0], + truncate: [0], + unlink: [0], + writeFile: [0], +}; + +function isUrlArgument(lowerer: Lowerer, node: ts.Expression | undefined): boolean { + return node !== undefined && lowerer.mapTypeOf(lowerer.typeOf(node))?.kind === "url"; +} + +/** A literal that Node's option validation accepts before it reads the + * path: a string (the checker admits only the declared encodings, flags + * and type names), a boolean, an integer in [0, 2^31 - 1] (every numeric + * option of these functions accepts that range), undefined or null, or an + * object literal of those. */ +function preValidatedLiteral(node: ts.Expression): boolean { + let e = node; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isStringLiteralLike(e)) return true; + if ( + e.kind === ts.SyntaxKind.TrueKeyword || + e.kind === ts.SyntaxKind.FalseKeyword || + e.kind === ts.SyntaxKind.NullKeyword + ) + return true; + if (ts.isIdentifier(e)) return e.text === "undefined"; + if (ts.isNumericLiteral(e)) { + const value = Number(e.text); + return Number.isInteger(value) && value >= 0 && value <= 2147483647; + } + if (ts.isObjectLiteralExpression(e)) { + return e.properties.nodes.every( + (p) => + ts.isPropertyAssignment(p) && + !ts.isComputedPropertyName(p.name) && + preValidatedLiteral(p.initializer), + ); + } + return false; +} + +/** SC2020 when the call passes a URL path in a form whose conversion + * order would differ from Node's (see the file comment). */ +function checkUrlPaths( + lowerer: Lowerer, + call: ts.CallExpression, + module: string, + member: string, +): void { + const args = call.arguments.nodes; + const name = module === "fs/promises" ? `fs.promises.${member}` : member; + const hint = "convert the URL first: fileURLToPath(url) from node:url"; + if (module === "fs/promises") { + if ((FSP_PATHS[member] ?? []).some((i) => isUrlArgument(lowerer, args[i]))) + lowerer.noLowering(`${name} with a URL path`, call, hint); + return; + } + if (member === "existsSync" && isUrlArgument(lowerer, args[0])) + lowerer.noLowering(`${name} with a URL path`, call, hint); + const row = FS_URL_PATHS[member]; + if (!row) return; + const first = row.paths.find((i) => isUrlArgument(lowerer, args[i])); + if (first === undefined) return; + if (args.some(ts.isSpreadElement)) + lowerer.noLowering(`${name} with spread arguments and a URL path`, call); + // The runtime checks a string path after the conversion, so a string + // path Node checks first cannot precede the URL. + if (row.paths.some((i) => i < first && args[i] !== undefined)) + lowerer.noLowering(`${name} with a string path before a URL path`, call, hint); + // Every argument from the URL through the last one with effects is + // evaluated ahead of the conversion, in source order: a later argument's + // effect (an assignment, say) must not change what an earlier one read. + let last = first; + for (let i = first + 1; i < args.length; i++) if (!sideEffectFreeOptionValue(args[i]!)) last = i; + const staged: ts.Expression[] = []; + for (let i = first + 1; i <= last; i++) { + // Keyed without type-only wrappers (`satisfies`, `as`, `!`), so a + // lowering that reads through them finds the saved value. + const e = stripTypeWrappers(args[i]!); + // Literals are read by their members, so a whole-value slot cannot + // hold them. + if (ts.isObjectLiteralExpression(e) || ts.isArrayLiteralExpression(e)) { + lowerer.noLowering( + sideEffectFreeOptionValue(e) + ? `${name} with a URL path and a literal before a later argument that has effects` + : `${name} with a URL path and a later literal that has effects`, + e, + hint, + ); + } + staged.push(e); + } + if (staged.length > 0) { + lowerer.stagedAfterPath.set(args[first]!, staged); + for (const e of staged) lowerer.stagedArguments.set(e, null); + } + if (row.pathsFirst) return; + // writeFileSync/appendFileSync's data is checked after the path. + const data = member === "writeFileSync" || member === "appendFileSync" ? 1 : -1; + for (let i = 0; i < args.length; i++) { + if (row.paths.includes(i) || i === data || preValidatedLiteral(args[i]!)) continue; + lowerer.noLowering(`${name} with a URL path and options that are not literals`, args[i]!, hint); + } +} + +/** Fences URL paths that cannot convert in Node's order: the check every + * fs and fs.promises call runs before lowering its arguments. */ +export function checkFsUrlPaths( + lowerer: Lowerer, + call: ts.CallExpression, + bi: { module: string; member: string }, +): void { + if (bi.module === "fs" || bi.module === "fs/promises") + checkUrlPaths(lowerer, call, bi.module, bi.member); +} + +/** An fs path argument as a string: a URL converts with fileURLToPath's + * conversion (url.fileURLToPathUrl), which throws Node's errors for other + * schemes, hosts and encoded separators. */ +export function lowerFsPath(lowerer: Lowerer, node: ts.Expression): IrExpr { + if (!isUrlArgument(lowerer, node)) return lowerer.lowerExprExpecting(node, STRING); + const loc = locOf(node); + const url = lowerer.lowerExprExpecting(node, URL_T); + const staged = lowerer.stagedAfterPath.get(node); + if (staged === undefined) + return { kind: "libCall", fn: "url.fileURLToPathUrl", args: [url], type: STRING, loc }; + // The URL and the later arguments with effects, in source order, then + // the conversion. + const saved = lowerer.declareHiddenLocal("%fsUrl", URL_T); + const stmts: IrStmt[] = [{ kind: "varDecl", localId: saved.id, init: url, loc }]; + for (const arg of staged) { + lowerer.stagedArguments.delete(arg); + const value = lowerer.lowerExpr(arg); + const slot = lowerer.declareHiddenLocal("%fsArg", value.type); + stmts.push({ kind: "varDecl", localId: slot.id, init: value, loc: locOf(arg) }); + lowerer.stagedArguments.set(arg, { localId: slot.id, type: value.type }); + } + return { + kind: "seqExpr", + stmts, + result: { + kind: "libCall", + fn: "url.fileURLToPathUrl", + args: [{ kind: "varRef", localId: saved.id, type: URL_T, loc }], + type: STRING, + loc, + }, + type: STRING, + loc, + }; +} + +/** Whether `index` is a path parameter of the fs function `member`. */ +export function isFsPathParam(member: string, index: number): boolean { + return FS_URL_PATHS[member]?.paths.includes(index) === true; +} diff --git a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts index 970a474c7..e5446291f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/module-calls.ts +++ b/packages/compiler/src/frontend/lowering/builtins/module-calls.ts @@ -25,6 +25,7 @@ import { lowerFsWriteOptionsCall, lowerFsReadPathCall, } from "./filesystem-calls.js"; +import { checkFsUrlPaths, isFsPathParam, lowerFsPath } from "./fs-paths.js"; import { lowerBufferEncodingCall } from "./text-codecs.js"; import { lowerUtilTypeCall } from "./util-types.js"; import { InternalCompilerError } from "../../../errors.js"; @@ -58,6 +59,7 @@ export function lowerBuiltinModuleCall( loc: SrcLoc, ): IrExpr { const name = ts.getTextOfNode(expr.expression); + checkFsUrlPaths(lowerer, expr, bi); if (bi.module === "util/types") { return lowerUtilTypeCall(lowerer, expr, bi, loc, name); } @@ -240,7 +242,7 @@ export function lowerBuiltinModuleCall( expr.arguments.nodes.length === 1 && lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind !== "f64" ) { - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); return { kind: "libCall", fn: "fs.readFileSyncBytes", args: [path], type: BYTES_U8, loc }; } if ( @@ -308,7 +310,7 @@ export function lowerBuiltinModuleCall( if (expr.arguments.nodes.length < 1 || expr.arguments.nodes.length > 2) { lowerer.noLowering(`${bi.member} with ${expr.arguments.nodes.length} arguments`, expr); } - const path = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, STRING); + const path = lowerFsPath(lowerer, expr.arguments.nodes[0]!); const second: IrExpr = expr.arguments.nodes[1] ? lowerer.lowerExprExpecting(expr.arguments.nodes[1], F64) : { kind: "numLit", value: 0, type: F64, loc }; @@ -470,7 +472,11 @@ export function lowerBuiltinModuleCall( ); args = lowerer.completeArgs(expr.arguments.nodes, shapes, loc, expr); } else { - args = expr.arguments.nodes.map((a, i) => lowerer.lowerExprExpecting(a, fn.params[i])); + args = expr.arguments.nodes.map((a, i) => + bi.module === "fs" && isFsPathParam(bi.member, i) + ? lowerFsPath(lowerer, a) + : lowerer.lowerExprExpecting(a, fn.params[i]), + ); for (let i = args.length; i < fn.params.length; i++) { const dflt = fn.defaults![i - required]!; args.push({ kind: "strLit", value: dflt, type: STRING, loc }); diff --git a/packages/compiler/src/frontend/lowering/builtins/strings.ts b/packages/compiler/src/frontend/lowering/builtins/strings.ts index 54ab22c05..4b4a8b29b 100644 --- a/packages/compiler/src/frontend/lowering/builtins/strings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/strings.ts @@ -3,7 +3,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { locOf } from "../../program.js"; import { lowerOptionalArgument, lowerStringSearchArgument } from "../optional-arguments.js"; -import { lowerToNumberArgument } from "../lower-exprs.js"; +import { lowerToNumberArguments } from "../lower-exprs.js"; import { BOOL, DYN, @@ -115,11 +115,19 @@ export function lowerStringStaticCall( const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64)); return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; } - // Each code converts with ToNumber: a missing value is NaN (code unit 0 - // for fromCharCode, a RangeError for fromCodePoint), exactly Node. - const elems = call.arguments.nodes.map((a) => lowerToNumberArgument(lowerer, a)); + // Each code converts with ToNumber after every argument is evaluated: a + // missing value is NaN (code unit 0 for fromCharCode, a RangeError for + // fromCodePoint), exactly Node. + const { stmts, values: elems } = lowerToNumberArguments(lowerer, call.arguments.nodes); const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc }; - return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc }; + const result: IrExpr = { + kind: "libCall", + fn: `string.${member}`, + args: [packed], + type: STRING, + loc, + }; + return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: STRING, loc }; } /** `String.fromCharCode.apply(thisArg, codes)`, the btoa chunking idiom diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts index 4e1ece0e9..34343ad27 100644 --- a/packages/compiler/src/frontend/lowering/call-signatures.ts +++ b/packages/compiler/src/frontend/lowering/call-signatures.ts @@ -546,9 +546,17 @@ export function paramShapes( /** A declared type that is (or is a union over) a bare type parameter * or an instantiation of a generic alias/interface over type arguments. */ -function mentionsTypeParameter(lowerer: Lowerer, t: ts.Type): boolean { +function mentionsTypeParameter( + lowerer: Lowerer, + t: ts.Type, + seen: Set = new Set(), +): boolean { if (t.flags & ts.TypeFlags.TypeParameter) return true; - const mentions = (part: ts.Type): boolean => mentionsTypeParameter(lowerer, part); + // A recursive alias (`type Child = string | Child[]`) reaches itself + // through its own type arguments; a type already on the walk adds nothing. + if (seen.has(t)) return false; + seen.add(t); + const mentions = (part: ts.Type): boolean => mentionsTypeParameter(lowerer, part, seen); if (t.isUnionType()) return ts.constituentTypes(t).some(mentions); if (t.getAliasTypeArguments().some(mentions)) return true; return ( diff --git a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts index 645ff8d2c..606d10d56 100644 --- a/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/caught-value-dispatch.ts @@ -5,6 +5,7 @@ import { DYN, type IrExpr, type IrFunction, + type IrStmt, type IrType, type SrcLoc, } from "../../ir/ir.js"; @@ -59,28 +60,48 @@ export class CaughtValueDispatch { loc, }; lowerer.liftedFns.push(this.helper); + lowerer.shareHelper(this.helper); changed = true; } const helper = this.helper; + let added = false; for (const className of this.thrownClasses) { if (this.convertedClasses.has(className)) continue; this.convertedClasses.add(className); + added = true; + } + if (added) { + // A subclass is tested before its ancestors, and classes of one depth + // by name, so the tests do not depend on the order the census met them. + const depth = (className: string): number => { + let count = 0; + for (let info = lowerer.classes.get(className)?.base; info; info = info.base) count++; + return count; + }; + const ordered = [...this.convertedClasses].sort( + (left, right) => depth(right) - depth(left) || (left < right ? -1 : left > right ? 1 : 0), + ); const loc = helper.loc; const value = varRef("caught", CAUGHT, loc); - const type: IrType = { kind: "object", className }; - helper.body.unshift({ - kind: "if", - cond: { kind: "caughtTest", value, test: "instanceof", className, type: BOOL, loc }, - then: [ - { - kind: "return", - value: lowerer.coerceToExpected({ kind: "caughtNarrow", value, type, loc }, DYN), + helper.body = [ + ...ordered.map((className): IrStmt => { + const type: IrType = { kind: "object", className }; + return { + kind: "if", + cond: { kind: "caughtTest", value, test: "instanceof", className, type: BOOL, loc }, + then: [ + { + kind: "return", + value: lowerer.coerceToExpected({ kind: "caughtNarrow", value, type, loc }, DYN), + loc, + }, + ], + else_: null, loc, - }, - ], - else_: null, - loc, - }); + }; + }), + helper.body[helper.body.length - 1]!, + ]; changed = true; } for (const fn of consumers) { diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts index dfb8bd7a5..b717452c2 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts @@ -33,6 +33,7 @@ function context() { classes: new Map(), liftedFns, liftedFnNamed: (name: string | undefined) => liftedFns.find((fn) => fn.name === name), + shareHelper: () => {}, subclassLinks: 0, constructedClassCount: () => -1, diags: [], @@ -475,3 +476,85 @@ test("normalizes base capsules before dispatching each derived member once", () ], }); }); + +test("class branches read their exact class's bag, also for classes with subclasses", () => { + const { lowerer, liftedFns } = context(); + const base = { + def: { name: "Base", fields: [{ name: "value", type: F64 }], tracksOwnFields: true }, + fields: new Map([["value", F64]]), + methods: new Map(), + base: null, + subclasses: [], + } as unknown as ClassInfo; + const child = { + def: { + name: "Child", + base: "Base", + fields: [{ name: "value", type: F64 }], + tracksOwnFields: true, + }, + fields: new Map([["value", F64]]), + methods: new Map(), + base, + subclasses: [], + } as unknown as ClassInfo; + base.subclasses.push(child); + lowerer.classes.set("Base", base); + lowerer.classes.set("Child", child); + lowerer.classCanBeConstructed = () => true; + lowerer.isSubclassOf = (name, parent) => name === "Child" && parent === "Base"; + lowerer.coerceToExpected = (value, type) => + type.kind === "dyn" ? { kind: "dynFrom", value, type, loc } : value; + const boxes = fn( + "boxes", + [base, child].map((info) => + statement({ + kind: "dynFrom", + value: { + kind: "varRef", + localId: info.def.name, + type: { kind: "object", className: info.def.name }, + loc, + }, + type: DYN, + loc, + }), + ), + ); + const read = fn("read", [ + statement({ + kind: "dynKeyGet", + value: variable("value"), + key: literal("value"), + type: DYN, + loc, + }), + ]); + const dispatch = new ClassDynamicDispatch(); + for (let pass = 0; pass < 4 && dispatch.process(lowerer, [boxes, read, ...liftedFns]); pass++); + dispatch.finalize(lowerer); + const property = liftedFns.find((helper) => helper.name.startsWith("%dyn.class.property."))!; + // The class branches; the fallback for other receivers keeps the + // dispatch over every class. + const branches = property.body.filter( + (statement) => + statement.kind === "if" && + statement.cond.kind === "libCall" && + statement.cond.fn === "dyn.typedRefIs", + ); + expect(branches).toHaveLength(2); + const text = JSON.stringify(branches); + expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Base"'); + expect(text).toContain('"callee":"%class.branchPropertiesOrEmpty:Child"'); + expect(text).not.toContain('"callee":"%dyn.class.readPropertiesOrEmpty"'); + const bag = liftedFns.find((helper) => helper.name === "%class.branchPropertiesOrEmpty:Base")!; + // The bag is the receiver's own field; an absent bag reads as a fresh, + // unstored empty object. + expect(bag.params[0]!.type).toEqual({ kind: "object", className: "Base" }); + expect(JSON.stringify(bag.body)).toContain('"className":"Base","field":"%dynProperties"'); + expect(JSON.stringify(bag.body)).not.toContain('"kind":"fieldSet"'); + expect(bag.body[0]).toMatchObject({ + kind: "if", + then: [{ kind: "return", value: { kind: "dynObjLit", fields: [] } }], + }); +}); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index 7d00eb89e..a41751f01 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -281,6 +281,7 @@ export function classInstanceOf( classValue?: IrExpr, ): IrExpr { const name = `%dyn.class.${classValue ? "valueInstanceof" : "instanceof"}:${info.def.name}`; + lowerer.shareHelper(name); const params = [ { localId: "value", name: "value", type: DYN }, ...(classValue ? [{ localId: "class", name: "class", type: classValue.type }] : []), @@ -386,8 +387,36 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] { return body; } +/** `value` with the class branch's checked receiver (`p.0` as a dynamic + * value, possibly cast to the branch's class) replaced by `receiver`, the + * typed receiver of a per-class helper. */ +function withTypedReceiver(value: IrExpr, receiver: IrExpr): IrExpr { + const checked = (e: IrExpr): boolean => + e.kind === "dynCheck" && e.value.kind === "varRef" && e.value.localId === "p.0"; + return transformExpr(value, { + stmt: (stmt) => stmt, + expr: (expr) => + (expr.kind === "downcast" && + checked(expr.value) && + expr.type.kind === "object" && + receiver.type.kind === "object" && + expr.type.className === receiver.type.className) || + checked(expr) + ? receiver + : expr, + }); +} + +/** Re-adds the entries of `set` after the first `from` sorted. */ +function sortAdded(set: Set, from: number): void { + const added = [...set].slice(from).sort(); + for (const name of added) set.delete(name); + for (const name of added) set.add(name); +} + export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction { const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties"; + lowerer.shareHelper(name); const existing = lowerer.liftedFnNamed(name); if (existing) return existing; const helper: IrFunction = { @@ -427,6 +456,8 @@ export function classForInHasKey( loc: SrcLoc, ): IrExpr { const name = "%dyn.class.forInHasKey"; + lowerer.shareHelper(name); + lowerer.shareHelper("%dyn.class.forInBagHas"); if (!lowerer.liftedFns.some((fn) => fn.name === name)) { // The read-only table: a recheck never creates an instance's table, which // is undefined while the instance has no own dynamic properties. @@ -728,7 +759,11 @@ export class ClassDynamicDispatch { private readonly readPropertyBags: { helper: IrFunction; initialize: Extract; + orEmpty?: boolean; }[] = []; + /** Read-only bags of exactly one class (exactPropertyBag), by class and + * whether an absent bag reads as a fresh empty object. */ + private readonly exactPropertyBags = new Map(); private readonly generated = new Set(); /** Protocol dispatch helpers and callee reads whose receivers normalize. */ private readonly protocols: IrFunction[] = []; @@ -801,6 +836,8 @@ export class ClassDynamicDispatch { else if (type.kind === "func") discover(type.ret); else if (type.kind === "promise") discover(type.inner); }; + const boxedBefore = this.boxed.size; + const boxedConstructorsBefore = this.boxedConstructors.size; for (const fn of functions) everyStmtList(fn.body, { stmt: (statement) => { @@ -895,6 +932,10 @@ export class ClassDynamicDispatch { return true; }, }); + // The classes this pass found join the tables by name, not in the order + // the scan met them. + sortAdded(this.boxed, boxedBefore); + sortAdded(this.boxedConstructors, boxedConstructorsBefore); changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed; if (this.boxed.size === 0 && rewrite.size === 0) return changed; const converting = this.convertingHints(lowerer); @@ -2557,7 +2598,6 @@ export class ClassDynamicDispatch { if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue; dispatch.classes.add(plan.key); const loc = dispatch.source.loc; - const value = varRef("p.0", DYN, loc); const { receiver, condition } = this.propertyReceiver(plan, loc); if (isClassCallback(lowerer, info, dispatch.source.method)) { const prototypeMethod = observedPrototypeMethod( @@ -2567,7 +2607,28 @@ export class ClassDynamicDispatch { loc, receiver, ); - const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); + const methodKey: IrExpr = { + kind: "strLit", + value: dispatch.source.method, + type: STRING, + loc, + }; + const bagHas: IrExpr = { + kind: "libCall", + fn: "dyn.hasKey", + args: [bag, methodKey], + type: BOOL, + loc, + }; + // An observed prototype slot answers through the bag when the bag + // holds the name (own or through its prototype). A bag read as a + // fresh empty object (an instance with no own dynamic properties + // whose bag initializer predates the class's prototype data) does + // not, so the method then comes from the class's prototype data. + const prototype = prototypeMethod + ? classPrototypeData(lowerer, info, loc, receiver) + : null; dispatch.callback.body.unshift({ kind: "if", cond: condition, @@ -2576,18 +2637,7 @@ export class ClassDynamicDispatch { kind: "return", value: { kind: "ternary", - cond: prototypeMethod - ? { kind: "boolLit", value: true, type: BOOL, loc } - : { - kind: "libCall", - fn: "dyn.hasKey", - args: [ - bag, - { kind: "strLit", value: dispatch.source.method, type: STRING, loc }, - ], - type: BOOL, - loc, - }, + cond: prototype ? { kind: "boolLit", value: true, type: BOOL, loc } : bagHas, then: { kind: "dynObjLit", fields: [ @@ -2595,8 +2645,17 @@ export class ClassDynamicDispatch { key: { kind: "strLit", value: "value", type: STRING, loc }, value: { kind: "dynKeyGet", - value: bag, - key: { kind: "strLit", value: dispatch.source.method, type: STRING, loc }, + value: prototype + ? { + kind: "ternary", + cond: bagHas, + then: bag, + else_: prototype, + type: DYN, + loc, + } + : bag, + key: methodKey, type: DYN, loc, }, @@ -2806,10 +2865,19 @@ export class ClassDynamicDispatch { dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); + // The branch checks its receiver once: each use of the checked + // receiver would repeat the check. + const local = `receiver.${dispatch.fn.locals.length}`; + dispatch.fn.locals.push({ + id: local, + name: "receiver", + type: receiver.type, + mutable: false, + }); const before = lowerer.diags.length; let branch: IrStmt[]; try { - branch = this.propertyBody(lowerer, dispatch, info, receiver); + branch = this.propertyBody(lowerer, dispatch, info, varRef(local, receiver.type, loc)); } catch (error) { if (!(error instanceof PoisonError) || !info.decl) throw error; const fence = lowerer.deferToRuntimeFence(before, info.decl, { kind: "statement" }); @@ -2819,7 +2887,7 @@ export class ClassDynamicDispatch { dispatch.fn.body.unshift({ kind: "if", cond: condition, - then: branch, + then: [{ kind: "varDecl", localId: local, init: receiver, loc }, ...branch], else_: null, loc, }); @@ -2919,11 +2987,7 @@ export class ClassDynamicDispatch { !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( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = symbolMemberKey(lowerer, info, name, loc); const fn = dispatch.probe === "in" @@ -2949,11 +3013,7 @@ export class ClassDynamicDispatch { { kind: "return", value: { kind: "boolLit", value: true, type: BOOL, loc }, loc }, ]; } - const bag = this.readPropertyBagOrEmpty( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = name.startsWith("sym:") || name.startsWith("%symbol:"); const fn = dispatch.probe === "in" @@ -3023,9 +3083,8 @@ export class ClassDynamicDispatch { } const branches: IrStmt[] = []; if (!dispatch.write && !dispatch.reflect && !info.def.tracksOwnFields) { - const value = varRef("p.0", DYN, loc); const key = varRef("p.key", DYN, loc); - const bag = this.readPropertyBagOrEmpty(lowerer, value, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); branches.push({ kind: "if", cond: { @@ -4051,6 +4110,98 @@ export class ClassDynamicDispatch { return helper; } + /** The read-only bag a class branch reads for its receiver: the + * receiver's own field when the branch selected the receiver's exact + * class, otherwise the dispatch over every class (readPropertyBag, or + * readPropertyBagOrEmpty with `orEmpty`). */ + private branchPropertyBag( + lowerer: Lowerer, + info: ClassInfo, + receiver: IrExpr, + orEmpty: boolean, + loc: SrcLoc, + ): IrExpr { + const helper = this.exactPropertyBag(lowerer, info, receiver, orEmpty, loc); + if (helper) return { kind: "call", callee: helper.name, args: [receiver], type: DYN, loc }; + return orEmpty + ? this.readPropertyBagOrEmpty(lowerer, lowerer.coerceToExpected(receiver, DYN), loc) + : this.readPropertyBag(lowerer, receiver, loc); + } + + /** `%class.branchProperties[OrEmpty]:(receiver)`: the read-only bag of + * an instance of exactly `info`. A class branch of the dispatch helpers + * selects a canonical receiver (normalizeDispatches gives an instance its + * actual class's capsule), so its branch for `info` sees only instances + * of `info`; instances of its subclasses take their own branches. Such a + * receiver needs neither the dispatch over every class nor a boxed copy of + * itself. Null + * when `receiver` is not typed as `info`, when a runtime or builtin class + * in the family keeps its own property contract, or before the class has + * a bag. */ + private exactPropertyBag( + lowerer: Lowerer, + info: ClassInfo, + receiver: IrExpr, + orEmpty: boolean, + loc: SrcLoc, + ): IrFunction | null { + const type = receiver.type; + if (type.kind !== "object" || type.className !== info.def.name) return null; + for (let current: ClassInfo | null = info; current; current = current.base) + if ( + current.def.runtime || + current.builtinEmitter || + current.builtinStream || + current.builtinError + ) + return null; + const key = `${type.className}:${orEmpty}`; + const existing = this.exactPropertyBags.get(key); + if (existing) return existing; + const initializers = this.bagInitializers.get(type.className); + if (!initializers) return null; + const self = varRef("p.0", type, loc); + const bag: IrExpr = { + kind: "fieldGet", + obj: self, + className: type.className, + field: PROPERTY_BAG, + type: DYN, + loc, + }; + const initialize: Extract = { + ...initializers[0]!, + obj: self, + value: withTypedReceiver(initializers[0]!.value, self), + loc, + }; + const helper: IrFunction = { + name: `%class.branchProperties${orEmpty ? "OrEmpty" : ""}:${type.className}`, + params: [{ localId: "p.0", name: "value", type }], + returnType: DYN, + locals: [{ id: "p.0", name: "value", type, mutable: false }], + loc, + body: [ + { + kind: "if", + cond: { kind: "dynTest", test: "undefined", value: bag, type: BOOL, loc }, + then: [initialize], + else_: null, + loc, + }, + { kind: "return", value: bag, loc }, + ], + }; + // Initializers gain their prototype as subclasses become constructible; + // finalize then turns the lazy store into the read's result. + initializers.push(initialize); + this.exactPropertyBags.set(key, helper); + this.readPropertyBags.push({ helper, initialize, orEmpty }); + this.generated.add(helper); + lowerer.liftedFns.push(helper); + 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 @@ -4130,9 +4281,11 @@ export class ClassDynamicDispatch { * prototype-backed bag is produced for this read only. A published object * cannot take the store, and Node's reads never write. */ finalize(lowerer?: Lowerer): void { - for (const { helper, initialize } of this.readPropertyBags) { + // Each table serves every site that reaches it. + if (lowerer) for (const fn of this.generated) lowerer.shareHelper(fn); + for (const { helper, initialize, orEmpty } of this.readPropertyBags) { const absent = helper.body[0]; - if (isEmptyBag(initialize.value)) helper.body.shift(); + if (isEmptyBag(initialize.value) && !orEmpty) helper.body.shift(); else if (absent?.kind === "if") absent.then = [{ kind: "return", value: initialize.value, loc: initialize.loc }]; } @@ -4714,7 +4867,7 @@ export class ClassDynamicDispatch { cond: this.readBagHas( lowerer, "dyn.hasKey", - this.readPropertyBag(lowerer, receiver, loc), + this.branchPropertyBag(lowerer, info, receiver, false, loc), key, loc, ), @@ -4729,7 +4882,7 @@ export class ClassDynamicDispatch { { kind: "return", value: null, loc }, ]; } - const bag = this.readPropertyBag(lowerer, receiver, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); return [ { kind: "return", @@ -4797,7 +4950,7 @@ export class ClassDynamicDispatch { ) { this.constructors.set(info.def.name, info); const cls = lowerer.coerceToExpected(classValueRef(lowerer, info, info.decl), DYN); - const bag = this.readPropertyBag(lowerer, receiver, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; return [ { @@ -4875,7 +5028,7 @@ export class ClassDynamicDispatch { if (symbol || (isClassOwnEnumerableFieldName(name) && !name.startsWith("sym:"))) { 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 bag = this.branchPropertyBag(lowerer, info, receiver, false, loc); const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc }; body.push({ kind: "if", @@ -4943,7 +5096,7 @@ export class ClassDynamicDispatch { } if (!field && !accessor) { const instance = lowerer.coerceToExpected(receiver, DYN); - const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; const own: IrExpr = { kind: "libCall", @@ -4990,11 +5143,7 @@ export class ClassDynamicDispatch { (t.kind === "union" && (getUnion(t.unionId)?.arms.some(mutable) ?? false)); if (boxed.kind === "dynFrom" && mutable(boxed.value.type)) boxed.liveRef = true; if (accessor) { - const bag = this.readPropertyBagOrEmpty( - lowerer, - lowerer.coerceToExpected(receiver, DYN), - loc, - ); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const key: IrExpr = { kind: "strLit", value: name, type: STRING, loc }; return [ { @@ -5021,7 +5170,7 @@ export class ClassDynamicDispatch { } if (field && info.def.tracksOwnFields) { const instance = lowerer.coerceToExpected(receiver, DYN); - const bag = this.readPropertyBagOrEmpty(lowerer, instance, loc); + const bag = this.branchPropertyBag(lowerer, info, receiver, true, loc); const symbol = symbolMemberKey(lowerer, info, name, loc); const key: IrExpr = symbol ?? { kind: "strLit", value: name, type: STRING, loc }; return [ diff --git a/packages/compiler/src/frontend/lowering/class-protocols.ts b/packages/compiler/src/frontend/lowering/class-protocols.ts index 172224053..81aa90a1f 100644 --- a/packages/compiler/src/frontend/lowering/class-protocols.ts +++ b/packages/compiler/src/frontend/lowering/class-protocols.ts @@ -98,7 +98,11 @@ export class ClassProtocols { * could read a stale value. A view of readonly data that nothing * observes stays a native record. */ private markIdentityFlows(protocols: Set): void { - const sites: { source: ts.Expression; target: () => ts.Type | undefined }[] = []; + const sites: { + source: ts.Expression; + target: () => ts.Type | undefined; + assertion?: true; + }[] = []; /** Expressions whose values are observed as whole objects. */ const observed: ts.Expression[] = []; /** Receivers whose type arguments or elements are observed (Set and Map @@ -233,6 +237,7 @@ export class ClassProtocols { sites.push({ source: node.expression, target: () => this.checker.getTypeFromTypeNode(typeNode), + assertion: true, }); } if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) @@ -325,11 +330,29 @@ export class ClassProtocols { } this.facts = facts; const pending: { sourceType: ts.Type; target: () => ts.Type | undefined }[] = []; + /** Views a value of unknown origin is asserted to and that expose a + * method of a class the view accepts (see methodViewSources). */ + const methodViews = new Set(); for (let i = 0; i < sites.length; i++) { const site = sites[i]!; const source = sourceNodes[i]!; const sourceType = attempt(() => this.checker.getTypeAtLocation(source)); - if (sourceType !== undefined) pending.push({ sourceType, target: site.target }); + if (sourceType === undefined) continue; + if ( + site.assertion && + (sourceType.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) !== 0 && + this.methods.size > 0 + ) { + const target = attempt(site.target); + if (target !== undefined) + for (const { classType, view } of attempt(() => this.methodViewSources(target)) ?? []) { + pending.push({ sourceType: classType, target: () => view }); + const symbol = view.getSymbol(); + if (symbol !== undefined) methodViews.add(symbol); + } + continue; + } + pending.push({ sourceType, target: site.target }); } for (const { fn, index, name } of callbackParams) { attempt(() => { @@ -372,7 +395,12 @@ export class ClassProtocols { // Keep the marks a copy would be told apart from, and the marks whose // values flow into a kept view or another live protocol. const added = [...marked].filter((symbol) => !before.has(symbol)); - const kept = new Set(added.filter((symbol) => this.needsView(symbol, marked, facts))); + // A copy of an instance drops the methods its class defines, so a view + // that exposes one of them stays live wherever such an instance may + // arrive. + const kept = new Set( + added.filter((symbol) => methodViews.has(symbol) || this.needsView(symbol, marked, facts)), + ); let grew = true; while (grew) { grew = false; @@ -391,6 +419,33 @@ export class ClassProtocols { this.facts = null; } + /** The program classes a value asserted from `unknown` or `any` to + * `target` may be an instance of while `target` exposes one of their + * methods: for each structural part of `target` with a callable member, + * the instance type of every class that declares a method of that name + * and is assignable to the part. A record copy of such an instance would + * drop the method, which Node reads from the instance's prototype. */ + private methodViewSources(target: ts.Type): { classType: ts.Type; view: ts.Type }[] { + const parts = target.isUnionType() ? ts.constituentTypes(target) : [target]; + const out: { classType: ts.Type; view: ts.Type }[] = []; + for (const part of parts) { + if (!this.isStructuralTarget(part)) continue; + for (const property of this.checker.getPropertiesOfType(part)) { + const memberType = this.checker.getNonNullableType(this.checker.getTypeOfSymbol(property)); + if (this.checker.getCallSignatures(memberType).length === 0) continue; + for (const declaration of this.methods.get(ts.symbolName(property)) ?? []) { + const classSymbol = declaration.name + ? this.checker.getSymbolAtLocation(declaration.name) + : undefined; + if (classSymbol === undefined) continue; + const classType = this.checker.getDeclaredTypeOfSymbol(classSymbol); + if (this.checker.isTypeAssignableTo(classType, part)) out.push({ classType, view: part }); + } + } + } + return out; + } + /** The symbols of `type`'s object parts: union and intersection members, * and to `depth` levels the elements and type arguments they hold. */ private collectSymbols(type: ts.Type, out: Set, depth: number): void { diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index d2919e202..4cbd8e5cf 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -6,6 +6,7 @@ import { DYN, STRING, SYMBOL_T, + VOID, isUnitType, type IrExpr, type IrFunction, @@ -121,6 +122,7 @@ export function classPrototypeData( const globalId = `%g.${name}`; const readyId = `${globalId}.ready`; info.def.prototypeDataHelper = name; + lowerer.shareHelper(name); const className = info.def.jsName ?? info.def.name; lowerer.globalsList.push({ id: globalId, @@ -155,6 +157,18 @@ export function classPrototypeData( : { kind: "dynObjLit", fields: [], type: DYN, loc }, loc, }, + // util.inspect prints an instance with its class's name. + { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "dyn.nameClassPrototype", + args: [value, { kind: "strLit", value: className, type: STRING, loc }], + type: VOID, + loc, + }, + loc, + }, { kind: "assign", localId: readyId, diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 438c5e6c6..bf9821d31 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -1972,7 +1972,8 @@ function lowerBufferStaticValue( const srcNode = args[0].expression; const srcIr = lowerer.mapTypeOf(lowerer.typeOf(srcNode)); if (srcIr?.kind === "bytes") { - const receiver = lowerer.lowerExpr(srcNode); + // A JavaScript field can hold the typed array as a dynamic value. + const receiver = lowerer.lowerExprExpecting(srcNode, srcIr); const idxArgs = args.slice(1).map((a) => lowerer.lowerExprExpecting(a, F64)); return { kind: "bytesIntrinsic", diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts new file mode 100644 index 000000000..72cba9942 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts @@ -0,0 +1,333 @@ +/* Hot dynamic-tier functions (SC6101, diagnostics/dynamic-tier.ts): the + * functions of a program that compile to the dynamic tier (ir/tiers.ts) and + * likely run often, so a build can name them. Without a profile, a function + * counts as likely hot when a dynamic operation runs on every iteration of + * one of its loops, when a loop calls it, or when it calls itself. Dynamic code that + * runs once (setup code that reads an `any` configuration, for example) + * stays quiet. + * + * The tier assignment is the one lowering made (Lowerer.dynamicFunctions); + * this module ranks its functions and says why each one is dynamic, from + * the source at its dynamic operations. A function is named only when the + * source says why (an `any`, an untyped `JSON.parse` result, an untyped + * JavaScript file): functions dynamic for operations without a static + * lowering would stay dynamic with more types. */ +import { relative } from "node:path"; +import type { ScrDiagnostic } from "../../diagnostics/diagnostic.js"; +import { + dynamicTierDiag, + type DynamicTierPackageCount, + type HotReason, + type UntypedCause, +} from "../../diagnostics/dynamic-tier.js"; +import type { IrExpr, IrFunction, IrModule, IrStmt, SrcLoc } from "../../ir/ir.js"; +import { + dynamicOperationOf, + dynamicOperationSites, + type DynamicOperationSite, +} from "../../ir/tiers.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import * as ts from "../ts7/adapter.js"; +import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js"; +import { isNodeModulesPath } from "../resolve.js"; +import type { Lowerer } from "./lowerer.js"; + +export interface HotDynamicFunction { + /** The IR function name (`%Point.norm`, `parse`, ...). */ + name: string; + loc: SrcLoc; + hot: HotReason; + /** Dynamic operations inside the function's loops. */ + loopOperations: number; + cause: UntypedCause; +} + +export interface DynamicTierReport { + /** Likely-hot dynamic functions in the program's own files, most likely + * hot first. */ + hot: HotDynamicFunction[]; + /** Dynamic-tier functions in npm packages, by package. */ + packages: DynamicTierPackageCount[]; +} + +interface FunctionFacts { + /** Dynamic operations in the parts of the function's loops that run on + * every iteration. */ + loopSites: DynamicOperationSite[]; + /** Callee names of direct calls, and whether each runs on every + * iteration of a loop. */ + calls: { callee: string; inLoop: boolean }[]; +} + +/** Walks a function body by structure: a loop's condition, update and body + * repeat, while a `for` loop's initializer and a `for...of` loop's iterable + * run once. (Locations cannot tell them apart: lowering may give a loop's + * repeated parts the whole statement's span.) */ +function factsOf(fn: IrFunction): FunctionFacts { + const facts: FunctionFacts = { loopSites: [], calls: [] }; + const expr = (e: IrExpr, inLoop: boolean): void => { + if (inLoop) { + const operation = dynamicOperationOf(e); + if (operation !== null) facts.loopSites.push({ operation, loc: e.loc }); + } + if (e.kind === "call") facts.calls.push({ callee: e.callee, inLoop }); + everyExprChild( + e, + (child) => { + expr(child, inLoop); + return true; + }, + (child) => { + stmt(child, inLoop); + return true; + }, + ); + }; + const children = (s: IrStmt, inLoop: boolean): void => { + everyStmtChild( + s, + (child) => { + expr(child, inLoop); + return true; + }, + (child) => { + stmt(child, inLoop); + return true; + }, + ); + }; + const stmt = (s: IrStmt, inLoop: boolean): void => { + switch (s.kind) { + case "for": + if (s.init !== null) stmt(s.init, inLoop); + if (s.cond !== null) expr(s.cond, true); + if (s.update !== null) stmt(s.update, true); + for (const b of s.body) stmt(b, true); + return; + case "forOf": + expr(s.iterable, inLoop); + for (const b of s.body) stmt(b, true); + return; + case "while": + case "doWhile": + children(s, true); + return; + default: + children(s, inLoop); + } + }; + for (const s of fn.body) stmt(s, false); + return facts; +} + +/** The package directory name of a node_modules path (`@scope/name` or `name`). */ +function packageOf(file: string): string { + const parts = file.split("/"); + const at = parts.lastIndexOf("node_modules"); + const first = parts[at + 1] ?? ""; + return first.startsWith("@") ? `${first}/${parts[at + 2] ?? ""}` : first; +} + +/** The innermost node spanning [start, end). */ +function nodeAt(sf: ts.SourceFile, start: number, end: number): ts.Node { + let node: ts.Node = sf; + for (;;) { + let next: ts.Node | undefined; + ts.forEachChild(node, (child) => { + if (ts.tokenStartOfNode(child) <= start && end <= child.end) { + next = child; + return true; + } + return undefined; + }); + if (next === undefined) return node; + node = next; + } +} + +/** The identifier an operand reads through (`items` in `items[i].x`). */ +function rootIdentifier(node: ts.Node): ts.Identifier | null { + let n: ts.Node = node; + for (;;) { + if (ts.isIdentifier(n)) return n; + if ( + ts.isPropertyAccessExpression(n) || + ts.isElementAccessExpression(n) || + ts.isCallExpression(n) + ) + n = n.expression; + else if (ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n)) n = n.expression; + else return null; + } +} + +function isJsonParse(node: ts.Node | undefined): boolean { + if (node === undefined || !ts.isCallExpression(node)) return false; + const callee = node.expression; + return ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === "JSON" && + callee.name.text === "parse" + ); +} + +/** Why an identifier holds a value without a static type, or null. */ +function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): UntypedCause | null { + const symbol = lowerer.checker.getSymbolAtLocation(id); + const decl = symbol?.declarations?.[0]; + // An unannotated `for (const item of items)` variable: name `items`. + if ( + depth < 4 && + decl !== undefined && + ts.isVariableDeclaration(decl) && + decl.type === undefined && + ts.isVariableDeclarationList(decl.parent) && + ts.isForOfStatement(decl.parent.parent) + ) { + const iterated = rootIdentifier(decl.parent.parent.expression); + if (iterated !== null) { + const cause = identifierCause(lowerer, iterated, depth + 1); + if (cause !== null) return cause; + } + } + if (decl !== undefined && ts.isVariableDeclaration(decl) && isJsonParse(decl.initializer)) + return { kind: "json-binding", name: id.text }; + const type = lowerer.checker.getTypeAtLocation(id); + if ((type.flags & ts.TypeFlags.Any) !== 0) return { kind: "any", name: id.text }; + return null; +} + +/** Why a node's value has no static type, or null. */ +function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null { + if (isJsonParse(n)) return { kind: "json" }; + const id = rootIdentifier(n); + return id === null ? null : identifierCause(lowerer, id); +} + +/** Why the dynamic operation at `loc` has no static type, or null when + * the source does not say. The site's own subexpressions are asked first + * (the operand of a dynamic `for...of`), then its ancestors. */ +function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null { + const sf = lowerer.program.getSourceFile(loc.file); + if (sf === undefined) return null; + if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs)) + return { kind: "javascript" }; + const node = nodeAt(sf, loc.start, loc.end); + let found: UntypedCause | null = null; + const descend = (n: ts.Node, depth: number): void => { + if (found !== null || depth > 3) return; + found = nodeCause(lowerer, n); + if (found === null) + ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined)); + }; + descend(node, 0); + for ( + let n: ts.Node | undefined = node.parent; + found === null && n !== undefined && n !== sf; + n = n.parent + ) + found = nodeCause(lowerer, n); + return found; +} + +/** The first cause the function's dynamic operations name, loop sites + * first, or null when none names one. */ +function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCause | null { + for (const site of sites) { + const cause = causeAt(lowerer, site.loc); + if (cause !== null) return cause; + } + return null; +} + +/** The likely-hot dynamic-tier functions of `module` (see the header). */ +export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport { + const facts = new Map(); + for (const fn of module.functions) facts.set(fn.name, factsOf(fn)); + // Functions some loop calls directly. + const calledInLoop = new Set(); + for (const fn of module.functions) { + const f = facts.get(fn.name)!; + for (const call of f.calls) if (call.inLoop) calledInLoop.add(call.callee); + } + const hot: HotDynamicFunction[] = []; + const packages = new Map(); + for (const fn of module.functions) { + // Generated `%dyn.*` helpers are dynamic operations lowered out of line, + // and `%init.*` is a module's top level: neither is a function to name. + if (fn.tier !== "dynamic" || fn.name.startsWith("%dyn.") || fn.name.startsWith("%init.")) + continue; + if (isNodeModulesPath(fn.loc.file)) { + const name = packageOf(fn.loc.file); + packages.set(name, (packages.get(name) ?? 0) + 1); + continue; + } + const f = facts.get(fn.name)!; + const sites = dynamicOperationSites(fn); + const inLoops = f.loopSites; + const recursive = f.calls.some((c) => c.callee === fn.name); + const reason: HotReason | null = + inLoops.length > 0 + ? "loop" + : calledInLoop.has(fn.name) + ? "called-in-loop" + : recursive + ? "recursive" + : null; + if (reason === null) continue; + // Only functions the source makes dynamic are named: ones dynamic for + // operations without a static lowering would stay dynamic with more types. + const looped = new Set(inLoops.map((s) => `${s.loc.file}:${s.loc.start}:${s.loc.end}`)); + const rest = sites.filter((s) => !looped.has(`${s.loc.file}:${s.loc.start}:${s.loc.end}`)); + const cause = causeOf(lowerer, [...inLoops, ...rest]); + if (cause === null) continue; + hot.push({ + name: fn.name, + loc: fn.loc, + hot: reason, + loopOperations: inLoops.length, + cause, + }); + } + const rank = { loop: 0, "called-in-loop": 1, recursive: 2 } as const; + hot.sort((a, b) => rank[a.hot] - rank[b.hot] || b.loopOperations - a.loopOperations); + return { + hot, + packages: [...packages] + .map(([name, functions]) => ({ name, functions })) + .sort((a, b) => b.functions - a.functions || (a.name < b.name ? -1 : 1)), + }; +} + +/** `%%m144.Session.finish` → `Session.finish`, `m163.find` → `find`. */ +function displayName(name: string): string { + return name.replace(/^%+/, "").replace(/^m\d+\./, ""); +} + +/** SC6101 for the program lowering produced, or null (see the header). + * Paths are relative to `root`, the entry's directory. */ +export function dynamicTierWarning( + lowerer: Lowerer, + module: IrModule, + root: string, +): ScrDiagnostic | null { + const report = scanDynamicTier(lowerer, module); + const first = report.hot[0]; + if (first === undefined) return null; + const where = (loc: SrcLoc): string => { + const sf = lowerer.program.getSourceFile(loc.file); + const line = sf === undefined ? 0 : ts.getLineAndCharacterOfPosition(sf, loc.start).line + 1; + return `${relative(root, loc.file) || loc.file}:${line}`; + }; + return dynamicTierDiag( + report.hot.map((f) => ({ + name: displayName(f.name), + where: where(f.loc), + cause: f.cause, + hot: f.hot, + })), + report.packages, + first.loc, + ); +} diff --git a/packages/compiler/src/frontend/lowering/error-methods.ts b/packages/compiler/src/frontend/lowering/error-methods.ts index 979cf4814..b545f4147 100644 --- a/packages/compiler/src/frontend/lowering/error-methods.ts +++ b/packages/compiler/src/frontend/lowering/error-methods.ts @@ -24,6 +24,7 @@ export function errorPropertyRead( ): IrExpr { const name = `%error.${property}.read`, loc = receiver.loc; + lowerer.shareHelper(name); if (!lowerer.liftedFns.some((fn) => fn.name === name)) lowerer.liftedFns.push({ name, @@ -50,6 +51,7 @@ export function errorPropertyWrite( ): IrStmt { const name = `%error.${property}.write`, loc = receiver.loc; + lowerer.shareHelper(name); if (!lowerer.liftedFns.some((fn) => fn.name === name)) lowerer.liftedFns.push({ name, @@ -392,6 +394,7 @@ export function errorToStringMethod( receiver: IrExpr, ): Extract { const loc = receiver.loc; + lowerer.shareHelper(helperName); if (!lowerer.liftedFns.some((fn) => fn.name === helperName)) { lowerer.liftedFns.push({ name: helperName, @@ -415,6 +418,7 @@ export function errorToStringCall(lowerer: Lowerer, receiver: IrExpr): IrExpr { const loc = receiver.loc; const method = errorToStringMethod(lowerer, receiver); const name = "%error.toString.virtual"; + lowerer.shareHelper(name); if (!lowerer.liftedFns.some((fn) => fn.name === name)) { const value = varRef("error.0", errorType, loc); lowerer.liftedFns.push({ diff --git a/packages/compiler/src/frontend/lowering/expressions/updates.ts b/packages/compiler/src/frontend/lowering/expressions/updates.ts index 7269f6b52..13baca7d2 100644 --- a/packages/compiler/src/frontend/lowering/expressions/updates.ts +++ b/packages/compiler/src/frontend/lowering/expressions/updates.ts @@ -30,6 +30,7 @@ import { } from "../../../ir/ir.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { classToNumberConversion } from "../class-to-primitive.js"; +import { lowerPrimitiveUnionNumber } from "../lower-calls.js"; import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../../ir/build.js"; import { fenceNodeModuleMutation } from "../lower-node-module.js"; import { staticFieldWriteTarget } from "../lower-classes.js"; @@ -132,8 +133,12 @@ export function lowerPrefixUnary(lowerer: Lowerer, expr: ts.PrefixUnaryExpressio const classNumber = classToNumberConversion(lowerer, raw, expr.operand, "unary plus"); if (classNumber) return classNumber; const operand = lowerOptionalNumber(lowerer, raw, loc, expr.operand); - if (operand.type.kind !== "f64") operatorUnsupported(lowerer, expr, "+", [raw.type]); - return operand; + if (operand.type.kind === "f64") return operand; + // `+process.argv[2]`: a `string | undefined` (or other primitive + // union) converts as Number(value) does. + const unionNumber = lowerPrimitiveUnionNumber(lowerer, raw, expr.operand, loc); + if (unionNumber) return unionNumber; + operatorUnsupported(lowerer, expr, "+", [raw.type]); } case ts.SyntaxKind.ExclamationToken: { // `!x` is ToBoolean-then-negate: f64/string operands go through toBool. diff --git a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts index ca6a5ecdc..3f43e5e05 100644 --- a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts @@ -68,8 +68,10 @@ export class GeneratorDynamicDispatch { this.properties.set(method, { fn, types: new Set() }); this.generated.add(fn); lowerer.liftedFns.push(fn); + lowerer.shareHelper(fn); // The runtime's own helper functions answer the helper methods. if (HELPER_METHODS.has(method)) return fn; + lowerer.shareHelper(`%dyn.generator.method.${method}`); lowerer.liftedFns.push({ name: `%dyn.generator.method.${method}`, params: [{ localId: "arg", name: "arg", type: DYN }], @@ -209,6 +211,7 @@ export class GeneratorDynamicDispatch { this.dispatches.set(key, dispatch); this.generated.add(helper); lowerer.liftedFns.push(helper); + lowerer.shareHelper(helper); changed = true; } return { diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index e367cec92..e4966991d 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -168,7 +168,6 @@ import { lowerReflectCall } from "./builtins/reflection.js"; import { lowerAbsenceProbe, v8CalleeText, - lowerOptionalNumber, lowerPromiseAllTupleCall, lowerPromiseRejectCall, lowerToNumberArgument, @@ -1016,10 +1015,9 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { // excludes shadowed names. parseInt's omitted radix completes // to 0 — the spec's "undefined" (base 10 with the 0x hex escape); // parseFloat lowers the STRING form only (Node would ToString other - // values — no static story); isNaN/isFinite's arguments are - // checker-pinned (or checked) to number, where the global's ToNumber - // coercion is the identity and the tests are Number.isNaN / - // Number.isFinite exactly (ms's `isFinite(val)` guard). + // values — no static story); isNaN/isFinite apply the global's + // ToNumber to any argument, then test like Number.isNaN / + // Number.isFinite. if ( (expr.expression.text === "parseInt" || expr.expression.text === "isNaN" || @@ -1138,18 +1136,14 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { } if (islFn && expr.arguments.nodes.length === islFn.args.length) { // The static forms above already took the exactly-typed argument - // (a string for parseFloat, a number for isFinite): name the - // argument type that refused the call, so the message states the - // condition rather than reading as if the global itself were - // unsupported. + // (a string for parseFloat): name the argument type that refused + // the call, so the message states the condition rather than + // reading as if the global itself were unsupported. const argText = loweredArgumentText(lowerer, expr.arguments.nodes[0]!); - const exact = expr.expression.text === "isFinite" ? "number" : "string"; lowerer.refuseUnsupportedApi( `'${expr.expression.text}' with a '${argText}' argument`, expr, - exact === "string" - ? "only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)" - : "only a number argument compiles statically: narrow the argument to a number first", + "only a string argument compiles statically: convert the argument explicitly first (for example String(x), or narrow the union)", ); } } @@ -3215,6 +3209,20 @@ function lowerStringPrototypeCall( ); } +/** ToNumber of a union of strings, numbers, booleans, null and undefined, as + * `Number(value)` converts it; null for other types. Unary `+` takes it too: + * the two conversions differ only for BigInt, which such unions don't hold. */ +export function lowerPrimitiveUnionNumber( + lowerer: Lowerer, + arg: IrExpr, + node: ts.Expression, + loc: SrcLoc, +): IrExpr | null { + return ( + lowerOptionalStringNumber(lowerer, arg, loc) ?? lowerScalarUnionNumber(lowerer, arg, node, loc) + ); +} + function lowerScalarUnionNumber( lowerer: Lowerer, arg: IrExpr, @@ -7682,6 +7690,10 @@ function lowerObjectStaticCall( const target = lowerer.lowerExpr(call.arguments.nodes[0]!); const rawSource = lowerer.lowerExpr(call.arguments.nodes[1]!); fenceSymbolFieldCopy(lowerer, call.arguments.nodes[1]!, rawSource.type); + let targetNode: ts.Expression = call.arguments.nodes[0]!; + while (ts.isParenthesizedExpression(targetNode)) targetNode = targetNode.expression; + if (ts.isObjectLiteralExpression(targetNode)) + fenceClassCopyIntoFreshObject(lowerer, call, call.arguments.nodes[1]!, rawSource); const source = lowerer.coerceToExpected(rawSource, DYN); if (target.type.kind === "dyn" && source.type.kind === "dyn") { return { kind: "libCall", fn: "dyn.assign", args: [target, source], type: DYN, loc }; @@ -7835,25 +7847,8 @@ function lowerObjectStaticCall( const srcNode = spread ? argNode.expression : argNode; const rawSource = lowerer.lowerExpr(srcNode); fenceSymbolFieldCopy(lowerer, srcNode, rawSource.type, spread); - // A class instance copied into a fresh object - // (`Object.assign({}, instance)`): the result is a plain - // object holding the instance's own fields, never an - // instance, so a result the checker types as the class has - // no representation (the checked cast would throw). - if ( - rawSource.type.kind === "object" && - freshLiteralTarget && - !ts.isExpressionStatement(call.parent) - ) { - const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); - if (resultT !== null && resultT.kind === "object") { - lowerer.unsupported( - "SC1090", - srcNode, - `Object.assign copying a '${lowerer.fmt(rawSource.type)}' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need)`, - ); - } - } + if (freshLiteralTarget) + fenceClassCopyIntoFreshObject(lowerer, call, srcNode, rawSource); const src = lowerer.coerceToExpected(rawSource, DYN); if (src.type.kind !== "dyn") { ok = false; @@ -10322,7 +10317,7 @@ export function lowerStaticNumberGlobal( args: readonly ts.Expression[], loc: SrcLoc, ): IrExpr | null { - if (name === "parseInt" || name === "isNaN") { + if (name === "parseInt" || name === "isNaN" || name === "isFinite") { const maxArgs = name === "parseInt" ? 2 : 1; if (args.length < 1 || args.length > maxArgs) { lowerer.noLowering( @@ -10330,10 +10325,12 @@ export function lowerStaticNumberGlobal( call, ); } - if (name === "isNaN") { - // The global's ToNumber turns a missing value into NaN. + if (name === "isNaN" || name === "isFinite") { + // The global's ToNumber turns a missing value into NaN, converts + // strings, booleans and null, and calls valueOf/toString on objects. const x = lowerToNumberArgument(lowerer, args[0]!); - return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc }; + const fn = name === "isNaN" ? "num.isNaN" : "number.isFinite"; + return { kind: "libCall", fn, args: [x], type: BOOL, loc }; } const radix: IrExpr = args[1] ? lowerer.lowerExprExpecting(args[1], F64) @@ -10469,32 +10466,28 @@ export function lowerStaticNumberGlobal( : lowerer.lowerExprExpecting(args[0]!, STRING); return { kind: "libCall", fn: "num.parseInt", args: [s, radix], type: F64, loc }; } - // STATIC parseFloat/isFinite over exactly-typed arguments — - // parseInt's siblings (num.parseFloat is ECMA 19.2.4's decimal- - // literal prefix parse in scr_string.c; a number-typed isFinite IS - // Number.isFinite — the global's ToNumber coercion is the identity - // there, ms's `isFinite(val)` guard). Other argument types retain - // their native coercion checks or the SC2012 refusal. The probe never - // emits — lowering is IR construction. - if ((name === "parseFloat" || name === "isFinite") && args.length === 1) { - if (name === "parseFloat") { - const optional = optionalCallValue(lowerer, args[0]!); - if (optional) { - const parsed = lowerOptionalStringNumber( - lowerer, - optional, - loc, - "num.parseFloat", - "parseFloat", - ); - if (parsed) return parsed; - } + // STATIC parseFloat over string arguments — parseInt's sibling + // (num.parseFloat is ECMA 19.2.4's decimal-literal prefix parse in + // scr_string.c). Other argument types retain their native coercion + // checks or the SC2012 refusal. The probe never emits — lowering is IR + // construction. + if (name === "parseFloat" && args.length === 1) { + const optional = optionalCallValue(lowerer, args[0]!); + if (optional) { + const parsed = lowerOptionalStringNumber( + lowerer, + optional, + loc, + "num.parseFloat", + "parseFloat", + ); + if (parsed) return parsed; } const probed = tryLowerExpression(lowerer, args[0]!); - if (name === "parseFloat" && probed?.type.kind === "string") { + if (probed?.type.kind === "string") { return { kind: "libCall", fn: "num.parseFloat", args: [probed], type: F64, loc }; } - if (name === "parseFloat" && probed?.type.kind === "dyn") { + if (probed?.type.kind === "dyn") { return { kind: "libCall", fn: "num.parseFloat", @@ -10503,20 +10496,6 @@ export function lowerStaticNumberGlobal( loc, }; } - if (name === "isFinite" && probed?.type.kind === "f64") { - return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc }; - } - // A number the checker trusts but storage may hold missing (an - // out-of-range read): the global's ToNumber makes undefined NaN. - if ( - name === "isFinite" && - probed?.type.kind === "union" && - lowerer.armTag(probed.type.unionId, UNDEFINED_T) >= 0 && - lowerer.stripUndefinedArm(probed.type).kind === "f64" - ) { - const x = lowerOptionalNumber(lowerer, probed, loc, args[0]); - return { kind: "libCall", fn: "number.isFinite", args: [x], type: BOOL, loc }; - } } return null; } @@ -10715,3 +10694,23 @@ function functionIntegrity( loc, }; } + +/** A class instance copied into a fresh object (`Object.assign({}, + * instance)`): the result is a plain object holding the instance's own + * fields, never an instance, so a result the checker types as the class has + * no representation (the checked cast would throw). */ +function fenceClassCopyIntoFreshObject( + lowerer: Lowerer, + call: ts.CallExpression, + srcNode: ts.Expression, + rawSource: IrExpr, +): void { + if (rawSource.type.kind !== "object" || ts.isExpressionStatement(call.parent)) return; + const resultT = lowerer.mapTypeOf(lowerer.typeOf(call)); + if (resultT === null || resultT.kind !== "object") return; + lowerer.unsupported( + "SC1090", + srcNode, + `Object.assign copying a '${lowerer.fmt(rawSource.type)}' instance into a fresh object typed as the class (the copy is a plain object holding the instance's own fields, not an instance — build the object literal from the fields you need)`, + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index f312e9229..790efe988 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -688,6 +688,9 @@ export interface ClassInfo { path: InstantiationPath; family: ClassInfo; bindings: Map; + /** The type arguments as checker types, for the resolutions that need + * literal identity (a conditional type over a literal argument). */ + tsBindings: Map; typeArgsText: string; ordinal: number; }; @@ -767,6 +770,9 @@ export interface GenericClassInfo { typeParams: ts.Symbol[]; family: ClassInfo; instances: Map; + /** The type parameters a conditional type in the class depends on + * (literalSensitiveTypeParams), computed on first instantiation. */ + literalSensitive?: Set; } /** The KEY symbol behind a LATE-BOUND (`__@name@id`) property: resolved @@ -1772,6 +1778,7 @@ export function collectClassShapeInner( family: ClassInfo; name: string; bindings: Map; + tsBindings: Map; typeArgsText: string; ordinal: number; }, @@ -4448,6 +4455,7 @@ export function collectClassShapeInner( path: inst.path, family: inst.family, bindings: inst.bindings, + tsBindings: inst.tsBindings, typeArgsText: inst.typeArgsText, ordinal: inst.ordinal, }; @@ -4591,8 +4599,10 @@ export function genericClassInstanceType( .getTypeArguments(ref as ts.TypeReference) .slice(0, gci.typeParams.length); const mapped: IrType[] = []; + const tsArgs: ts.Type[] = []; if (args.length === gci.typeParams.length) { for (const a of args) { + tsArgs.push(lowerer.typeParamTsResolver(a) ?? a); const m = lowerer.mapTypeOf(a, mapArgument); if (!m || m.kind === "void") { // An UNBOUND type parameter argument (`Box` outside any @@ -4611,9 +4621,32 @@ export function genericClassInstanceType( const b = lowerer.typeParamBindings?.get(tp); if (!b) return null; mapped.push(b); + const bound = lowerer.typeParamTsBindings?.get(tp); + if (bound) tsArgs.push(bound); } } - const key = mapped.map(typeKey).join(","); + // A type parameter that a conditional type in the class depends on + // (`K extends "required" ? T : T | undefined`) is bound to its checker + // type for the members, and that type keys the instance: mapType alone + // widens a literal, so Field and Field would otherwise share one layout. The other parameters get + // no checker binding, so nothing in the members depends on a literal + // the key does not carry. + gci.literalSensitive ??= literalSensitiveTypeParams(lowerer, gci); + const sensitive = (i: number): boolean => + tsArgs.length === gci.typeParams.length && gci.literalSensitive!.has(gci.typeParams[i]!); + const checkerKey = gci.typeParams + .map((_, i) => (sensitive(i) ? `#${tsArgs[i]!.id}` : "*")) + .join(","); + const key = + mapped.map(typeKey).join(",") + (gci.literalSensitive.size > 0 ? `@${checkerKey}` : ""); + const literalText = (i: number): string | null => { + const arg = sensitive(i) ? tsArgs[i] : undefined; + if (arg?.isStringLiteralType()) return JSON.stringify(arg.value); + if (arg?.isNumberLiteralType()) return String(arg.value); + if (arg && arg.flags & ts.TypeFlags.BooleanLiteral) return lowerer.checker.typeToString(arg); + return null; + }; const existing = gci.instances.get(key); if (existing) { if (existing.info) lowerer.noteGenericClassInstanceDemand(existing.info); @@ -4635,13 +4668,19 @@ export function genericClassInstanceType( gci.instances.set(key, entry); const bindings = new Map(); gci.typeParams.forEach((tp, i) => bindings.set(tp, mapped[i]!)); - const rendered = mapped.map((m) => lowerer.fmt(m)).join(", "); + const tsBindings = new Map(); + gci.typeParams.forEach((tp, i) => { + if (sensitive(i)) tsBindings.set(tp, tsArgs[i]!); + }); + const rendered = mapped.map((m, i) => literalText(i) ?? lowerer.fmt(m)).join(", "); const typeArgsText = `<${rendered.length > 80 ? rendered.slice(0, 77) + "..." : rendered}>`; const prevBindings = lowerer.typeParamBindings; + const prevTsBindings = lowerer.typeParamTsBindings; const prevContext = lowerer.instantiationContext; const previousPath = lowerer.genericInstantiationPath; lowerer.genericInstantiationPath = path; lowerer.typeParamBindings = bindings; + lowerer.typeParamTsBindings = tsBindings; lowerer.instantiationContext = `instantiating class '${gci.baseName}' with ${typeArgsText}`; try { lowerer.collectClassShapeInner(decl, undefined, { @@ -4649,6 +4688,7 @@ export function genericClassInstanceType( family: gci.family, name, bindings, + tsBindings, typeArgsText, ordinal, }); @@ -4660,6 +4700,7 @@ export function genericClassInstanceType( return null; } finally { lowerer.typeParamBindings = prevBindings; + lowerer.typeParamTsBindings = prevTsBindings; lowerer.instantiationContext = prevContext; lowerer.genericInstantiationPath = previousPath; } @@ -4675,6 +4716,63 @@ export function genericClassInstanceType( return { kind: "object", className: name }; } +/** The class's type parameters that a conditional type inside it depends + * on: the parameters named within a type node that is a conditional type + * or reaches one through the type aliases and interfaces it refers to + * (`get(): Value` with `type Value = K extends "required" ? T + * : T | undefined`, also when the alias wraps the conditional in a union + * or another alias). Their arguments pick the conditional's branch. */ +function literalSensitiveTypeParams(lowerer: Lowerer, gci: GenericClassInfo): Set { + const params = new Set(gci.typeParams); + const found = new Set(); + const reaches = new Map(); + const declarationsReach = (symbol: ts.Symbol): boolean => { + const target = + symbol.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(symbol) : symbol; + const known = reaches.get(target); + if (known !== undefined) return known; + reaches.set(target, false); // a recursive alias reaches through its other parts + const result = target.declarations.some((d) => + ts.isTypeAliasDeclaration(d) + ? reachesConditional(d.type) + : ts.isInterfaceDeclaration(d) + ? d.members.nodes.some(reachesConditional) + : false, + ); + reaches.set(target, result); + return result; + }; + const reachesConditional = (node: ts.Node): boolean => { + if (ts.isConditionalTypeNode(node)) return true; + if (ts.isTypeReferenceNode(node)) { + const name = ts.isIdentifier(node.typeName) ? node.typeName : node.typeName.right; + const symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol && !params.has(symbol) && declarationsReach(symbol)) return true; + } + let found = false; + ts.forEachChild(node, (child) => { + if (!found && reachesConditional(child)) found = true; + }); + return found; + }; + const collect = (node: ts.Node): void => { + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const symbol = lowerer.checker.getSymbolAtLocation(node.typeName); + if (symbol && params.has(symbol)) found.add(symbol); + } + ts.forEachChild(node, collect); + }; + const visit = (node: ts.Node): void => { + if (ts.isTypeNode(node) && reachesConditional(node)) { + collect(node); + return; + } + ts.forEachChild(node, visit); + }; + for (const member of gci.decl.members.nodes) visit(member); + return found; +} + /** Runs a member-lowering thunk under an INSTANTIATION's type-parameter * bindings (the generic-fn typeParamResolver mechanism) — the checker * keeps reporting the unsubstituted `T`s inside the shared body AST. @@ -4708,17 +4806,20 @@ function withInstanceBindings(lowerer: Lowerer, info: ClassInfo, fn: () => T) } } const prevBindings = lowerer.typeParamBindings; + const prevTsBindings = lowerer.typeParamTsBindings; const prevContext = lowerer.instantiationContext; const prevSuppress = lowerer.suppressStats; const previousPath = lowerer.genericInstantiationPath; lowerer.genericInstantiationPath = gi.path; lowerer.typeParamBindings = gi.bindings; + lowerer.typeParamTsBindings = gi.tsBindings; lowerer.instantiationContext = `instantiating class '${gi.family.generic?.baseName ?? info.def.jsName ?? ""}' with ${gi.typeArgsText}`; lowerer.suppressStats = prevSuppress || gi.ordinal > 0; try { return fn(); } finally { lowerer.typeParamBindings = prevBindings; + lowerer.typeParamTsBindings = prevTsBindings; lowerer.instantiationContext = prevContext; lowerer.suppressStats = prevSuppress; lowerer.genericInstantiationPath = previousPath; @@ -8437,6 +8538,10 @@ function superCallInStatementPosition(call: ts.CallExpression): boolean { for (;;) { const parent = node.parent; if (ts.isExpressionStatement(parent)) return true; + if (ts.isIfStatement(parent)) { + const lead = superConditionLead(parent.expression); + return lead !== null && call.pos >= lead.left.pos && call.end <= lead.left.end; + } const comma = ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.CommaToken; if (!comma && !ts.isParenthesizedExpression(parent)) return false; @@ -8444,6 +8549,29 @@ function superCallInStatementPosition(call: ts.CallExpression): boolean { } } +/** The comma chain of an `if` condition whose leading operands hold a + * super() call (`if (super(), r === 0) throw ...`, as minifiers join a + * super statement with the next test), or null. The if statement runs the + * chain's left operand as a statement first and tests its right operand, + * which is what the comma evaluates. */ +export function superConditionLead(cond: ts.Expression): ts.BinaryExpression | null { + let expr = cond; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (!ts.isBinaryExpression(expr) || expr.operatorToken.kind !== ts.SyntaxKind.CommaToken) + return null; + const holdsSuperCall = (operand: ts.Expression): boolean => { + let e = operand; + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (ts.isCallExpression(e)) return e.expression.kind === ts.SyntaxKind.SuperKeyword; + return ( + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.CommaToken && + (holdsSuperCall(e.left) || holdsSuperCall(e.right)) + ); + }; + return holdsSuperCall(expr.left) ? expr : null; +} + /** Super statements initialize the base and then the derived fields on * whichever control-flow path executes. Repeated calls are explicitly * refused until native layouts can allocate a second receiver. */ diff --git a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts index d84f84f4e..f472672b2 100644 --- a/packages/compiler/src/frontend/lowering/lower-event-emitter.ts +++ b/packages/compiler/src/frontend/lowering/lower-event-emitter.ts @@ -2128,8 +2128,12 @@ function ensureEmitSpec(lowerer: Lowerer, info: ClassInfo, event: string, tuple: params: tuple.map((t) => ({ type: t, mode: "required" as const })), ret: BOOL, }); - if (info.def.methods) info.def.methods.push(mName); - else info.def.methods = [mName]; + // Specializations sit in the method list sorted by event name, so the + // class table does not depend on which body asked for one first. + const methods = (info.def.methods ??= []); + const after = methods.findIndex((m) => m.startsWith("emit:") && m > mName); + if (after < 0) methods.push(mName); + else methods.splice(after, 0, mName); } const ordinal = lowerer.emitSpecQueue.filter((q) => q.info === info).length; lowerer.emitSpecQueue.push({ info, event, tuple, ordinal }); diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 591057cab..e7a1b79a8 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1420,9 +1420,83 @@ export function lowerOptionalNumber( * out-of-range read, a local or field holding one, or a hole — converts to * NaN instead of trusting the checker's number type. */ export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): IrExpr { + const operand = numberOperand(lowerer, node); + return operand.convert ? operand.convert(operand.value) : operand.value; +} + +/** The ToNumber arguments of one call (the Math functions, + * String.fromCharCode): Node evaluates every argument before it converts + * any. When an argument's conversion can run program code (valueOf or + * toString on a dynamic value) and a later argument follows, the arguments + * evaluate into temporaries first and convert in order after. `lowerOther` + * lowers an argument that takes no conversion (a spread's array), or returns + * null for a ToNumber argument; with temporaries, such an array is copied + * where it is evaluated, since JavaScript reads a spread's elements then, + * before a later conversion's valueOf could change them. Returns the + * temporaries' statements (empty when none are needed) and the converted + * values. */ +export function lowerToNumberArguments( + lowerer: Lowerer, + nodes: readonly ts.Expression[], + lowerOther?: (node: ts.Expression) => IrExpr | null, +): { stmts: IrStmt[]; values: IrExpr[] } { + const operands = nodes.map((node): NumberOperand => { + const other = lowerOther?.(node); + return other + ? { value: other, convert: null, runsCode: false, spread: true } + : numberOperand(lowerer, node); + }); + const late = operands.slice(0, -1).some((operand) => operand.runsCode); + if (!late) { + const values = operands.map((o) => (o.convert ? o.convert(o.value) : o.value)); + return { stmts: [], values }; + } + const stmts: IrStmt[] = []; + const values = operands.map((operand) => { + if (operand.value.kind === "numLit") return operand.value; + const temp = lowerer.declareHiddenLocal("%numberArgument", operand.value.type); + const loc = operand.value.loc; + const init: IrExpr = + operand.spread && operand.value.type.kind === "array" + ? { + kind: "arrIntrinsic", + method: "slice", + receiver: operand.value, + args: [numLit(0, loc), numLit(Infinity, loc)], + type: operand.value.type, + loc, + } + : operand.value; + stmts.push({ kind: "varDecl", localId: temp.id, init, loc }); + const read = varRef(temp.id, operand.value.type, loc); + return operand.convert ? operand.convert(read) : read; + }); + return { stmts, values }; +} + +/** An argument a ToNumber site lowers: its evaluated value and the + * conversion that makes it a number (null when the value already is one). + * `runsCode`: the conversion can call program code. `spread`: the value is + * a spread's array (lowerToNumberArguments' `lowerOther`). */ +interface NumberOperand { + value: IrExpr; + convert: ((value: IrExpr) => IrExpr) | null; + runsCode: boolean; + spread?: boolean; +} + +function numberOperand(lowerer: Lowerer, node: ts.Expression): NumberOperand { let inner = node; while (ts.isParenthesizedExpression(inner)) inner = inner.expression; const loc = locOf(inner); + const number = (value: IrExpr): NumberOperand => ({ value, convert: null, runsCode: false }); + const dynToNumber = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "dyn.toNumberCoerce", + args: [value], + type: F64, + loc, + }); const isOptionalNumber = (value: IrExpr): boolean => value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 && @@ -1433,12 +1507,54 @@ export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): Ir ? lowerAbsenceProbe(lowerer, inner) : null; if (optional && isOptionalNumber(optional)) - return lowerOptionalNumber(lowerer, optional, loc, inner); - // A conditional builds each branch for the destination. - if (ts.isConditionalExpression(inner)) return lowerer.lowerExprExpecting(node, F64); + return number(lowerOptionalNumber(lowerer, optional, loc, inner)); + // A conditional with number branches builds each branch as a number; + // other branches box, and the value converts like any dynamic value. + if (ts.isConditionalExpression(inner)) { + const branch = (b: ts.Expression) => lowerer.mapTypeOf(lowerer.typeOf(b))?.kind; + if (branch(inner.whenTrue) === "f64" && branch(inner.whenFalse) === "f64") + return number(lowerer.lowerExprExpecting(node, F64)); + return { value: lowerer.lowerExprExpecting(node, DYN), convert: dynToNumber, runsCode: true }; + } const value = lowerer.lowerExpr(node); - if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner); - return lowerer.coerceInto(node, value, F64); + if (isOptionalNumber(value)) return number(lowerOptionalNumber(lowerer, value, loc, inner)); + // Other values convert with ToNumber: strings parse as numeric literals, + // booleans and null are numbers, and checked-dynamic values call + // valueOf/toString or throw Node's TypeError for a Symbol or BigInt. + const num = (n: number): IrExpr => ({ kind: "numLit", value: n, type: F64, loc }); + const pure = (convert: (value: IrExpr) => IrExpr): NumberOperand => ({ + value, + convert, + runsCode: false, + }); + switch (value.type.kind) { + case "string": + return pure((v) => ({ kind: "libCall", fn: "num.fromString", args: [v], type: F64, loc })); + case "bool": + return pure((v) => ({ + kind: "ternary", + cond: v, + then: num(1), + else_: num(0), + type: F64, + loc, + })); + case "nullT": + return pure((v) => defaultAfterUndefined(v, num(0))); + case "undefinedT": + return pure((v) => defaultAfterUndefined(v, num(NaN))); + case "dyn": + return { value, convert: dynToNumber, runsCode: true }; + case "union": + if (lowerer.dynConvertible(value.type)) + return { + value: lowerer.coerceToExpected(value, DYN), + convert: dynToNumber, + runsCode: true, + }; + break; + } + return number(lowerer.coerceInto(node, value, F64)); } /** The stored value of an identifier whose storage holds an undefined arm diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 0ddfa0ac4..cd8dc31d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -1,14 +1,20 @@ import { declareTypedArguments } from "./typed-arguments.js"; -import { varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { everyStmtList, transformStmtList } from "../../ir/traverse.js"; +import { bindingNeverReassigned } from "./binding-analysis.js"; +import { declareContextLocal } from "./function-context.js"; +import { isErrorFirstErrorType } from "./builtins/arguments.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; import { isRebindableFunctionSymbol } from "./rebindable-functions.js"; import { bodyReadsArguments } from "../arguments-usage.js"; import type { Lowerer } from "./lowerer.js"; import { + BOOL, DYN, type IrExpr, type IrFunction, + type IrLocal, type IrParam, type IrStmt, type SrcLoc, @@ -253,6 +259,14 @@ export function lowerLambda( shapes, funcType, ); + // An error-first callback that reads its Buffer result: undefined on the + // error path, as in Node. + const undefinedResult = undefinedResultParam( + lowerer, + node, + shapes, + lowerer.errorFirstLiterals.has(node), + ); const nameIdent = !ts.isArrowFunction(node) && node.name && ts.isIdentifier(node.name) ? node.name : null; const baseName = nameIdent ? nameIdent.text : ""; @@ -340,78 +354,84 @@ export function lowerLambda( !shapes.some((s) => s.mode === "dynRest"), ); - let body: IrStmt[]; - if (ts.isBlock(node.body!)) { - body = lowerer.lowerStmts(node.body!.statements.nodes); - } else { - // Bare-expression arrow body: `x => e` is `x => { return e; }` - // (or an expression statement when the signature returns void — or - // when a union-returning signature wraps a void expression, whose - // value is the implicit undefined arm appended below). - const bodyExpr = node.body as ts.Expression; - if (bodyReturn.kind === "void") { - // `() => undefined` — the return type maps to void (standalone - // undefined IS void in the type mapping) and the body value is a - // bare unit literal: a pure no-op, dropped rather than tripping - // the validator's bare-unitLit rule (typeCheckReturnExpression). - // A `void e` body rides the statement lowering (the value is - // discarded here, so the operand evaluates for effect alone — - // `(name) => void doThing(name)`, the fire-and-forget arrow). A - // conditional body does the same so its void arms become lazy - // statement branches instead of a forbidden void-valued ternary. - let stripped: ts.Expression = bodyExpr; - while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression; - if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) { - body = [lowerer.lowerExprStatement(stripped)]; + const lowerBody = (): IrStmt[] => { + let body: IrStmt[]; + if (ts.isBlock(node.body!)) { + body = lowerer.lowerStmts(node.body!.statements.nodes); + } else { + // Bare-expression arrow body: `x => e` is `x => { return e; }` + // (or an expression statement when the signature returns void — or + // when a union-returning signature wraps a void expression, whose + // value is the implicit undefined arm appended below). + const bodyExpr = node.body as ts.Expression; + if (bodyReturn.kind === "void") { + // `() => undefined` — the return type maps to void (standalone + // undefined IS void in the type mapping) and the body value is a + // bare unit literal: a pure no-op, dropped rather than tripping + // the validator's bare-unitLit rule (typeCheckReturnExpression). + // A `void e` body rides the statement lowering (the value is + // discarded here, so the operand evaluates for effect alone — + // `(name) => void doThing(name)`, the fire-and-forget arrow). A + // conditional body does the same so its void arms become lazy + // statement branches instead of a forbidden void-valued ternary. + let stripped: ts.Expression = bodyExpr; + while (ts.isParenthesizedExpression(stripped)) stripped = stripped.expression; + if (ts.isVoidExpression(stripped) || ts.isConditionalExpression(stripped)) { + body = [lowerer.lowerExprStatement(stripped)]; + } else { + const value = lowerer.lowerExpr(bodyExpr); + // `async () => p` with a void result still resolves the + // function's promise with p (its settlement, rejections included); + // a maybe-promise value adopts only its promise arm. + const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null; + body = flattened + ? [flattened] + : value.kind === "unitLit" + ? [] + : isAsync && value.type.kind === "promise" + ? [lowerer.adoptingReturn(value, VOID, (e) => e)] + : [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]; + } } else { - const value = lowerer.lowerExpr(bodyExpr); - // `async () => p` with a void result still resolves the - // function's promise with p (its settlement, rejections included); - // a maybe-promise value adopts only its promise arm. - const flattened = isAsync ? lowerer.maybePromiseReturn(bodyExpr, value, VOID) : null; + // Concise and block bodies use the same destination layout for + // fresh literals. Async bodies must inspect promises first. + let value = + isAsync || fnCtx.inferReturn + ? lowerer.lowerExpr(bodyExpr) + : lowerer.lowerExprExpecting(bodyExpr, bodyReturn); + // An async concise body whose value is itself a promise + // (`async () => p`): the async machinery RESOLVES the returned + // thenable into the function's own promise — lowerReturnValue's + // await-through, applied to the implicit return. + if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") { + value = lowerer.returnedPromiseAwait(value); + } + const flattened = + isAsync && !fnCtx.inferReturn + ? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn) + : null; body = flattened ? [flattened] - : value.kind === "unitLit" - ? [] - : isAsync && value.type.kind === "promise" - ? [lowerer.adoptingReturn(value, VOID, (e) => e)] - : [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }]; + : value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) + ? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }] + : [ + { + kind: "return", + value: fnCtx.inferReturn + ? value + : lowerer.coerceInto(bodyExpr, value, bodyReturn), + loc: locOf(node.body!), + }, + ]; + if (fnCtx.inferReturn && body[0]?.kind === "return") + fnCtx.inferReturn.entries.push({ stmt: body[0], node: bodyExpr }); } - } else { - // Concise and block bodies use the same destination layout for - // fresh literals. Async bodies must inspect promises first. - let value = - isAsync || fnCtx.inferReturn - ? lowerer.lowerExpr(bodyExpr) - : lowerer.lowerExprExpecting(bodyExpr, bodyReturn); - // An async concise body whose value is itself a promise - // (`async () => p`): the async machinery RESOLVES the returned - // thenable into the function's own promise — lowerReturnValue's - // await-through, applied to the implicit return. - if (isAsync && value.type.kind === "promise" && bodyReturn.kind !== "promise") { - value = lowerer.returnedPromiseAwait(value); - } - const flattened = - isAsync && !fnCtx.inferReturn - ? lowerer.maybePromiseReturn(bodyExpr, value, bodyReturn) - : null; - body = flattened - ? [flattened] - : value.type.kind === "void" && lowerer.wrappedUndefined(bodyReturn, locOf(node.body!)) - ? [{ kind: "exprStmt", expr: value, loc: locOf(node.body!) }] - : [ - { - kind: "return", - value: fnCtx.inferReturn - ? value - : lowerer.coerceInto(bodyExpr, value, bodyReturn), - loc: locOf(node.body!), - }, - ]; - if (fnCtx.inferReturn && body[0]?.kind === "return") - fnCtx.inferReturn.entries.push({ stmt: body[0], node: bodyExpr }); } - } + return body; + }; + let body = undefinedResult + ? lowerUndefinedResultBody(lowerer, node, undefinedResult, lowerBody) + : lowerBody(); if (fnCtx.inferReturn) { bodyReturn = resolveInferredReturn( lowerer, @@ -537,7 +557,14 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I "function declarations whose block body the frontend cannot locate", ); } - const body = [...prologue, ...lowerer.lowerStmts(bodyBlock.statements.nodes)]; + const undefinedResult = undefinedResultParam(lowerer, decl, sig.params, false); + const lowerBody = (): IrStmt[] => lowerer.lowerStmts(bodyBlock.statements.nodes); + const body = [ + ...prologue, + ...(undefinedResult + ? lowerUndefinedResultBody(lowerer, decl, undefinedResult, lowerBody) + : lowerBody()), + ]; appendImplicitUndefinedReturn(lowerer, body, bodyReturn, locOf(decl)); const fn: IrFunction = { name: sig.name, @@ -594,3 +621,240 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I lowerer.fnStack.pop(); } } + +/** The parameters of an error-first callback whose Buffer result its body + * reads: `(err, out)` with err typed Error | null (or any) and out a + * Buffer. The callback adapters of zlib and crypto pass a NULL Buffer + * after an error, where Node passes undefined. `error` is the error + * parameter's symbol when nothing writes it; `onlyAdapterCalls` marks a + * literal passed straight to such a function, which nothing else calls. */ +interface UndefinedResultParam { + symbol: ts.Symbol; + name: ts.Identifier; + error: ts.Symbol | null; + onlyAdapterCalls: boolean; +} + +function undefinedResultParam( + lowerer: Lowerer, + node: ts.SignatureDeclaration, + shapes: readonly ParamShape[], + onlyAdapterCalls: boolean, +): UndefinedResultParam | null { + const params = node.parameters.nodes.filter((p) => !isThisParameter(p)); + if (params.length !== 2 || shapes.length !== 2 || node.asteriskToken !== undefined) return null; + const [errorDecl, decl] = params as [ts.ParameterDeclaration, ts.ParameterDeclaration]; + const [errorShape, shape] = shapes as [ParamShape, ParamShape]; + if ( + !ts.isIdentifier(decl.name) || + decl.initializer !== undefined || + decl.questionToken !== undefined || + decl.dotDotDotToken !== undefined || + shape.mode !== "required" || + shape.type.kind !== "bytes" || + shape.type.elem !== "u8" || + errorShape.mode !== "required" || + !isErrorFirstErrorType(lowerer, errorShape.type) + ) + return null; + const symbol = lowerer.checker.getSymbolAtLocation(decl.name); + if ( + symbol === undefined || + node.body === undefined || + !readsSymbol(lowerer, node.body, symbol, decl.name) + ) + return null; + const errorSymbol = ts.isIdentifier(errorDecl.name) + ? lowerer.checker.getSymbolAtLocation(errorDecl.name) + : undefined; + const error = + errorSymbol !== undefined && + errorDecl.initializer === undefined && + bindingNeverReassigned(lowerer, errorSymbol, errorDecl) + ? errorSymbol + : null; + return { symbol, name: decl.name, error, onlyAdapterCalls }; +} + +/** Whether `body` reads `symbol` (other than at its declaration `name`). */ +function readsSymbol( + lowerer: Lowerer, + body: ts.Node, + symbol: ts.Symbol, + name: ts.Identifier, +): boolean { + let read = false; + const visit = (n: ts.Node): void => { + if (read) return; + if (ts.isShorthandPropertyAssignment(n)) { + if (lowerer.checker.getShorthandAssignmentValueSymbol(n) === symbol) read = true; + } else if (ts.isIdentifier(n)) { + if (n !== name && lowerer.checker.getSymbolAtLocation(n) === symbol) read = true; + } else { + ts.forEachChild(n, visit); + } + }; + visit(body); + return read; +} + +/** The body of an error-first callback whose Buffer result may be absent. + * With a result, the body lowers as written. Without one (the adapter's + * NULL Buffer after an error), the body lowers again with the result read + * as `any` holding undefined and the error parameter holding an Error. + * Conditions on the error parameter fold in that copy (staticCondition), + * and in the first copy too, as null, for a literal only the adapter + * calls: a body that returns after `if (err)` then keeps the rest of its + * statements in the first copy only. + * + * Inside an error-path copy, a nested callback lowers once with the result + * read as `any`, so nested callbacks do not double at every level. An + * error-path copy with no lowering throws a coded error when it runs. */ +function lowerUndefinedResultBody( + lowerer: Lowerer, + node: ts.SignatureDeclaration, + result: UndefinedResultParam, + lowerBody: () => IrStmt[], +): IrStmt[] { + const loc = locOf(node); + const param = lowerer.bindingIn(lowerer.ctx, result.symbol); + if (param === null) throw new InternalCompilerError("lowerer bug: callback result unbound"); + const absent = (): IrExpr => ({ + kind: "libCall", + fn: "bytes.isAbsent", + args: [varRef(param.id, param.type, loc)], + type: BOOL, + loc, + }); + const fence: IrStmt = { + kind: "runtimeFence", + code: "SC2020", + message: `the callback's '${result.name.text}' is undefined after an error, and this callback has no lowering that reads it as undefined`, + loc, + }; + const withKnownError = (error: boolean | null, lower: () => IrStmt[]): IrStmt[] => { + if (result.error === null || error === null) return lower(); + lowerer.knownErrors.set(result.error, error); + try { + return lower(); + } finally { + lowerer.knownErrors.delete(result.error); + } + }; + /** The body with the result read as `any` from `slot`, or null when it + * has no such lowering or converts the result into a static slot. */ + const lowerDynamic = (slot: IrLocal, knownError: boolean | null): IrStmt[] | null => { + const diags = lowerer.diags.length; + const stats = lowerer.suppressStats; + lowerer.scopes.push(new Map([[result.symbol, slot]])); + lowerer.anySymbols.add(result.symbol); + lowerer.suppressStats = true; + lowerer.errorPathCopies++; + try { + const lowered = withKnownError(knownError, lowerBody); + if (lowerer.diags.length > diags || !readsSlotDynamically(lowered, slot.id)) + throw new PoisonError(); + // Passed on into a Buffer slot, the result is the Buffer it holds + // (the adapter's, or one the body assigned), or, while it holds + // undefined, the absent Buffer, which a callee with this entry test + // reads as undefined. + const guarded = new Set(); + return transformStmtList(lowered, { + expr: (e) => { + if (!isBufferCheckOf(e, slot.id) || guarded.has(e)) return e; + guarded.add(e); + return { + kind: "ternary", + cond: { + kind: "dynTest", + test: "undefined", + value: varRef(slot.id, DYN, e.loc), + type: BOOL, + loc: e.loc, + }, + then: { kind: "libCall", fn: "bytes.absent", args: [], type: e.type, loc: e.loc }, + else_: e, + type: e.type, + loc: e.loc, + }; + }, + stmt: (stmt) => stmt, + }); + } catch (error) { + if (error instanceof InternalCompilerError) throw error; + lowerer.diags.length = diags; + return null; + } finally { + lowerer.errorPathCopies--; + lowerer.anySymbols.delete(result.symbol); + lowerer.scopes.pop(); + lowerer.suppressStats = stats; + } + }; + // Mutable: the body may assign to its result (`if (!out) out = ...`). + const slot = declareContextLocal(lowerer.ctx, "%result", DYN, true, undefined, undefined); + const undefinedResult: IrStmt = { + kind: "varDecl", + localId: slot.id, + init: dynUndefinedExpr(loc), + loc, + }; + if (lowerer.errorPathCopies > 0) { + // One copy for both paths, with the result boxed when present. + const dynamic = lowerDynamic(slot, null); + if (dynamic !== null) + return [ + { + kind: "varDecl", + localId: slot.id, + init: { + kind: "ternary", + cond: absent(), + then: dynUndefinedExpr(loc), + else_: lowerer.coerceInto(result.name, varRef(param.id, param.type, loc), DYN), + type: DYN, + loc, + }, + loc, + }, + ...dynamic, + ]; + } + const withResult = withKnownError(result.onlyAdapterCalls ? false : null, lowerBody); + const withoutResult = lowerDynamic(slot, true); + return [ + { + kind: "if", + cond: absent(), + then: withoutResult === null ? [fence] : [undefinedResult, ...withoutResult], + else_: withResult, + loc, + }, + ]; +} + +/** Whether the error-path copy reads the checked-dynamic result slot only as + * a checked-dynamic value, never converted into a static slot (where Node + * would hold undefined) other than a Buffer one. */ +function readsSlotDynamically(body: IrStmt[], slotId: string): boolean { + const isSlot = (e: IrExpr): boolean => e.kind === "varRef" && e.localId === slotId; + return everyStmtList(body, { + expr: (e) => { + if (isSlot(e)) return e.type.kind === "dyn"; + if (e.kind === "dynCheck" && isSlot(e.value)) return isBufferCheckOf(e, slotId); + return true; + }, + stmt: () => true, + }); +} + +/** Whether `e` converts the result slot into a Buffer slot. */ +function isBufferCheckOf(e: IrExpr, slotId: string): boolean { + return ( + e.kind === "dynCheck" && + e.value.kind === "varRef" && + e.value.localId === slotId && + e.type.kind === "bytes" && + e.type.elem === "u8" + ); +} diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 5bd0a2800..c1396e90b 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -1054,7 +1054,8 @@ export function moduleArtifacts( } const reachable = lowerer.reachableForArtifacts ?? lowerer.reachable; return { - classes: [...lowerer.classes.values()] + classes: lowerer + .classesInTableOrder() .map((c) => c.def) .filter((def) => classNames.has(def.name)) .map((def): IrClassDef => { diff --git a/packages/compiler/src/frontend/lowering/lower-server.ts b/packages/compiler/src/frontend/lowering/lower-server.ts index f0da85bf8..058d7874a 100644 --- a/packages/compiler/src/frontend/lowering/lower-server.ts +++ b/packages/compiler/src/frontend/lowering/lower-server.ts @@ -4749,7 +4749,8 @@ function lowerTlsServerOptions( lowerer: Lowerer, node: ts.Expression, what: string, -): { cert: IrExpr; key: IrExpr } { + allowSni = false, +): { cert: IrExpr; key: IrExpr; sni?: IrExpr } { if (!ts.isObjectLiteralExpression(node)) { lowerer.noLowering( `${what} with a non-literal options argument`, @@ -4760,6 +4761,8 @@ function lowerTlsServerOptions( const loc = locOf(node); let cert: IrExpr | null = null; let key: IrExpr | null = null; + let sni: IrExpr | undefined; + let pendingSni: IrStmt | null = null; for (const prop of node.properties.nodes) { let initializer: ts.Expression | null; if ( @@ -4777,6 +4780,25 @@ function lowerTlsServerOptions( ); } const k = (prop.name as ts.Identifier | ts.StringLiteral).text; + if (k === "SNICallback" && allowSni) { + const value = + initializer !== null + ? lowerer.lowerExpr(initializer) + : lowerer.lowerShorthandValue(prop as ts.ShorthandPropertyAssignment); + const checked = checkSniCallbackValue(lowerer, value, prop, what); + if (cert !== null && key !== null) { + sni = checked; + } else { + // The runtime call reads cert and key first. A cert or key entry + // after the callback can change the binding the callback reads, + // so the callback is saved ahead of the next of them, in source + // order. + const saved = lowerer.declareHiddenLocal("%sniCallback", checked.type); + pendingSni = { kind: "varDecl", localId: saved.id, init: checked, loc: locOf(prop) }; + sni = varRef(saved.id, checked.type, locOf(prop)); + } + continue; + } if (k !== "cert" && k !== "key") { const init = ts.isPropertyAssignment(prop) ? prop.initializer : null; if ( @@ -4801,7 +4823,7 @@ function lowerTlsServerOptions( "cert and key (PEM strings or Buffers) are the supported options", { SNICallback: - "SNICallback lowers only on http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here", + "SNICallback lowers on tls.createServer and http2.createSecureServer({ allowHTTP1: true, ... }) — serve one cert/key pair here", requestCert: "client-certificate handshakes are not modeled (SEMANTICS.md divergence 55) — requestCert: false is the lowered value", }, @@ -4838,6 +4860,10 @@ function lowerTlsServerOptions( "cert/key are PEM strings or Buffers here", ); } + if (pendingSni !== null) { + v = { kind: "seqExpr", stmts: [pendingSni], result: v, type: v.type, loc }; + pendingSni = null; + } if (k === "cert") cert = v; else key = v; } @@ -4848,7 +4874,7 @@ function lowerTlsServerOptions( "the supported options object is { cert, key } (PEM strings or Buffers)", ); } - return { cert, key }; + return sni === undefined ? { cert, key } : { cert, key, sni }; } /** The options argument of tls/https createServer, split three ways: an @@ -4877,12 +4903,14 @@ const TLS_VALIDATED_OPTIONS: ReadonlySet = new Set([ ]); /** True when a literal options bag must ride the runtime walker: JS uses - * it for the typed TLS validation ladders, and HTTPS uses it for + * it for the typed TLS validation ladders, HTTPS uses it for * keepAliveTimeoutBuffer so the HTTP option is consumed without losing - * the literal's evaluation order. */ + * the literal's evaluation order, and `runtimeKeys` names options only the + * runtime walk implements (tls.createServer's ALPNProtocols). */ function tlsLiteralNeedsRuntimeWalk( node: ts.ObjectLiteralExpression, includeHttpTimeoutOptions = false, + runtimeKeys: ReadonlySet = new Set(), ): boolean { const jsSource = isJsSourceFile(ts.getSourceFileOfNode(node)); return node.properties.nodes.some( @@ -4890,21 +4918,43 @@ function tlsLiteralNeedsRuntimeWalk( (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && ((includeHttpTimeoutOptions && p.name.text === "keepAliveTimeoutBuffer") || + runtimeKeys.has(p.name.text) || (jsSource && TLS_VALIDATED_OPTIONS.has(p.name.text))), ); } +/** tls.createServer options the runtime walk implements and the literal + * cert/key lowering does not. */ +const TLS_SERVER_RUNTIME_OPTIONS: ReadonlySet = new Set([ + "ALPNProtocols", + "minVersion", + "maxVersion", +]); + function lowerTlsServerOptionsOrDyn( lowerer: Lowerer, node: ts.Expression, what: string, includeHttpTimeoutOptions = false, -): { cert: IrExpr; key: IrExpr; dyn?: undefined } | { dyn: IrExpr } { - if ( - ts.isObjectLiteralExpression(node) && - !tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions) - ) { - return lowerTlsServerOptions(lowerer, node, what); + runtimeKeys: ReadonlySet = new Set(), + allowSni = false, +): { cert: IrExpr; key: IrExpr; sni?: IrExpr; dyn?: undefined } | { dyn: IrExpr } { + if (ts.isObjectLiteralExpression(node)) { + if (!tlsLiteralNeedsRuntimeWalk(node, includeHttpTimeoutOptions, runtimeKeys)) + return lowerTlsServerOptions(lowerer, node, what, allowSni); + const sniOption = node.properties.nodes.find( + (p) => + (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && + (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && + p.name.text === "SNICallback", + ); + if (allowSni && sniOption !== undefined) { + lowerer.noLowering( + `${what} with SNICallback and options the runtime walk reads`, + sniOption, + "SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet", + ); + } } const v = lowerer.lowerExpr(node); if (v.type.kind === "dyn") return { dyn: v }; @@ -4934,14 +4984,14 @@ function lowerTlsServerOptionsOrDyn( * connect(options[, cb]) and connect(port[, host][, options][, cb]) with * a RUNTIME options record (the checked-dynamic JS lane's shape — a dyn * value, or a record that converts): the runtime walk implements - * port/host/rejectUnauthorized/ca/servername and throws the catchable + * port/host/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout and throws the catchable * fence for other documented members (the divergence-66 stance). The * callback (and 'secureConnect'/'connect' listeners) fires * post-handshake — Node's secureConnect timing. */ function lowerTlsConnectCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr { const args = expr.arguments.nodes; const FORMS_HINT = - "the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/rejectUnauthorized/ca/servername"; + "the supported forms are connect(options[, cb]) and connect(port[, host][, options][, cb]) — options implement port/host/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout"; if (args.length < 1 || args.length > 4 || args.some(ts.isSpreadElement)) { lowerer.noLowering(`tls.connect with ${args.length} arguments`, expr, FORMS_HINT); } @@ -5060,10 +5110,31 @@ function lowerTlsModuleCall( "the supported form is createServer({ cert, key }, socket => ...)", ); } - const opts = lowerTlsServerOptionsOrDyn(lowerer, args[0]!, "tls.createServer"); + const opts = lowerTlsServerOptionsOrDyn( + lowerer, + args[0]!, + "tls.createServer", + false, + TLS_SERVER_RUNTIME_OPTIONS, + true, + ); if (args.length === 1) { - return opts.dyn !== undefined - ? { kind: "libCall", fn: "tls.createServerDyn", args: [opts.dyn], type: NETSERVER_T, loc } + if (opts.dyn !== undefined) + return { + kind: "libCall", + fn: "tls.createServerDyn", + args: [opts.dyn], + type: NETSERVER_T, + loc, + }; + return opts.sni !== undefined + ? { + kind: "libCall", + fn: "tls.createServerSni", + args: [opts.cert, opts.key, opts.sni], + type: NETSERVER_T, + loc, + } : { kind: "libCall", fn: "tls.createServer", @@ -5081,11 +5152,19 @@ function lowerTlsModuleCall( "use (socket) or ()", [NETSOCKET_T], ); - return opts.dyn !== undefined + if (opts.dyn !== undefined) + return { + kind: "libCall", + fn: "tls.createServerDynCb", + args: [opts.dyn, cb], + type: NETSERVER_T, + loc, + }; + return opts.sni !== undefined ? { kind: "libCall", - fn: "tls.createServerDynCb", - args: [opts.dyn, cb], + fn: "tls.createServerSniCb", + args: [opts.cert, opts.key, opts.sni, cb], type: NETSERVER_T, loc, } @@ -5463,6 +5542,44 @@ const SNI_HINT = "...(x ? { SNICallback: x } : {}) — the callback is (servername, cb) => void " + "with cb: (err: Error | null, ctx?: tls.SecureContext) => void"; +/** An SNICallback option value: (servername: string, cb) => void where cb + * is (err: Error | null, ctx?: SecureContext) => void, or that function + * unioned with undefined (no callback). cb may declare fewer params (the + * emitted answer thunk matches its ABI), but what it does declare must + * carry the arms the thunk decodes. */ +function lowerSniCallbackValue( + lowerer: Lowerer, + node: ts.Expression, + blame: ts.Node, + what: string, +): IrExpr { + return checkSniCallbackValue(lowerer, lowerer.lowerExpr(node), blame, what); +} + +function checkSniCallbackValue(lowerer: Lowerer, v: IrExpr, blame: ts.Node, what: string): IrExpr { + const shapeOk = (t: IrType): boolean => { + if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false; + if (t.params[0]!.kind !== "string") return false; + const cbT = t.params[1]!; + if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false; + const armKinds = (u: IrType): string[] => + u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : []; + const p0 = cbT.params[0]; + if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false; + const p1 = cbT.params[1]; + if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false; + return true; + }; + const arms = v.type.kind === "union" ? (lowerer.unions.get(v.type.unionId)?.arms ?? []) : []; + const funcArm = v.type.kind === "func" ? v.type : arms.find((a) => a.kind === "func"); + const unionOk = + v.type.kind !== "union" || (arms.length === 2 && arms.some((a) => a.kind === "undefinedT")); + if (funcArm === undefined || !unionOk || !shapeOk(funcArm)) { + lowerer.noLowering(`a ${what} SNICallback of '${lowerer.fmt(v.type)}' values`, blame, SNI_HINT); + } + return v; +} + /** Strips parentheses and type assertions: the portless spelling wraps * an options spread as `...({ ... } as Record)`. */ function stripParensAndCasts(node: ts.Expression): ts.Expression { @@ -5715,43 +5832,8 @@ function lowerHttp2ModuleCall( let sni: IrExpr | null = null; let allowHttp1 = false; let enableConnectProtocol = false; - // The SNICallback value shape: (servername: string, cb) => void where - // cb is (err: Error | null, ctx?: SecureContext) => void — cb may - // declare fewer params (the emitted answer thunk matches its ABI), - // but what it does declare must carry the arms the thunk decodes. - const sniShapeOk = (t: IrType): boolean => { - if (t.kind !== "func" || t.ret.kind !== "void" || t.params.length !== 2) return false; - if (t.params[0]!.kind !== "string") return false; - const cbT = t.params[1]!; - if (cbT.kind !== "func" || cbT.ret.kind !== "void" || cbT.params.length > 2) return false; - const armKinds = (u: IrType): string[] => - u.kind === "union" ? (lowerer.unions.get(u.unionId)?.arms ?? []).map((a) => a.kind) : []; - const p0 = cbT.params[0]; - if (p0 !== undefined && !armKinds(p0).includes("nullT")) return false; - const p1 = cbT.params[1]; - if (p1 !== undefined && !armKinds(p1).includes("secureCtx")) return false; - return true; - }; const lowerSniValue = (node: ts.Expression, blame: ts.Node): void => { - const v = lowerer.lowerExpr(node); - const funcArm = - v.type.kind === "func" - ? v.type - : v.type.kind === "union" - ? (lowerer.unions.get(v.type.unionId)?.arms ?? []).find((a) => a.kind === "func") - : undefined; - const unionOk = - v.type.kind !== "union" || - ((lowerer.unions.get(v.type.unionId)?.arms ?? []).length === 2 && - (lowerer.unions.get(v.type.unionId)?.arms ?? []).some((a) => a.kind === "undefinedT")); - if (funcArm === undefined || !unionOk || !sniShapeOk(funcArm)) { - lowerer.noLowering( - `a createSecureServer SNICallback of '${lowerer.fmt(v.type)}' values`, - blame, - SNI_HINT, - ); - } - sni = v; + sni = lowerSniCallbackValue(lowerer, node, blame, "createSecureServer"); }; const lowerOption = (prop: ts.ObjectLiteralElementLike): void => { if (ts.isSpreadAssignment(prop)) { diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index e968af638..2ad3657c7 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -2,7 +2,7 @@ import { dynUndefinedExpr, nodeThrowExpr } from "../../ir/build.js"; import { InternalCompilerError } from "../../errors.js"; import * as ts from "../ts7/adapter.js"; -import { lowerToNumberArgument } from "./lower-exprs.js"; +import { lowerToNumberArguments } from "./lower-exprs.js"; import type { Lowerer } from "./lowerer.js"; import { isDeclaredInAmbientNamespace } from "../type-mapper.js"; import { @@ -2415,8 +2415,14 @@ export function lowerStdlibMethodCall( const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined; // A missing element (a hole bound by a loop, a local or field holding a // missing read, or the read itself) converts to NaN, ToNumber's answer - // for undefined, instead of trusting the checker's number type. - const mathArg = (a: ts.Expression): IrExpr => lowerToNumberArgument(lowerer, a); + // for undefined, instead of trusting the checker's number type. The + // arguments are all evaluated before any converts (lowerToNumberArguments). + const mathArgs = ( + nodes: readonly ts.Expression[], + lowerOther?: (node: ts.Expression) => IrExpr | null, + ) => lowerToNumberArguments(lowerer, nodes, lowerOther); + const afterArguments = (stmts: IrStmt[], result: IrExpr): IrExpr => + stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: result.type, loc }; // Math.hypot, and Math.max/Math.min with a spread among other arguments // (`Math.max(...xs, 5)`): the arguments in order, spreads flattened, as // one number[] the runtime folds. @@ -2427,8 +2433,8 @@ export function lowerStdlibMethodCall( call.arguments.nodes.length > 1 && call.arguments.nodes.some(ts.isSpreadElement))) ) { - const elems = call.arguments.nodes.map((a) => - ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a), + const { stmts, values: elems } = mathArgs(call.arguments.nodes, (a) => + ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : null, ); const spreads = call.arguments.nodes.flatMap((a, i) => (ts.isSpreadElement(a) ? [i] : [])); const packed: IrExpr = { @@ -2439,7 +2445,7 @@ export function lowerStdlibMethodCall( loc, }; const fn = name === "max" ? "math.maxArr" : name === "min" ? "math.minArr" : staticMath.fn; - return { kind: "libCall", fn, args: [packed], type: F64, loc }; + return afterArguments(stmts, { kind: "libCall", fn, args: [packed], type: F64, loc }); } if (staticMath && call.arguments.nodes.every((a) => !ts.isSpreadElement(a))) { // Math.max/Math.min at ANY plain arity — Node's are variadic. The @@ -2455,16 +2461,16 @@ export function lowerStdlibMethodCall( if (call.arguments.nodes.length === 0) { return { kind: "numLit", value: name === "max" ? -Infinity : Infinity, type: F64, loc }; } - const args = call.arguments.nodes.map(mathArg); + const { stmts, values: args } = mathArgs(call.arguments.nodes); let acc = args[0]!; for (let i = 1; i < args.length; i++) { acc = { kind: "libCall", fn: staticMath.fn, args: [acc, args[i]!], type: F64, loc }; } - return acc; + return afterArguments(stmts, acc); } if (call.arguments.nodes.length === staticMath.arity) { - const args = call.arguments.nodes.map(mathArg); - return { kind: "libCall", fn: staticMath.fn, args, type: F64, loc }; + const { stmts, values: args } = mathArgs(call.arguments.nodes); + return afterArguments(stmts, { kind: "libCall", fn: staticMath.fn, args, type: F64, loc }); } } // `Math.max(...xs)` / `Math.min(...xs)` over a number[]: a STATIC diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.test.ts b/packages/compiler/src/frontend/lowering/lower-stmts.test.ts index 322b21b2a..8e0c809b7 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.test.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.test.ts @@ -54,7 +54,7 @@ function mutate(value) { captured = value; compound += value; let shadow = 0; sh ctx.bind(shadow, []); ctx.bind(untouched, []); ctx.bind(innerShadow, [at(ctx.assignments, 2)]); - const walk = vi.spyOn(ctx.file, "appendChildIndices"); + const walk = vi.spyOn(ctx.file.file.wire, "preorder"); for (let i = 0; i < 20; i++) { expect(jsBindingHasOpenWrites(ctx.lowerer, captured)).toBe(true); expect(jsBindingHasOpenWrites(ctx.lowerer, compound)).toBe(true); diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index b04ba0b09..e3480cf9c 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -159,7 +159,7 @@ import { classStaticDataFor } from "./class-static-data.js"; import { objectFactorySignature } from "./object-factory-new.js"; import { isClassCallback, lowerClassCallbackAssign } from "./class-callbacks.js"; import { classForInHasKey, classPropertiesHelper } from "./class-dynamic-dispatch.js"; -import { genericIfaceBindingKeepsClass } from "./lower-classes.js"; +import { genericIfaceBindingKeepsClass, superConditionLead } from "./lower-classes.js"; import { lowerStreamUnderscoreAssign, streamClassAliasDecl } from "./lower-stream.js"; import { staticAssertInstanceDecl } from "./assert-instances.js"; import { @@ -1537,43 +1537,48 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt return lowerer.lowerDefaultExport(stmt); } if (ts.isIfStatement(stmt)) { - const cond = lowerer.lowerCondition(stmt.expression); + // `if (super(), test)`: the super call runs as a statement, then the + // test decides. + const lead = superConditionLead(stmt.expression); + const leading = lead ? lowerer.lowerExprStatement(lead.left) : null; + const test = lead ? lead.right : stmt.expression; + const cond = lowerer.lowerCondition(test); // A global-detection condition (static-typeof.ts) folds; its untaken // arm is code Node never runs and does not lower, unless it declares a // hoisted binding. - const decided = staticCondition(lowerer, stmt.expression); + const decided = staticCondition(lowerer, test); if (decided !== null) { const taken = decided ? stmt.thenStatement : stmt.elseStatement; const untaken = decided ? stmt.elseStatement : stmt.thenStatement; if (untaken === undefined || !declaresHoistedBinding(untaken)) { const arm = taken === undefined ? [] : lowerer.lowerScopedBlock(taken); - return decided + const folded: IrStmt = decided ? { kind: "if", cond, then: arm, else_: null, loc: locOf(stmt) } : { kind: "if", cond, then: [], else_: arm, loc: locOf(stmt) }; + return leading ? { kind: "block", body: [leading, folded], loc: locOf(stmt) } : folded; } } // Aliased-typeof narrows (ms's `var type = typeof val` guard): the // then-branch lowers under what a TRUE condition proves, the // else-branch under a FALSE one — the var/let alias narrowing the // checker only performs for consts (aliasTypeofNarrows). - const then = lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, stmt.expression, true), () => - withRuntimeOptionalNarrowed(lowerer, runtimeOptionalTrueIds(lowerer, stmt.expression), () => + const then = lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, test, true), () => + withRuntimeOptionalNarrowed(lowerer, runtimeOptionalTrueIds(lowerer, test), () => lowerer.lowerScopedBlock(stmt.thenStatement), ), ); const else_ = stmt.elseStatement - ? lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, stmt.expression, false), () => - withRuntimeOptionalNarrowed( - lowerer, - runtimeOptionalGuardIds(lowerer, stmt.expression, false), - () => lowerer.lowerScopedBlock(stmt.elseStatement!), + ? lowerer.narrowingAliases(aliasTypeofNarrows(lowerer, test, false), () => + withRuntimeOptionalNarrowed(lowerer, runtimeOptionalGuardIds(lowerer, test, false), () => + lowerer.lowerScopedBlock(stmt.elseStatement!), ), ) : null; for (const local of falsyExitRuntimeOptionalLocals(lowerer, stmt)) { lowerer.runtimeOptionalLocals.delete(lowerer.runtimeOptionalRootOf(local)); } - return { kind: "if", cond, then, else_, loc: locOf(stmt) }; + const lowered: IrStmt = { kind: "if", cond, then, else_, loc: locOf(stmt) }; + return leading ? { kind: "block", body: [leading, lowered], loc: locOf(stmt) } : lowered; } if (ts.isWhileStatement(stmt)) { const labels = lowerer.takeLabels(); @@ -11594,10 +11599,23 @@ function lowerForInArray( try { const arr = lowerer.declareHiddenLocal("%inarr", arrExpr.type); const len = lowerer.declareHiddenLocal("%inlen", F64); + const keysT: IrType = { kind: "array", elem: STRING }; + const keys = lowerer.declareHiddenLocal("%inkeys", keysT); + const total = lowerer.declareHiddenLocal("%intotal", F64); const i = lowerer.declareHiddenLocal("%ini", F64); i.mutable = true; const arrRef = (): IrExpr => ({ kind: "varRef", localId: arr.id, type: arrExpr.type, loc }); + const keysRef = (): IrExpr => ({ kind: "varRef", localId: keys.id, type: keysT, loc }); + const lenRef = (): IrExpr => ({ kind: "varRef", localId: len.id, type: F64, loc }); const iRef = (): IrExpr => ({ kind: "varRef", localId: i.id, type: F64, loc }); + const isIndex = (): IrExpr => ({ + kind: "bin", + op: "<", + left: iRef(), + right: lenRef(), + type: BOOL, + loc, + }); const liveLen = (): IrExpr => ({ kind: "arrIntrinsic", method: "length", @@ -11606,7 +11624,52 @@ function lowerForInArray( type: F64, loc, }); - const keyInit: IrExpr = { kind: "toString", operand: iRef(), type: STRING, loc }; + // Indices first, then the named keys the array had when the loop began. + const namedKey = (): IrExpr => ({ + kind: "arrayGet", + arr: keysRef(), + index: { kind: "bin", op: "-", left: iRef(), right: lenRef(), type: F64, loc }, + type: STRING, + loc, + }); + const keyInit: IrExpr = { + kind: "ternary", + cond: isIndex(), + then: { kind: "toString", operand: iRef(), type: STRING, loc }, + else_: namedKey(), + type: STRING, + loc, + }; + // An index is visited while it is below the live length and not a hole, + // a named key while the array still has it. + const visits: IrExpr = { + kind: "ternary", + cond: isIndex(), + then: { + kind: "logical", + op: "&&", + left: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc }, + right: { + kind: "bin", + op: "!==", + left: { kind: "arrayState", arr: arrRef(), index: iRef(), type: F64, loc }, + right: { kind: "numLit", value: 0, type: F64, loc }, + type: BOOL, + loc, + }, + type: BOOL, + loc, + }, + else_: { + kind: "libCall", + fn: "array.hasNamedKey", + args: [arrRef(), namedKey()], + type: BOOL, + loc, + }, + type: BOOL, + loc, + }; // The three binding forms, sharing the per-visit key declaration. let keyDecl: IrStmt; @@ -11670,6 +11733,32 @@ function lowerForInArray( body: [ { kind: "varDecl", localId: arr.id, init: arrExpr, loc }, { kind: "varDecl", localId: len.id, init: liveLen(), loc }, + { + kind: "varDecl", + localId: keys.id, + init: { kind: "libCall", fn: "array.namedKeys", args: [arrRef()], type: keysT, loc }, + loc, + }, + { + kind: "varDecl", + localId: total.id, + init: { + kind: "bin", + op: "+", + left: lenRef(), + right: { + kind: "arrIntrinsic", + method: "length", + receiver: keysRef(), + args: [], + type: F64, + loc, + }, + type: F64, + loc, + }, + loc, + }, { kind: "for", init: { @@ -11682,7 +11771,7 @@ function lowerForInArray( kind: "bin", op: "<", left: iRef(), - right: { kind: "varRef", localId: len.id, type: F64, loc }, + right: { kind: "varRef", localId: total.id, type: F64, loc }, type: BOOL, loc, }, @@ -11702,7 +11791,7 @@ function lowerForInArray( body: [ { kind: "if", - cond: { kind: "bin", op: "<", left: iRef(), right: liveLen(), type: BOOL, loc }, + cond: visits, then: [keyDecl, ...writes, ...body], else_: null, loc, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ebd0a4ca0..99dea40a6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,4 +1,5 @@ -import { onlyForwarded } from "./typed-arguments.js"; +import { relieveHeapPressure } from "../../heap-pressure.js"; +import { onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; import { needsGeneralNodeStreams, noteUntypedStreamFlow, @@ -121,6 +122,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js"; import { indexReadInBounds } from "./runtime-optional-bounds.js"; import { ArrayOwnership } from "./runtime-optional-ownership.js"; import { RecordFlowClasses } from "./runtime-optional-records.js"; +import { SiteDependencies } from "./site-dependencies.js"; import { StringIndexBounds } from "./string-index-bounds.js"; import { NanCodedReads } from "./nan-coded-reads.js"; import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; @@ -146,6 +148,7 @@ import { ClassProtocols } from "./class-protocols.js"; import { earlyReadVarSymbols } from "./var-hoisting.js"; import { recordTextCodecClass } from "../../ir/ir.js"; import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js"; +import { dynamicTierWarning } from "./dynamic-tier.js"; import { promoteJsvals } from "../../ir/jsval.js"; import { analyzeTiers, @@ -174,7 +177,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js"; * - Lexical scoping is resolved here: locals get function-unique ids * ("x.0", "x.1" for shadowing); the IR is scope-flat. */ -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; import { tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; @@ -784,6 +787,9 @@ export interface LowerResult { /** Compile-time-detectable behavior differences from Node (SC6xxx * divergence warnings) in the program's own modules. Never failures. */ divergences?: ScrDiagnostic[]; + /** Performance warnings (SC61xx): the likely-hot functions the source + * leaves untyped (lowering/dynamic-tier.ts). Never failures. */ + performance?: ScrDiagnostic[]; /** Functions lowering placed in the dynamic tier (ir/tiers.ts), present * when there are any. */ dynamicFunctions?: DynamicFunction[]; @@ -953,6 +959,33 @@ function directExternalTypeSpecifiersByFile( return out; } +/** Debugging aid: SCRIPTC_LOWER_SHUFFLE_SEED= lowers each wave of + * reachable bodies in an order drawn from that seed instead of discovery + * order. Output assembly is ordered independently of it, so any IR + * difference between seeds is state one body's lowering leaves for another + * (scripts/lowering-order.mjs). */ +function lowerShuffle(): ((wave: string[]) => void) | null { + const raw = process.env["SCRIPTC_LOWER_SHUFFLE_SEED"]; + if (raw === undefined || raw === "") return null; + // mulberry32 + let state = Number.parseInt(raw, 10) >>> 0; + const next = (): number => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return (wave) => { + for (let index = wave.length - 1; index > 0; index--) { + const other = Math.floor(next() * (index + 1)); + const swap = wave[index]!; + wave[index] = wave[other]!; + wave[other] = swap; + } + }; +} + /** Build lowering runs as a reachability worklist over the ts.Program: * * 1. REACHABLE EMIT — a worklist computes the set of reachable bodies. @@ -1160,6 +1193,13 @@ export function lowerToIr( const divergences = scanDivergences(resultLowerer, moduleOrder); timing("divergence-scan", { found: divergences.length }); if (divergences.length > 0) result = { ...result, divergences }; + // The performance warning (SC6101): likely-hot functions of the program + // that compile to the dynamic tier because the source leaves them untyped. + if (result.module !== null && resultLowerer.dynamicFunctions.length > 0) { + const hotDynamic = dynamicTierWarning(resultLowerer, result.module, dirname(entry.fileName)); + timing("dynamic-tier-scan", { found: hotDynamic === null ? 0 : 1 }); + if (hotDynamic !== null) result = { ...result, performance: [hotDynamic] }; + } // Contract types (@scriptc/types): written annotations, checked against // the IR by the compile pipeline (contracts/check.ts). // Acyclic object types name the IR unit the program lowered them to; a @@ -1828,6 +1868,13 @@ function monomorphizedImplicit( return true; } +/** `fns` sorted by name; functions of one name keep their order. */ +function byName(fns: readonly IrFunction[]): IrFunction[] { + return [...fns].sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); +} + export class Lowerer { readonly frontendServices: FrontendServices | undefined; readonly checker: ts.TypeChecker; @@ -2014,6 +2061,40 @@ export class Lowerer { this.genericClassInstances.splice(from, tail.length, ...tail); } + /** The classes in table order. Generic instances are added as bodies + * demand them, so they keep the slots they hold and fill them in the + * order of their settled demand ranks. */ + classesInTableOrder(): ClassInfo[] { + const all = [...this.classes.values()]; + const slots = all.flatMap((info, index) => + this.genericClassDemandPriority.has(info) ? [index] : [], + ); + const ranked = slots + .map((index) => all[index]!) + .sort((left, right) => { + const a = this.genericClassDemandPriority.get(left)!.rank; + const b = this.genericClassDemandPriority.get(right)!.rank; + return a[0]! - b[0]! || a[1]! - b[1]!; + }); + slots.forEach((slot, index) => (all[slot] = ranked[index]!)); + return all; + } + + /** The globals in table order. A class's prototype data globals are + * added when a body first reads that prototype, so they keep the slots + * they hold and fill them in id order. */ + private globalsInTableOrder(): IrGlobal[] { + const all = [...this.globalsList]; + const prototypeData = (global: IrGlobal): boolean => + global.id.startsWith("%g.%prototype.data."); + const slots = all.flatMap((global, index) => (prototypeData(global) ? [index] : [])); + const sorted = slots + .map((index) => all[index]!) + .sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0)); + slots.forEach((slot, index) => (all[slot] = sorted[index]!)); + return all; + } + private settleGenericDemandPriorities(): void { const functionQueue: GenericInstance[] = []; const classQueue: ClassInfo[] = []; @@ -2656,6 +2737,19 @@ export class Lowerer { /** True while re-lowering a base function's 2nd+ instance: the same source * statements were already counted for the first instance. */ suppressStats = false; + /** Callback literals passed straight to an error-first zlib or crypto + * function: only that function's callback adapter calls them + * (lowerUndefinedResultBody). */ + readonly errorFirstLiterals = new Set(); + /** Parameters read as `any` in the error-path copy of an error-first + * callback. */ + readonly anySymbols = new Set(); + /** Error parameters of an error-first callback with a known value in + * the copy being lowered: true while it holds an Error, false while it + * holds null (staticCondition folds conditions on them). */ + readonly knownErrors = new Map(); + /** The number of error-path callback copies being lowered. */ + errorPathCopies = 0; /** Comparisons interned by operand representations and equality semantics. */ readonly equalityHelpers = new Map(); /** Synthetic array-HOF loop functions (map/filter/forEach desugar), @@ -2931,6 +3025,12 @@ export class Lowerer { * for the duration of that call, the receiver lowers to the Transform of * the value held in the named local. */ readonly hashStreamReceivers = new Map(); + /** fs call arguments evaluated ahead of a URL path's conversion + * (builtins/fs-paths.ts): each lowers to the hidden local that holds its + * value, which is null until the path's lowering has saved it. */ + readonly stagedArguments = new Map(); + /** The staged arguments that follow each such URL path, in order. */ + readonly stagedAfterPath = new Map(); readonly moduleNsParamOverrides = new Map< ts.ParameterDeclaration, IrType & { kind: "moduleNs" } @@ -3158,6 +3258,60 @@ export class Lowerer { constructedClassCount(): number { return this.constructedClasses?.size ?? -1; } + /** Helpers that every site asking for them shares (shareHelper). */ + private readonly sharedHelpers = new Set(); + private readonly sharedHelperNames = new Set(); + /** Marks a helper, or the lifted helpers of a name, as shared by every + * site that asks for it. A shared helper is built for whichever site asks + * first, so once lowering finishes its locations are replaced by one that + * names no site. */ + shareHelper(helper: IrFunction | string): void { + if (typeof helper === "string") this.sharedHelperNames.add(helper); + else this.sharedHelpers.add(helper); + } + /** Gives each shared helper the location of the entry file's start: the + * helpers marked by shareHelper, and those interned by key in the helper + * tables. */ + private settleSharedHelperLocations(): void { + const loc: SrcLoc = { file: this.entry.fileName, start: 0, end: 0 }; + const interned = new Set([...this.sharedHelperNames, ...this.classMethodValueHelpers]); + for (const table of [ + this.equalityHelpers, + this.arrHofHelpers, + this.unionCallHelpers, + this.valueHelpers, + this.assertHelpers, + this.inspectHelpers, + this.iterDrainHelpers, + this.mapHofHelpers, + this.setHofHelpers, + this.spHofHelpers, + this.primitiveCtorFns, + this.builtinCallableValueFns, + this.regionRunHelpers, + this.internTableHelpers, + this.coercions.retags, + this.coercions.narrows, + ]) + for (const name of table.values()) interned.add(name); + for (const fn of [...this.liftedFns, ...this.implicitFns]) + if (interned.has(fn.name)) this.sharedHelpers.add(fn); + const seen = new Set(); + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null || seen.has(node)) return; + seen.add(node); + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + const record = node as Record; + for (const key of Object.keys(record)) { + if (key === "loc") record[key] = loc; + else if (key !== "type") visit(record[key]); + } + }; + for (const fn of this.sharedHelpers) visit(fn); + } /** The first lifted function named `name`, the one * `liftedFns.find((fn) => fn.name === name)` returns. */ liftedFnNamed(name: string | undefined): IrFunction | undefined { @@ -4691,10 +4845,15 @@ export class Lowerer { // resolveValueSymbol also flushes deferred diagnostics and applies // merged-namespace fences, whose authority remains ordinary lowering. this.checker.prefetchSymbolRoots(sourceFiles, true); + // The facts each fixed-point site reads, by the symbols, nodes and + // element keys they belong to (created with the site list below). + let siteDependencies: SiteDependencies | null = null; const symbolOf = (node: ts.Node): ts.Symbol | null => { const s = this.checker.getSymbolAtLocation(node); if (!s) return null; - return s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s; + const symbol = s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s; + siteDependencies?.read(symbol); + return symbol; }; const signatureOf = (node: ts.Identifier): FnSig | null => { const symbol = symbolOf(node); @@ -4879,7 +5038,10 @@ export class Lowerer { const callableSymbols = new Map(); const callableSymbolOf = (node: ts.Expression): ts.Symbol | null => { const cached = callableSymbols.get(node); - if (cached !== undefined) return cached; + if (cached !== undefined) { + if (cached !== null) siteDependencies?.read(cached); + return cached; + } let symbol = symbolOf(ts.isPropertyAccessExpression(node) ? node.name : node); if (symbol === null || signatureBySymbol.has(symbol)) { callableSymbols.set(node, symbol); @@ -5168,7 +5330,10 @@ export class Lowerer { if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true; if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this)) return true; - if (ts.isCallExpression(e) && this.runtimeOptionalReduceTypes.has(e)) return true; + if (ts.isCallExpression(e)) { + siteDependencies?.read(e); + if (this.runtimeOptionalReduceTypes.has(e)) return true; + } if ( ts.isCallExpression(e) && e.arguments.nodes.length === 0 && @@ -5666,6 +5831,7 @@ export class Lowerer { if (fnType?.kind === "func" && bodyReturnsOptional(fn)) { const widenedReturn = this.runtimeOptionalType(fnType.ret); provenance?.note("callback-return", fn, provenanceSite); + siteDependencies?.read(fn); const previousReturn = this.runtimeOptionalFunctionReturnType(fn, fnType.ret); this.runtimeOptionalFunctionReturns.set(fn, widenedReturn); if (!typeEquals(previousReturn, widenedReturn)) changed = true; @@ -5901,8 +6067,10 @@ export class Lowerer { arm.elem.kind === "union" ? this.armTag(arm.elem.unionId, UNDEFINED_T) >= 0 : isUnitType(arm.elem) || arm.elem.kind === "void" - ) + ) { + if (!optionalElementKeys.has(key)) siteDependencies?.changed(key); optionalElementKeys.add(key); + } keys.push(key); } return keys; @@ -6014,7 +6182,10 @@ export class Lowerer { return keys; }; const elementStates = new ArrayElementStates({ - elementAdmitsUndefined: (key) => optionalElementKeys.has(key), + elementAdmitsUndefined: (key) => { + siteDependencies?.read(key); + return optionalElementKeys.has(key); + }, ownerOf: (node) => ownership.ownerOf(node), allocationOwner: (node) => ownership.allocationOwner(node), arrayKeys: arrayKeysOf, @@ -6087,6 +6258,8 @@ export class Lowerer { note: provenance ? (fact, key, site) => provenance.note(`element-${fact}`, site, site, key) : null, + read: (fact) => siteDependencies?.read(fact), + changed: (fact) => siteDependencies?.changed(fact), }); // Each pass revisits every array callback site; the last visit holds // the decision at the fixed point. @@ -6388,6 +6561,7 @@ export class Lowerer { ) { if (promoteHofCallback(callback, [0, 1])) changed = true; const result = this.runtimeOptionalType(this.irTypeOf(node)); + siteDependencies?.read(node); const previous = this.runtimeOptionalReduceTypes.get(node); if (!previous || !typeEquals(previous, result)) { this.runtimeOptionalReduceTypes.set(node, result); @@ -6455,6 +6629,7 @@ export class Lowerer { ) continue; for (const target of familyBySymbol.get(symbol) ?? [symbol]) { + siteDependencies?.read(target); const shape = signatureBySymbol.get(target)?.params[i]; if (!shape || shape.mode !== "required") continue; const widened = addUndefined(shape.type); @@ -6499,6 +6674,7 @@ export class Lowerer { if (!ts.isSpreadElement(arg) && shape && i !== restAt && fieldsOfValue(arg)) { const fields = fieldsOfValue(arg)!; for (const target of familyBySymbol.get(symbol) ?? [symbol]) { + siteDependencies?.read(target); const parameterShape = signatureBySymbol.get(target)?.params[i]; const parameter = functionDeclBySymbol.get(target)?.parameters?.nodes[i]; if (!parameterShape || parameterShape.type.kind !== "record") continue; @@ -6544,6 +6720,7 @@ export class Lowerer { // argument can make its elements undefined, not the pack. // Keep every virtual implementation on the same array ABI. for (const target of familyBySymbol.get(symbol) ?? [symbol]) { + siteDependencies?.read(target); const rest = signatureBySymbol.get(target)?.params[i]; if (rest?.mode !== "rest" || rest.type.kind !== "array") continue; const widened = arrayOf(addUndefined(rest.type.elem)); @@ -6565,6 +6742,7 @@ export class Lowerer { // downstream calls and returns see the same optional value. const family = familyBySymbol.get(symbol); for (const target of family ?? [symbol]) { + siteDependencies?.read(target); const parameterShape = signatureBySymbol.get(target)?.params[i]; if (!parameterShape) continue; parameterShape.type = addUndefined(parameterShape.type); @@ -6595,8 +6773,21 @@ export class Lowerer { }); } }; - for (const node of sitesOf(sf)) scan(node); - return changed; + // A site whose facts are unchanged since its last visit would compute + // that visit's result again, except in a full pass. + const base = siteBase.get(sf)!; + const dependencies = siteDependencies!; + const sites = sitesOf(sf); + let fileChanged = false; + for (let i = 0; i < sites.length; i++) { + if (!fullPass && !dependencies.isStale(base + i)) continue; + changed = false; + dependencies.begin(base + i); + scan(sites[i]!); + dependencies.end(changed); + if (changed) fileChanged = true; + } + return fileChanged; }; const scanReturns = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): boolean => { let changed = false; @@ -6646,23 +6837,41 @@ export class Lowerer { optionalSymbols.add(symbol); this.earlyReadVars.add(symbol); } - let changed = true; - while (changed) { + // Number the sites of every file. The first and the last pass visit + // every site; the passes between visit only the sites whose facts + // changed (SiteDependencies). When such a pass changes nothing, a full + // pass confirms the fixed point, and the iteration resumes if it + // changes a fact after all. + const siteBase = new Map(); + let siteCount = 0; + for (const sf of sourceFiles) { + siteBase.set(sf, siteCount); + siteCount += sitesOf(sf).length; + } + siteDependencies = new SiteDependencies(siteCount); + let fullPass = true; + for (let changed = true, first = true; changed || !fullPass; first = false) { + fullPass = first || !changed; changed = false; for (const [symbol, field] of staticFieldsBySymbol) { if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) { optionalSymbols.add(symbol); + siteDependencies.changed(symbol); provenance?.note("static-field", field.initializer.parent, field.initializer); changed = true; } } for (const sf of sourceFiles) if (scanFile(sf)) changed = true; - for (const [symbol, decl] of functionDeclBySymbol) - if (scanReturns(symbol, decl)) changed = true; + for (const [symbol, decl] of functionDeclBySymbol) { + if (!scanReturns(symbol, decl)) continue; + siteDependencies.changed(symbol); + changed = true; + } const noteGenericReturn = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): void => { if (optionalReturns.has(symbol) || isJsSourceFile(ts.getSourceFileOfNode(decl))) return; if (!returnsOf(decl).some(genericMayBeOptional)) return; optionalReturns.add(symbol); + siteDependencies!.changed(symbol); this.runtimeOptionalGenericReturns.add(decl); provenance?.note("return", decl.name ?? decl, decl); changed = true; @@ -6699,6 +6908,7 @@ export class Lowerer { : previous; if (!typeEquals(previous, next)) { this.runtimeOptionalFunctionReturns.set(fn, next); + siteDependencies.changed(fn); provenance?.note("lambda-return", fn, fn.body); changed = true; } @@ -6716,10 +6926,12 @@ export class Lowerer { if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) { if (!optionalReturns.has(symbol)) { optionalReturns.add(symbol); + siteDependencies.changed(symbol); changed = true; } } else if (symbol && !arithmeticReturns.has(symbol)) { arithmeticReturns.set(symbol, next); + siteDependencies.changed(symbol); changed = true; } } @@ -6736,6 +6948,7 @@ export class Lowerer { : this.runtimeOptionalType(previous); if (!typeEquals(previous, next)) { this.runtimeOptionalFunctionReturns.set(fn, next); + siteDependencies.changed(fn); changed = true; } } @@ -6749,6 +6962,7 @@ export class Lowerer { for (const symbol of optional ? family : []) { if (!optionalReturns.has(symbol)) { optionalReturns.add(symbol); + siteDependencies.changed(symbol); provenance?.note( "return", functionDeclBySymbol.get(symbol), @@ -6771,10 +6985,14 @@ export class Lowerer { const previous = arithmeticReturns.get(symbol); if (previous && typeEquals(previous, joined)) continue; arithmeticReturns.set(symbol, joined); + siteDependencies.changed(symbol); changed = true; } } } + // The site facts serve only the fixed point; release them for the rest + // of lowering. + siteDependencies = null; // NaN-coded slots convert optional values on entry instead of holding // the union, so promotion never applies to them. for (const symbol of [...optionalSymbols]) @@ -7186,6 +7404,7 @@ export class Lowerer { // Lambdas lifted while lowering any of the above (plus synthetic // array-HOF loop functions, which ride the same list), and the // implicit-any instances lowered eagerly at their first call sites. + this.settleSharedHelperLocations(); functions.push(...this.liftedFns); functions.push(...this.implicitFns); functions.push(...runtimeClassConstructs(this, functions)); @@ -7319,7 +7538,7 @@ export class Lowerer { classes: artifacts.classes, records: artifacts.records, unions: artifacts.unions, - globals: this.globalsList, + globals: this.globalsInTableOrder(), entry: ENTRY_NAME, ...(this.usesWorkers ? { workers: true } : {}), ...(this.usesLifetimeRegions ? { lifetimeRegions: true as const } : {}), @@ -7728,6 +7947,7 @@ export class Lowerer { this.shapes.withDeclaredOrderPriority(ref, () => this.lowerClassMembers(info)), ), ); + relieveHeapPressure(); } } while (instLowered < this.instantiationQueue.length) { @@ -7755,6 +7975,7 @@ export class Lowerer { ), ), ); + relieveHeapPressure(); } catch (e) { if (!(e instanceof PoisonError)) throw e; } @@ -7765,6 +7986,15 @@ export class Lowerer { // exactly like generic instances. while (specLowered < this.emitSpecQueue.length) { const waveEnd = this.emitSpecQueue.length; + // A wave's specializations lower by class and event, not in the + // order bodies asked for them. + const wave = this.emitSpecQueue.slice(specLowered, waveEnd); + const specKey = ({ info, event }: EmitSpecRequest): string => + `${info.def.name}\0${event}`; + wave.sort((left, right) => + specKey(left) < specKey(right) ? -1 : specKey(left) > specKey(right) ? 1 : 0, + ); + this.emitSpecQueue.splice(specLowered, wave.length, ...wave); this.checker.prefetchRoots( this.emitSpecQueue .slice(specLowered, waveEnd) @@ -7814,9 +8044,11 @@ export class Lowerer { // seed the worklist beside the init bodies. Unknown names are inert // (the export-map resolution reports them as SC4002 later). for (const root of extraRoots ?? []) this.onEdge?.(root); + const shuffle = lowerShuffle(); const drainUnits = (): void => { while (queue.length > 0) { const wave = queue.splice(0); + shuffle?.(wave); this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots)); for (const name of wave) { // A body-level poison outside the per-statement catches (a fenced @@ -7831,6 +8063,7 @@ export class Lowerer { this.shapes.withDeclaredOrderPriority(priority, unit.lower), ); if (fn) loweredUnits.set(name, fn); + relieveHeapPressure(); } catch (e) { if (!(e instanceof PoisonError)) throw e; } @@ -7845,21 +8078,28 @@ export class Lowerer { // queue above is the only portion whose discovery order differed from // historical emit order. const classDispatch = new ClassDynamicDispatch(extraRoots !== undefined); + // Units in declaration order, so the passes below meet functions, and the + // classes they collect, in an order that does not depend on lowering order. + const unitsInOrder = (): IrFunction[] => + [...loweredUnits] + .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) + .map(([, fn]) => fn); for (;;) { drainInstances(); + const declared = unitsInOrder(); const initializerChanged = this.deferredModuleInitializers.process(this, [ - ...loweredUnits.values(), + ...declared, ...initFunctions, ...instanceFunctions, - ...this.liftedFns, - ...this.implicitFns, + ...byName(this.liftedFns), + ...byName(this.implicitFns), ]); const dispatchChanged = classDispatch.process(this, [ - ...loweredUnits.values(), + ...declared, ...initFunctions, ...instanceFunctions, - ...this.liftedFns, - ...this.implicitFns, + ...byName(this.liftedFns), + ...byName(this.implicitFns), ]); if (queue.length === 0 && !dispatchChanged && !initializerChanged) break; drainUnits(); @@ -7867,9 +8107,7 @@ export class Lowerer { this.restoreGenericClassInstanceOrder(clsInstLowered); } classDispatch.finalize(this); - const orderedUnits = [...loweredUnits] - .sort(([left], [right]) => units.get(left)!.order - units.get(right)!.order) - .map(([, fn]) => fn); + const orderedUnits = unitsInOrder(); this.settleGenericDemandPriorities(); this.shapes.settleDeclaredOrderPriorities(); for (const finalize of this.shapeOrderMetadataFinalizers) finalize(); @@ -7882,13 +8120,16 @@ export class Lowerer { ]); observeRecordLiteralRebuilds(null); for (const finalize of this.shapeOrderHelperFinalizers) finalize(); + this.settleSharedHelperLocations(); + // Lifted and implicit functions are created in lowering order; they + // are assembled by name. const functions = [ ...orderedUnits, ...initFunctions, this.buildMain(), ...instanceFunctions, - ...this.liftedFns, - ...this.implicitFns, + ...byName(this.liftedFns), + ...byName(this.implicitFns), ]; functions.push(...runtimeClassConstructs(this, functions)); if (this.regexGroupResults) for (const fn of functions) foldRegexSpanReads(this, fn); @@ -8392,7 +8633,36 @@ export class Lowerer { return fenceStaticReadableStreamMember(this, access, use); } + /** Whether `node` reads an anySymbols parameter, or a member, element or + * call result of one: the error-path copy of a callback reads those as + * `any`. */ + private readsAnySymbol(node: ts.Node): boolean { + let base: ts.Node = node; + for (;;) { + if ( + ts.isPropertyAccessExpression(base) || + ts.isElementAccessExpression(base) || + ts.isNonNullExpression(base) || + ts.isParenthesizedExpression(base) + ) { + base = base.expression; + } else if (ts.isCallExpression(base) && ts.isPropertyAccessExpression(base.expression)) { + base = base.expression.expression; + } else { + break; + } + } + if (!ts.isIdentifier(base)) return false; + const parent = base.parent; + const symbol = + parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === base + ? this.checker.getShorthandAssignmentValueSymbol(parent) + : this.checker.getSymbolAtLocation(base); + return symbol !== undefined && this.anySymbols.has(symbol); + } + typeOf(node: ts.Node): ts.Type { + if (this.anySymbols.size > 0 && this.readsAnySymbol(node)) return this.checker.getAnyType(); if (ts.isPropertyAccessExpression(node) && isJsSourceFile(ts.getSourceFileOfNode(node))) { const receiver = node.expression.kind === ts.SyntaxKind.ThisKeyword && this.fnStack.length > 0 @@ -8688,8 +8958,9 @@ export class Lowerer { /** The ts-level twin of typeParamResolver: the bound CHECKER type of a * type parameter in the current instantiation (typeParamTsBindings), for * the resolutions where mapType's widening already dropped what the body - * needs (indexed accesses over literal-bound keys). Inert outside - * call-keyed generic instantiation. */ + * needs (indexed accesses over literal-bound keys, conditional types + * over literal arguments). Inert outside generic function and class + * instantiation. */ readonly typeParamTsResolver = (t: ts.Type): ts.Type | null => { if (!this.typeParamTsBindings || !(t.flags & ts.TypeFlags.TypeParameter)) return null; const sym: ts.Symbol | undefined = t.getSymbol(); @@ -9720,6 +9991,20 @@ export class Lowerer { * directly when the expression was already lowered. */ coerceInto(node: ts.Node, expr: IrExpr, expected: IrType): IrExpr { refuseInferredCallbackDrops(this, node, expr.type, expected); + // An assertion's scalar check whose value goes straight back into a + // checked-dynamic slot (`process.loadEnvFile({} as unknown as string)`) + // keeps the original value: JavaScript erases assertions, so the check + // could only add a TypeError Node does not throw, and the slot holds + // the value either way. + if (expected.kind === "dyn" && expr.kind === "dynCheck" && expr.value.type.kind === "dyn") { + let source = node; + while (ts.isParenthesizedExpression(source)) source = source.expression; + if ( + (ts.isAsExpression(source) || ts.isTypeAssertion(source)) && + (expr.type.kind === "string" || expr.type.kind === "f64" || expr.type.kind === "bool") + ) + return expr.value; + } // A union destination can accept the binding's tagged storage directly. // The checker's single-arm flow type can be stale after a callback writes // the binding. Extracting that arm and wrapping it again would discard the @@ -10047,6 +10332,8 @@ export class Lowerer { // retain their own checked conversion and must not be erased here. while (ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) node = node.expression; + const staged = this.stagedArgument(node); + if (staged) return expected ? this.coerceInto(node, staged, expected) : staged; if (expected) { if (ts.isConditionalExpression(node)) return lowerTernary(this, node, expected); const escaped = this.neverAssertionInto(node, expected); @@ -10882,6 +11169,7 @@ export class Lowerer { family: ClassInfo; name: string; bindings: Map; + tsBindings: Map; typeArgsText: string; ordinal: number; }, @@ -12079,7 +12367,19 @@ export class Lowerer { lowerExpr(expr: ts.Expression): IrExpr { const hash = this.hashStreamReceivers.get(expr); if (hash) return hashStreamOf(varRef(hash.localId, hash.type, locOf(expr))); - return lowerExpr(this, expr); + return this.stagedArgument(expr) ?? lowerExpr(this, expr); + } + + /** The saved value of a staged fs argument (stagedArguments, keyed + * without type-only wrappers), or null for every other expression. */ + private stagedArgument(node: ts.Expression): IrExpr | null { + if (this.stagedArguments.size === 0) return null; + node = stripTypeWrappers(node); + if (!this.stagedArguments.has(node)) return null; + const slot = this.stagedArguments.get(node); + if (!slot) + throw new InternalCompilerError("an fs argument lowered before the URL path it follows"); + return varRef(slot.localId, slot.type, locOf(node)); } lowerIntrinsicProperty(expr: ts.PropertyAccessExpression): IrExpr | null { diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts index 5ed011ce1..96d20d2c4 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-elements.ts @@ -39,8 +39,16 @@ export interface ElementStateHost { allocationOwner(node: ts.Expression): ts.Symbol | null; /** Debug provenance for a new fact. */ note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null; + /** The scan read, or added to, the facts of an element key, of an owning + * slot, or of every array (ANY_ARRAY), for dependency tracking + * (SiteDependencies). */ + read?(fact: string | ts.Symbol): void; + changed?(fact: string | ts.Symbol): void; } +/** The fact name of the facts shared by every array. */ +const ANY_ARRAY = "*"; + /** What an array value can contain besides present values. */ interface Contents { undefined: boolean; @@ -179,8 +187,10 @@ export class ArrayElementStates { if (built !== undefined) return built; } const owner = this.host.ownerOf(e); - if (owner !== null) + if (owner !== null) { + this.host.read?.(owner); return { undefined: this.undefinedOwners.has(owner), holes: this.holeyOwners.has(owner) }; + } return this.stored(this.host.arrayKeys(e)); } @@ -317,7 +327,9 @@ export class ArrayElementStates { /** The facts recorded for arrays with these element ABIs. */ private stored(keys: readonly string[] | null): Contents | null { if (this.ownFactsOnly) return NOTHING; + this.host.read?.(ANY_ARRAY); if (keys === null) return null; + for (const key of keys) this.host.read?.(key); return { undefined: this.anyUndefined || @@ -333,6 +345,7 @@ export class ArrayElementStates { const keys = this.host.arrayKeys(node); if (keys !== null && keys.length === 0) return false; const contents = this.contents(node); + this.host.read?.(ANY_ARRAY); return contents === null ? this.anyUndefined || this.anyHoley : contents.undefined || contents.holes; @@ -368,6 +381,7 @@ export class ArrayElementStates { this.holeyOwners.add(owner); changed = true; } + if (changed) this.host.changed?.(owner); return changed; } let changed = false; @@ -406,6 +420,7 @@ export class ArrayElementStates { if (owner === null) return this.addUndefined(this.host.arrayKeys(receiver)); if (this.undefinedOwners.has(owner)) return false; this.undefinedOwners.add(owner); + this.host.changed?.(owner); return true; } @@ -415,6 +430,7 @@ export class ArrayElementStates { if (owner === null) return this.addHoles(this.host.arrayKeys(receiver)); if (this.holeyOwners.has(owner)) return false; this.holeyOwners.add(owner); + this.host.changed?.(owner); return true; } @@ -422,6 +438,7 @@ export class ArrayElementStates { if (keys === null) { if (this.anyUndefined) return false; this.anyUndefined = true; + this.host.changed?.(ANY_ARRAY); this.host.note?.("undefined", "*", this.site); return true; } @@ -429,6 +446,7 @@ export class ArrayElementStates { for (const key of keys) { if (this.undefinedKeys.has(key)) continue; this.undefinedKeys.add(key); + this.host.changed?.(key); this.host.note?.("undefined", key, this.site); changed = true; } @@ -439,6 +457,7 @@ export class ArrayElementStates { if (keys === null) { if (this.anyHoley) return false; this.anyHoley = true; + this.host.changed?.(ANY_ARRAY); this.host.note?.("holes", "*", this.site); return true; } @@ -446,6 +465,7 @@ export class ArrayElementStates { for (const key of keys) { if (this.holeyKeys.has(key)) continue; this.holeyKeys.add(key); + this.host.changed?.(key); this.host.note?.("holes", key, this.site); changed = true; } diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.test.ts b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts new file mode 100644 index 000000000..ca5affcbe --- /dev/null +++ b/packages/compiler/src/frontend/lowering/site-dependencies.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "vitest"; +import { SiteDependencies } from "./site-dependencies.js"; + +test("a changed fact makes the sites that read it stale", () => { + const dependencies = new SiteDependencies(3); + const a = {}; + const b = "key"; + dependencies.begin(0); + dependencies.read(a); + dependencies.end(false); + dependencies.begin(1); + dependencies.read(b); + dependencies.end(false); + expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, false, false]); + dependencies.changed(b); + expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, true, false]); + dependencies.begin(1); + expect(dependencies.isStale(1)).toBe(false); +}); + +test("a visit that changes a fact makes every reader of its facts stale", () => { + const dependencies = new SiteDependencies(3); + const shared = {}; + const own = {}; + dependencies.begin(0); + dependencies.read(shared); + dependencies.end(false); + dependencies.begin(2); + dependencies.read(own); + dependencies.end(false); + dependencies.begin(1); + dependencies.read(shared); + dependencies.read(shared); + dependencies.end(true); + expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, false]); +}); + +test("reads outside a visit are not recorded", () => { + const dependencies = new SiteDependencies(1); + const fact = {}; + dependencies.read(fact); + dependencies.changed(fact); + expect(dependencies.isStale(0)).toBe(false); +}); + +test("a site visited again is listed once per fact", () => { + const dependencies = new SiteDependencies(3); + const fact = {}; + for (const site of [0, 2, 1, 2, 0]) { + dependencies.begin(site); + dependencies.read(fact); + dependencies.end(false); + } + dependencies.changed(fact); + expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, true]); +}); diff --git a/packages/compiler/src/frontend/lowering/site-dependencies.ts b/packages/compiler/src/frontend/lowering/site-dependencies.ts new file mode 100644 index 000000000..da8531e48 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/site-dependencies.ts @@ -0,0 +1,79 @@ +/** The facts each site of a fixed-point scan read on its last visit, so that + * a pass visits again only the sites whose facts changed since. + * + * Sites are numbered from 0. A fact is any value usable as a Map key: a + * symbol, a syntax node, or a string that names a derived fact. A visit + * reports what it reads (`read`). When a visit changes a fact, every site + * that read one of the facts this visit read is visited again: the facts a + * visit changes belong to what it read. Facts changed outside a visit, or + * not read before they change, are reported with `changed`. + * + * A site that is not visited again would compute what its last visit + * computed, as long as every fact it reads is reported. The scan confirms + * that with a pass over every site once a pass changes nothing, and resumes + * when that pass changes a fact: a fact this tracking misses costs passes, + * not a different result. */ +export class SiteDependencies { + /** Each fact's entry: the visit that last read it, so that a visit lists a + * fact once, followed by the sites that read it, ascending. */ + private readonly readers = new Map(); + private readonly stale: Uint8Array; + private site = -1; + private visit = 0; + private facts: unknown[] = []; + + constructor(siteCount: number) { + this.stale = new Uint8Array(siteCount); + } + + /** A fact that site `index` read changed since its last visit. */ + isStale(index: number): boolean { + return this.stale[index] === 1; + } + + /** Starts a visit of site `index`. */ + begin(index: number): void { + this.site = index; + this.visit++; + this.stale[index] = 0; + this.facts = []; + } + + /** Ends the visit; `changed` when it changed a fact. */ + end(changed: boolean): void { + this.site = -1; + if (changed) for (const fact of this.facts) this.changed(fact); + } + + /** The site being visited read `fact`. */ + read(fact: unknown): void { + if (this.site < 0) return; + const entry = this.readers.get(fact); + if (entry === undefined) { + this.readers.set(fact, [this.visit, this.site]); + this.facts.push(fact); + return; + } + if (entry[0] === this.visit) return; + entry[0] = this.visit; + this.facts.push(fact); + // A pass visits sites in ascending order, so a site is new at the end; + // a site visited again is usually listed already. + const site = this.site; + let low = 1; + let high = entry.length; + if (entry[high - 1]! < site) low = high; + while (low < high) { + const middle = (low + high) >>> 1; + if (entry[middle]! < site) low = middle + 1; + else high = middle; + } + if (entry[low] !== site) entry.splice(low, 0, site); + } + + /** `fact` changed: every site that read it is visited again. */ + changed(fact: unknown): void { + const entry = this.readers.get(fact); + if (entry !== undefined) for (let i = 1; i < entry.length; i++) this.stale[entry[i]!] = 1; + } +} diff --git a/packages/compiler/src/frontend/lowering/static-typeof.ts b/packages/compiler/src/frontend/lowering/static-typeof.ts index 54f2fdfa0..852cc3117 100644 --- a/packages/compiler/src/frontend/lowering/static-typeof.ts +++ b/packages/compiler/src/frontend/lowering/static-typeof.ts @@ -63,9 +63,44 @@ export function staticTypeofComparison(lowerer: Lowerer, e: ts.BinaryExpression) } } +/** Whether a callback's error parameter holds an Error (true) or null + * (false) in the copy of the callback body being lowered + * (lowerUndefinedResultBody), or null for other expressions. */ +function knownError(lowerer: Lowerer, e: ts.Expression): boolean | null { + while (ts.isParenthesizedExpression(e)) e = e.expression; + if (lowerer.knownErrors.size === 0 || !ts.isIdentifier(e)) return null; + const symbol = lowerer.checker.getSymbolAtLocation(e); + return symbol === undefined ? null : (lowerer.knownErrors.get(symbol) ?? null); +} + +/** A comparison of a known error parameter against `null` + * (`err !== null`, `err == null`), folded. Null for other shapes. */ +function knownErrorComparison(lowerer: Lowerer, e: ts.BinaryExpression): boolean | null { + const isNull = (x: ts.Expression): boolean => { + while (ts.isParenthesizedExpression(x)) x = x.expression; + return x.kind === ts.SyntaxKind.NullKeyword; + }; + const error = isNull(e.right) + ? knownError(lowerer, e.left) + : isNull(e.left) + ? knownError(lowerer, e.right) + : null; + if (error === null) return null; + switch (e.operatorToken.kind) { + case ts.SyntaxKind.EqualsEqualsEqualsToken: + case ts.SyntaxKind.EqualsEqualsToken: + return !error; + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsToken: + return error; + default: + return null; + } +} + /** The value of a condition built from statically answered typeof - * comparisons, `!`, `&&` and `||` (the global-detection chains), or null - * when any operand needs evaluation. */ + * comparisons, known callback error parameters, `!`, `&&` and `||` (the + * global-detection chains), or null when any operand needs evaluation. */ export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean | null { let e = expr; while (ts.isParenthesizedExpression(e)) e = e.expression; @@ -73,6 +108,7 @@ export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean const inner = staticCondition(lowerer, e.operand); return inner === null ? null : !inner; } + if (ts.isIdentifier(e)) return knownError(lowerer, e); if (!ts.isBinaryExpression(e)) return null; const op = e.operatorToken.kind; if (op === ts.SyntaxKind.AmpersandAmpersandToken || op === ts.SyntaxKind.BarBarToken) { @@ -82,7 +118,7 @@ export function staticCondition(lowerer: Lowerer, expr: ts.Expression): boolean if (left !== isAnd) return left; return staticCondition(lowerer, e.right); } - return staticTypeofComparison(lowerer, e); + return staticTypeofComparison(lowerer, e) ?? knownErrorComparison(lowerer, e); } /** The expression a value reduces to once statically decided conditions diff --git a/packages/compiler/src/frontend/lowering/var-hoisting.ts b/packages/compiler/src/frontend/lowering/var-hoisting.ts index 6276ab671..3f950f3ca 100644 --- a/packages/compiler/src/frontend/lowering/var-hoisting.ts +++ b/packages/compiler/src/frontend/lowering/var-hoisting.ts @@ -41,7 +41,10 @@ export function earlyReadVarSymbols( if (sf.isDeclarationFile) continue; const bindings = new Map(); const names = new Set(); + const functionNames = new Set(); ts.walkPreorder(sf, (node) => { + if (ts.isFunctionDeclaration(node) && node.name !== undefined) + functionNames.add(node.name.text); if (!ts.isVariableDeclarationList(node)) return undefined; if ((node.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const | ts.NodeFlags.Using)) !== 0) return undefined; @@ -66,7 +69,7 @@ export function earlyReadVarSymbols( }); if (bindings.size === 0) continue; const refs = referencesByName(lowerer, sf, names); - const analysis = new ScopeAnalysis(lowerer, sf); + const analysis = new ScopeAnalysis(lowerer, sf, functionNames); for (const [symbol, binding] of bindings) { const settle = analysis.settleIndex(binding.scope, symbol); const early = (refs.get(symbol) ?? []).some( @@ -151,6 +154,31 @@ function referencesByName( if (ts.isIdentifier(node) && names.has(node.text)) idents.push(node); return undefined; }); + return referencesBySymbol(lowerer, idents); +} + +/** The identifiers of `sf` named in `names`, by name, each list in source + * order. */ +function identifiersByName( + sf: ts.SourceFile, + names: ReadonlySet, +): Map { + const out = new Map(); + ts.walkPreorder(sf, (node) => { + if (!ts.isIdentifier(node) || !names.has(node.text)) return undefined; + const list = out.get(node.text); + if (list) list.push(node); + else out.set(node.text, [node]); + return undefined; + }); + return out; +} + +/** `idents` by resolved symbol, each list in the order of `idents`. */ +function referencesBySymbol( + lowerer: Lowerer, + idents: readonly ts.Identifier[], +): Map { lowerer.checker.prefetchSymbolNodesExact(idents); const out = new Map(); for (const id of idents) { @@ -173,10 +201,14 @@ class ScopeAnalysis { * its own body names is never called through that name). */ private readonly functionMemo = new Map(); private readonly functionRefs = new Map(); + /** The file's identifiers named like one of its function declarations, + * by name, collected in one walk on first use. */ + private functionNameIdents: Map | null = null; constructor( private readonly lowerer: Lowerer, private readonly sf: ts.SourceFile, + private readonly functionNames: ReadonlySet, ) { this.js = isJsSourceFile(sf); } @@ -263,12 +295,19 @@ class ScopeAnalysis { const refs = symbol === undefined ? [] - : (referencesByName(this.lowerer, this.sf, new Set([name.text])).get(symbol) ?? []).filter( - (ref) => ref !== name, - ); + : ( + referencesBySymbol(this.lowerer, this.identifiersNamed(name.text)).get(symbol) ?? [] + ).filter((ref) => ref !== name); this.functionRefs.set(fn, refs); return refs; } + + /** The identifiers named `text`, the name of a function declaration of + * the file. */ + private identifiersNamed(text: string): readonly ts.Identifier[] { + this.functionNameIdents ??= identifiersByName(this.sf, this.functionNames); + return this.functionNameIdents.get(text) ?? []; + } } /** A loop statement that settles the binding settles the references in diff --git a/packages/compiler/src/frontend/npm-static-umd.test.ts b/packages/compiler/src/frontend/npm-static-umd.test.ts new file mode 100644 index 000000000..14e0d5ea9 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-umd.test.ts @@ -0,0 +1,121 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "vitest"; +import { nodeFrontendServices } from "./services-node.js"; + +const dirs: string[] = []; +afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); +}); + +function unwrap(source: string): string | null { + return nodeFrontendServices().umdFactory(source, "/pkg/index.js"); +} + +/** TypeScript's UMD wrapper around `body`. */ +function umd(body: string, params = "require, exports"): string { + return `(function (factory) { + if (typeof module === "object" && typeof module.exports === "object") { + var v = factory(require, exports); + if (v !== undefined) module.exports = v; + } + else if (typeof define === "function" && define.amd) { + define(["require", "exports"], factory); + } +})(function (${params}) { +${body} +}); +`; +} + +const probe = ` +console.log(JSON.stringify(module.exports), Object.keys(module.exports).join(",")); +`; + +function run(file: string): string { + const result = spawnSync(process.execPath, [file], { encoding: "utf8" }); + expect(result.status).toBe(0); + return result.stdout; +} + +/** The unwrapped module exports what the original exports. */ +function expectSameModule(source: string): string { + const result = unwrap(source); + expect(typeof result).toBe("string"); + const dir = mkdtempSync(join(tmpdir(), "scriptc-umd-")); + dirs.push(dir); + const original = join(dir, "original.cjs"); + const unwrapped = join(dir, "unwrapped.cjs"); + writeFileSync(original, source + probe); + writeFileSync(unwrapped, result! + probe); + const answer = run(original); + expect(run(unwrapped)).toBe(answer); + return result!; +} + +test("the factory body runs at the top level with its positions kept", () => { + const source = umd(` "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.twice = twice; + const path = require("path"); + exports.sep = path.sep; + function twice(n) { + return n * 2; + }`); + const result = expectSameModule(source); + expect(result.length).toBe(source.length); + expect(result.split("\n").length).toBe(source.split("\n").length); + expect(result.indexOf("exports.twice = twice;")).toBe(source.indexOf("exports.twice = twice;")); + expect(result).not.toContain("define.amd"); + expect(result).not.toContain("factory"); +}); + +test("TypeScript's helper probes of this keep their fallback", () => { + expectSameModule( + umd(` "use strict"; + var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; + }; + Object.defineProperty(exports, "__esModule", { value: true }); + const os_1 = __importDefault(require("os")); + exports.eol = os_1.default.EOL;`), + ); +}); + +test("nested functions keep their own return, arguments and this", () => { + expectSameModule( + umd(` "use strict"; + exports.first = function () { return arguments[0]; }; + exports.self = { get() { return this; } }; + exports.arrow = () => { return 1; };`), + ); +}); + +test("other shapes keep the original text", () => { + // `export =` returns the module value from the factory. + expect(unwrap(umd(` "use strict";\n return { a: 1 };`))).toBeNull(); + // The body's own `this` and `arguments` differ from the module's. + expect(unwrap(umd(` exports.self = this;`))).toBeNull(); + expect(unwrap(umd(` exports.count = arguments.length;`))).toBeNull(); + // Other factory parameters. + expect(unwrap(umd(` exports.a = 1;`, "require, exports, module"))).toBeNull(); + expect(unwrap(umd(` exports.a = 1;`, "req, exp"))).toBeNull(); + // Code after the wrapper. + expect(unwrap(umd(` exports.a = 1;`) + "exports.b = 2;\n")).toBeNull(); + // Other statements in the wrapper, which unwrapping would drop. + const wrapped = umd(` exports.a = 1;`); + const call = " var v = factory(require, exports);\n"; + expect(unwrap(wrapped.replace(call, call + " exports.b = 2;\n"))).toBeNull(); + expect(unwrap(wrapped.replace(call, " exports.b = 2;\n" + call))).toBeNull(); + expect(unwrap(wrapped.replace(" }\n})(", " }\n exports.b = 2;\n})("))).toBeNull(); + expect( + unwrap(wrapped.replace("define.amd) {", "define.amd) {\n exports.b = 2;")), + ).toBeNull(); + expect( + unwrap(wrapped.replace("define(", "exports.b = 2;\n }\n else {\n define(")), + ).toBeNull(); + // Not a UMD wrapper. + expect(unwrap(`exports.a = 1;\n`)).toBeNull(); +}); diff --git a/packages/compiler/src/frontend/npm-static-umd.ts b/packages/compiler/src/frontend/npm-static-umd.ts new file mode 100644 index 000000000..4a5db5065 --- /dev/null +++ b/packages/compiler/src/frontend/npm-static-umd.ts @@ -0,0 +1,192 @@ +/* TypeScript's UMD module emit in a compiled package: the module body is a + * factory function that the wrapper calls with the CommonJS module's own + * `require` and `exports`. + * + * (function (factory) { + * if (typeof module === "object" && typeof module.exports === "object") { + * var v = factory(require, exports); + * if (v !== undefined) module.exports = v; + * } + * else if (typeof define === "function" && define.amd) { + * define(["require", "exports", "./dep"], factory); + * } + * })(function (require, exports) { + * "use strict"; + * const dep = require("./dep"); + * exports.f = f; + * }); + * + * Under Node the CommonJS branch runs: the factory body runs once with the + * module's `require` and `exports`, and a body without a `return` leaves + * `module.exports` as it was. That is the module's own body, so the wrapper + * is blanked out (line breaks kept, so positions stay) and the body runs at + * the module's top level, where its requires and export assignments are the + * module's own. + * + * Accepted: the wrapper is the file's only statement, the wrapper's body is + * exactly the emit above (so dropping it drops nothing else), the factory's + * parameters are exactly `require` and `exports`, and the body uses no + * `return` or `arguments` of its own. `this` in the body is the factory's + * receiver, not the module's: it may appear only in TypeScript's helper + * probes (`this && this.__importDefault`), which find no helper on either + * receiver. Any other shape keeps the original text. */ +import * as ts from "./ts7/syntax.js"; +import { tokenStartOfNode } from "./ts7/node-functions.js"; +import { applyEdits } from "./npm-static-bundled-chunks.js"; + +/** True when the text may hold a UMD wrapper (a cheap probe). */ +export function mayHoldUmdWrapper(source: string): boolean { + return source.includes("define.amd") && source.includes("(require, exports)"); +} + +/** The module text with a UMD wrapper's factory body at the top level, or + * null when the file is not one. */ +export function unwrapUmdFactory(sf: ts.SourceFile): string | null { + const stmts = sf.statements.nodes; + if (stmts.length !== 1) return null; + const stmt = stmts[0]!; + if (!ts.isExpressionStatement(stmt) || !ts.isCallExpression(stmt.expression)) return null; + const call = stmt.expression; + if (call.arguments.nodes.length !== 1) return null; + const wrapper = unparenthesized(call.expression); + const factory = call.arguments.nodes[0]!; + if (!ts.isFunctionExpression(wrapper) || !ts.isFunctionExpression(factory)) return null; + const wrapperParams = wrapper.parameters.nodes; + if (wrapperParams.length !== 1 || !isPlainParameter(wrapperParams[0]!)) return null; + const factoryName = (wrapperParams[0]!.name as ts.Identifier).text; + if (!isTypeScriptUmdWrapper(wrapper, factoryName)) return null; + const params = factory.parameters.nodes; + if ( + params.length !== 2 || + !params.every(isPlainParameter) || + (params[0]!.name as ts.Identifier).text !== "require" || + (params[1]!.name as ts.Identifier).text !== "exports" + ) + return null; + const body = factory.body; + if (body === undefined || !ownBodyKeepsModuleSemantics(body)) return null; + const open = tokenStartOfNode(body) + 1; + const close = body.end - 1; + return applyEdits(sf.text, 0, sf.text.length, [ + { start: tokenStartOfNode(stmt), end: open, text: "" }, + { start: close, end: stmt.end, text: "" }, + ]); +} + +function unparenthesized(e: ts.Expression): ts.Expression { + while (ts.isParenthesizedExpression(e)) e = e.expression; + return e; +} + +/** A parameter that is a bare identifier: no default, rest or pattern. */ +function isPlainParameter(p: ts.ParameterDeclaration): boolean { + return ts.isIdentifier(p.name) && p.initializer === undefined && p.dotDotDotToken === undefined; +} + +/** True when the wrapper body is exactly TypeScript's emit and nothing else: + * one `if` statement whose CommonJS branch holds only the factory call and + * the `module.exports` assignment, and whose `else`, when present, is only + * the AMD branch: + * + * if (typeof module === "object" && typeof module.exports === "object") { + * var v = factory(require, exports); + * if (v !== undefined) module.exports = v; + * } + * else if (typeof define === "function" && define.amd) { + * define([...], factory); + * } + * + * Unwrapping drops the wrapper, so any other statement in it would be lost. */ +function isTypeScriptUmdWrapper(wrapper: ts.FunctionExpression, factoryName: string): boolean { + const stmts = wrapper.body?.statements.nodes ?? []; + if (stmts.length !== 1 || !ts.isIfStatement(stmts[0]!)) return false; + const test = stmts[0]; + const sf = ts.getSourceFileOfNode(wrapper); + const text = (n: ts.Node): string => + sf.text.slice(tokenStartOfNode(n), n.end).replace(/\s+/g, " "); + if (text(test.expression) !== 'typeof module === "object" && typeof module.exports === "object"') + return false; + const branch = test.thenStatement; + if (!ts.isBlock(branch) || branch.statements.nodes.length !== 2) return false; + const [call, assign] = branch.statements.nodes as [ts.Statement, ts.Statement]; + if (!ts.isVariableStatement(call)) return false; + const declarations = call.declarationList.declarations.nodes; + const name = declarations.length === 1 ? declarations[0]!.name : undefined; + if (name === undefined || !ts.isIdentifier(name)) return false; + const result = name.text; + if (text(call) !== `var ${result} = ${factoryName}(require, exports);`) return false; + if (text(assign) !== `if (${result} !== undefined) module.exports = ${result};`) return false; + const amd = test.elseStatement; + if (amd === undefined) return true; + if (!ts.isIfStatement(amd) || amd.elseStatement !== undefined) return false; + if (text(amd.expression) !== 'typeof define === "function" && define.amd') return false; + const define = ts.isBlock(amd.thenStatement) ? amd.thenStatement.statements.nodes : []; + if (define.length !== 1 || !ts.isExpressionStatement(define[0]!)) return false; + const defineCall = define[0].expression; + if (!ts.isCallExpression(defineCall) || text(defineCall.expression) !== "define") return false; + const last = defineCall.arguments.nodes.at(-1); + return last !== undefined && ts.isIdentifier(last) && last.text === factoryName; +} + +/** True when the factory body behaves the same as the module's top level: + * no `return` or `arguments` of its own, and `this` only in TypeScript's + * helper probes. */ +function ownBodyKeepsModuleSemantics(body: ts.Block): boolean { + let keeps = true; + ts.walkPreorder(body, (n) => { + if (!keeps) return "stop"; + // Nested functions have their own `return`, `arguments` and `this`; + // arrow functions only their own `return`. + if ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) + ) + return "skip"; + if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return "skip"; + if (ts.isReturnStatement(n) && !insideArrow(n, body)) keeps = false; + else if (ts.isIdentifier(n) && n.text === "arguments") keeps = false; + else if (n.kind === ts.SyntaxKind.ThisKeyword && !isHelperProbe(n)) keeps = false; + return undefined; + }); + return keeps; +} + +/** True when `node` sits inside an arrow function nested in `body`. */ +function insideArrow(node: ts.Node, body: ts.Block): boolean { + for (let p = node.parent; p !== undefined && p !== body; p = p.parent) + if (ts.isArrowFunction(p)) return true; + return false; +} + +/** `this` in TypeScript's helper probe `this && this.__name`: the left + * operand, or the receiver of the right operand's `__` member. */ +function isHelperProbe(node: ts.Node): boolean { + const parent = node.parent; + if (parent === undefined) return false; + const probe = + ts.isBinaryExpression(parent) && parent.left === node + ? parent + : ts.isPropertyAccessExpression(parent) && + parent.expression === node && + ts.isIdentifier(parent.name) && + parent.name.text.startsWith("__") && + parent.parent !== undefined && + ts.isBinaryExpression(parent.parent) && + parent.parent.right === parent + ? parent.parent + : undefined; + if (probe === undefined || probe.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) + return false; + const right = probe.right; + return ( + probe.left.kind === ts.SyntaxKind.ThisKeyword && + ts.isPropertyAccessExpression(right) && + right.expression.kind === ts.SyntaxKind.ThisKeyword && + ts.isIdentifier(right.name) && + right.name.text.startsWith("__") + ); +} diff --git a/packages/compiler/src/frontend/npm-static.ts b/packages/compiler/src/frontend/npm-static.ts index bc9fc2abd..56edafc48 100644 --- a/packages/compiler/src/frontend/npm-static.ts +++ b/packages/compiler/src/frontend/npm-static.ts @@ -452,6 +452,10 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow } else if (esm !== null) { original = esm; } + // TypeScript's UMD wrapper calls its factory with the module's own + // require and exports: the factory body is the module body. + const umd = original === null ? null : services.umdFactory(original, path); + if (umd !== null) original = umd; // Polyfill packages that ARE a Node builtin compile as the builtin. const aliased = original === null ? null : services.builtinAliasRequires(original, path); if (aliased !== null) original = aliased; @@ -472,6 +476,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow if (exportsVar !== null) original = exportsVar; const respelled = split !== null || + umd !== null || tscHelpers !== null || esm !== null || aliased !== null || diff --git a/packages/compiler/src/frontend/program.test.ts b/packages/compiler/src/frontend/program.test.ts index c1bd183f2..bdd6260ed 100644 --- a/packages/compiler/src/frontend/program.test.ts +++ b/packages/compiler/src/frontend/program.test.ts @@ -351,7 +351,7 @@ test("repeated cycle edges reuse a file's binding index", () => { [peer, peerImports.map((stmt) => ({ dep: main, stmt }))], ]); const admit = makeCycleAdmission(load.program, (file) => edges.get(file) ?? []); - const walk = vi.spyOn(peer, "appendChildIndices"); + const walk = vi.spyOn(peer.file.wire, "preorder"); try { expect(admit(peer, edges.get(peer)![0]!)).toBeNull(); expect(walk).toHaveBeenCalled(); diff --git a/packages/compiler/src/frontend/services.ts b/packages/compiler/src/frontend/services.ts index 10931ab29..19728d6ce 100644 --- a/packages/compiler/src/frontend/services.ts +++ b/packages/compiler/src/frontend/services.ts @@ -12,6 +12,7 @@ import { mayRequireBuiltinAlias, rewriteBuiltinAliasRequires, } from "./npm-static-builtin-aliases.js"; +import { mayHoldUmdWrapper, unwrapUmdFactory } from "./npm-static-umd.js"; import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js"; import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js"; import { builtinPackageAliasFor } from "./package-shims.js"; @@ -114,6 +115,13 @@ export class FrontendServices { if (!mayHoldTscBuiltinImport(source)) return null; return unwrapTscBuiltinImports(this.parse(path, source, "js")); } + /** A package module written as TypeScript's UMD wrapper with its + * factory body at the top level (null: not one). */ + umdFactory(source: string, path: string): string | null { + this.ensureOpen(); + if (!mayHoldUmdWrapper(source)) return null; + return unwrapUmdFactory(this.parse(path, source, "js")); + } /** A package module with its literal-valued TypeScript enum IIFEs * respelled as object literals (null: none). */ enumIifes(source: string, path: string): string | null { diff --git a/packages/compiler/src/frontend/ts7/ast-node.test.ts b/packages/compiler/src/frontend/ts7/ast-node.test.ts index 60bfaa005..6b2725b93 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.test.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.test.ts @@ -511,6 +511,40 @@ test("preorder traversal preserves depth, skipped subtrees and early termination expect(prefix).toEqual(expected.slice(0, 7)); }); +test("preorder walks from every node match a recursive visit of every file", () => { + const kinds = new Set([AstKind.Identifier, AstKind.JSDocTypeLiteral]); + for (const [name, { file, oracle }] of decoded) { + expect(file.wire.preorder(), name).not.toBeNull(); + for (let index = 1; index < file.wire.nodeCount; index++) { + if (file.wire.kind(index) === KIND_NODE_LIST) continue; + const expected: [number, number][] = []; + const selected: [number, number][] = []; + const visit = (node: OracleNode, depth: number): void => { + expected.push([node.index, depth]); + if (kinds.has(node.kind)) selected.push([node.index, depth]); + node.forEachChild((child) => { + visit(child as OracleNode, depth + 1); + }); + }; + visit(oracle.getOrCreateNodeAtIndex(index), 0); + const actual: [number, number][] = []; + walkPreorder(file.node(index), (node, depth) => { + actual.push([node.index, depth]); + }); + expect(actual, `${name}:${index}`).toEqual(expected); + const actualSelected: [number, number][] = []; + walkPreorder( + file.node(index), + (node, depth) => { + actualSelected.push([node.index, depth]); + }, + kinds, + ); + expect(actualSelected, `${name}:${index}`).toEqual(selected); + } + } +}); + test("child traversal still rejects a sibling belonging to another parent", () => { const { file, bytes } = decoded.get("main.ts")!; let parent = 0; diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index f337bd623..be2745616 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -9,6 +9,7 @@ import { import { astLineOfPosition, astLineStarts, astSkipTrivia } from "./ast-text.js"; import { AstWireFile, + PREORDER_LIST, parseAstNodeHandle, type AstFileReference, type AstNodeHandle, @@ -359,6 +360,32 @@ export class AstNode { forEachChild( visitNode: (node: AstNode) => T, visitList?: (nodes: AstNodeList) => T, + ): T | undefined { + const wire = this.file.wire; + const tree = wire.preorder(); + if (tree === null) return this.forEachLinkedChild(visitNode, visitList); + // In the checked preorder tree, the first child follows its parent and + // each later child starts where the previous child's subtree ends. + const kinds = tree.kinds; + const ends = tree.ends; + const stop = ends[this.index]!; + for (let index = this.index + 1; index < stop; index = ends[index]!) { + const kind = kinds[index]!; + if (kind === PREORDER_LIST) { + const result = this.visitChildList(index, visitNode, visitList); + if (result) return result; + } else if (kind !== AstKind.JSDoc) { + const result = visitNode(this.file.node(index)); + if (result) return result; + } + } + return undefined; + } + + /** forEachChild through the sibling and parent links. */ + private forEachLinkedChild( + visitNode: (node: AstNode) => T, + visitList?: (nodes: AstNodeList) => T, ): T | undefined { const wire = this.file.wire; // Walk the immutable sibling links directly. Repeated semantic scans @@ -368,16 +395,8 @@ export class AstNode { throw new AstDecodeError("sibling belongs to another parent"); const kind = wire.kind(index); if (kind === KIND_NODE_LIST) { - const list = this.file.list(index); - if (visitList !== undefined) { - const result = visitList(list); - if (result) return result; - } else { - for (const node of list.nodes) { - const result = visitNode(node); - if (result) return result; - } - } + const result = this.visitChildList(index, visitNode, visitList); + if (result) return result; } else if (kind !== AstKind.JSDoc) { const result = visitNode(this.file.node(index)); if (result) return result; @@ -386,6 +405,20 @@ export class AstNode { return undefined; } + private visitChildList( + index: number, + visitNode: (node: AstNode) => T, + visitList?: (nodes: AstNodeList) => T, + ): T | undefined { + const list = this.file.list(index); + if (visitList !== undefined) return visitList(list); + for (const node of list.nodes) { + const result = visitNode(node); + if (result) return result; + } + return undefined; + } + get jsDoc(): AstNode[] | undefined { const docs: AstNode[] = []; for (const index of this.file.wire.children(this.index)) { diff --git a/packages/compiler/src/frontend/ts7/ast-wire.ts b/packages/compiler/src/frontend/ts7/ast-wire.ts index a2466a743..ff70148df 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire.ts @@ -32,6 +32,16 @@ import { astChildOrder, } from "./ast-schema.generated.js"; +/** A node table read as a preorder tree (AstWireFile.preorder): each + * node's kind, with PREORDER_LIST for lists, and the end of its subtree. */ +export interface AstPreorder { + readonly kinds: Uint16Array; + readonly ends: Uint32Array; +} + +/** The kind AstPreorder records for a list (KIND_NODE_LIST on the wire). */ +export const PREORDER_LIST = 0xffff; + export interface AstFileReference { pos: number; end: number; @@ -125,6 +135,7 @@ export class AstWireFile { private readonly words: Uint32Array; /** Decoded strings by string word index / 2. */ private readonly stringCache: (string | undefined)[]; + private preorderTree: AstPreorder | null | undefined; constructor(private readonly bytes: Uint8Array) { astBounds(bytes, 0, HEADER_SIZE); @@ -317,6 +328,66 @@ export class AstWireFile { return children; } + /** The node table as a preorder tree, built on first use: every node's + * subtree is the index range up to `ends[index]`, and `kinds[index]` is its + * kind. The pass checks the links the linked walk checks one child at a + * time: each node's parent is open, the first child follows its parent, + * each sibling link names the end of the previous sibling's subtree, a + * last child has no sibling link, and each list holds `data` nodes and no + * lists. null when any check fails; walks then follow the links and fail + * where they read the inconsistent node. */ + preorder(): AstPreorder | null { + if (this.preorderTree === undefined) this.preorderTree = this.buildPreorder(); + return this.preorderTree; + } + + private buildPreorder(): AstPreorder | null { + const count = this.nodeCount; + const words = this.words; + const kinds = new Uint16Array(count); + const ends = new Uint32Array(count); + // The nodes whose subtrees contain the current index, innermost last, + // with the number of children each has so far. + const open: number[] = [1]; + const children: number[] = [0]; + const rootKind = words[NODE_WORDS + WORD_KIND]!; + if (rootKind >= PREORDER_LIST) return null; + kinds[1] = rootKind; + for (let index = 2; index <= count; index++) { + const word = index * NODE_WORDS; + const parent = index < count ? words[word + WORD_PARENT]! : 0; + let previous = 0; + while (open.length > 0 && open[open.length - 1] !== parent) { + const closed = open.pop()!; + const closedChildren = children.pop()!; + ends[closed] = index; + const closedWord = closed * NODE_WORDS; + if (kinds[closed] === PREORDER_LIST && closedChildren !== words[closedWord + WORD_DATA]!) + return null; + if (open.length > 0 && open[open.length - 1] === parent) previous = closed; + else if (closed !== 1 && words[closedWord + WORD_NEXT]! !== 0) return null; + } + if (index === count) break; + if (open.length === 0 || parent >= index) return null; + if ( + previous !== 0 ? words[previous * NODE_WORDS + WORD_NEXT]! !== index : index !== parent + 1 + ) + return null; + const kind = words[word + WORD_KIND]!; + if (kind === KIND_NODE_LIST) { + if (kinds[parent] === PREORDER_LIST) return null; + kinds[index] = PREORDER_LIST; + } else { + if (kind >= PREORDER_LIST) return null; + kinds[index] = kind; + } + children[children.length - 1] = children[children.length - 1]! + 1; + open.push(index); + children.push(0); + } + return { kinds, ends }; + } + /** The semantic walk flattens lists and omits attached JSDoc trees. * Validate the same list links as list(), without materializing nodes * that a selective syntax scan will never inspect. */ diff --git a/packages/compiler/src/frontend/ts7/ast.ts b/packages/compiler/src/frontend/ts7/ast.ts index a92cfafd8..369213d89 100644 --- a/packages/compiler/src/frontend/ts7/ast.ts +++ b/packages/compiler/src/frontend/ts7/ast.ts @@ -47,6 +47,8 @@ import { } from "./ast-guards.generated.js"; import type { Ts7DiagnosticData as Diagnostic } from "./session-schema.generated.js"; import { ModifierFlags, SyntaxKind } from "./enums.js"; +import { AstKind } from "./ast-schema.generated.js"; +import { PREORDER_LIST } from "./ast-wire.js"; /* ---- everything 7 kept under the same name ---- */ @@ -101,7 +103,7 @@ export function forEachChild( return node.forEachChild(cbNode, cbNodes); } -/** Preorder walk over a whole subtree with an EXPLICIT stack. The idiomatic +/** Preorder walk over a whole subtree without recursion. The idiomatic * recursive forEachChild visit costs two JS frames per AST level, so a * pathologically deep tree — the ~6500-term left-nested binary chain of the * binderBinaryExpressionStress corpus pair — overflows the stack as an ICE @@ -110,11 +112,64 @@ export function forEachChild( * detection) walk this way instead, so absurd nesting always reaches the * named fence. The callback may answer "skip" to leave a node's children * unvisited (scope and depth cut-offs — `depth` counts levels below the - * root) or "stop" to end the walk early. */ + * root) or "stop" to end the walk early. + * + * The wire stores nodes in preorder, so the walk visits the subtree's index + * range in order: it enters lists without a level of their own, and passes + * over attached JSDoc trees and skipped subtrees by their end index. A table + * that is not a checked preorder tree (AstWireFile.preorder) is walked + * through its links instead. */ export function walkPreorder( root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop", kinds?: ReadonlySet, +): void { + const file = root.file; + const tree = file.wire.preorder(); + if (tree === null) { + walkLinkedPreorder(root, cb, kinds); + return; + } + const nodeKinds = tree.kinds; + const ends = tree.ends; + const start = root.index; + const stop = ends[start]!; + // The subtree ends of the visited nodes that contain the current index. + const open: number[] = []; + let index = start; + while (index < stop) { + const kind = nodeKinds[index]!; + if (kind === PREORDER_LIST) { + index++; + continue; + } + if ( + kind === AstKind.JSDoc && + index !== start && + nodeKinds[file.wire.parent(index)] !== PREORDER_LIST + ) { + index = ends[index]!; + continue; + } + while (open.length > 0 && open[open.length - 1]! <= index) open.pop(); + const node = kinds === undefined || kinds.has(kind) ? file.node(index) : undefined; + const verdict = node === undefined ? undefined : cb(node, open.length); + if (verdict === "stop") return; + if (verdict === "skip") { + index = ends[index]!; + continue; + } + open.push(ends[index]!); + index++; + } +} + +/** walkPreorder through the sibling and parent links, with an explicit + * stack. */ +function walkLinkedPreorder( + root: Node, + cb: (node: Node, depth: number) => void | "skip" | "stop", + kinds?: ReadonlySet, ): void { const file = root.file; const stack: number[] = [root.index]; diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index ca838cea2..a233f0186 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -46,6 +46,7 @@ import { literalValues } from "./literal-values.js"; import { remapUnionDiscriminant } from "./union-discriminants.js"; import { isJsSourceFile, isNodeTypesPath } from "./program.js"; +import { isProvenanceSourceFile } from "./provenance-registry.js"; import { functionCanReturnUndefined } from "./function-completion.js"; import { accessorSlotProp, recordTextCodecClass } from "../ir/ir.js"; // typeKey moved to ir/ir.ts (the backend needs it too, for per-type @@ -1108,6 +1109,11 @@ export type TypeParamTsResolver = (t: ts.Type) => ts.Type | null; * once per Lowerer and threaded through the recursion — adding a hook means * one new field here, not a new parameter at every call site. */ export interface TypeMapperCtx { + /** Set only while a union maps an arm that is the type inferred from an + * empty object literal, beside another non-nullish arm: that arm holds + * only the literal's objects, so it maps to the empty record (see the + * empty object type rule in the object mapping). */ + emptyLiteralArm?: boolean; checker: ts.TypeChecker; /** JavaScript declarations whose instance or object-literal types only * the checked-dynamic representation holds (js-dynamic-objects.ts). @@ -1650,7 +1656,10 @@ export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const topLevel = mapTypeDepth === 0; const typeId = (type as { id?: number }).id; const memoizable = typeId !== undefined && (ctx.canMemoizeType?.() ?? true); - const memoKey = memoizable ? `${0}:${typeId}` : null; + // An empty literal's type maps to the empty record as an unreduced union's + // arm (emptyLiteralArm) and to the checked-dynamic value elsewhere: the two + // answers keep separate memo entries. + const memoKey = memoizable ? `${ctx.emptyLiteralArm ? 1 : 0}:${typeId}` : null; if (memoKey !== null) { const memo = ctx.typeMemo?.get(memoKey); if (memo !== undefined) return memo; @@ -2459,6 +2468,27 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return { kind: "classval", className: inst.className }; } } + // A constructable declared in the program whose instances are not one + // program class's (`new () => unknown`, `abstract new () => S` over a + // record, a type that also names static members such as `{ readonly + // schema: unknown; new (): unknown }`, construct overloads): the class + // value rides the checked-dynamic tree, as an `unknown` slot already + // holds one. `new` through it and static reads dispatch at run time, + // and the constructed value checks into the signature's instance type. + // Lib and package constructables keep their fences, and so do those of + // fetched provenance sources (third-party code under its own policies: + // an unused `@__PURE__` const typed `{ new (): any }` elides there). + if ( + ctorSigs.length > 0 && + checker.getCallSignatures(widened).length === 0 && + (widened.getSymbol()?.declarations.length ?? 0) > 0 && + widened.getSymbol()!.declarations.every((d) => { + const sf = ts.getSourceFileOfNode(d); + return ctx.isProgramFile(sf) && !isProvenanceSourceFile(sf.fileName); + }) + ) { + return DYN; + } } // Map: a reference to the standard library's Map interface — // provenance, not the name (a user's own `interface Map` maps as a record @@ -4648,6 +4678,18 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { ); }; const hasArrayPart = parts.some((part) => checker.isArrayType(part) || matchArray(part)); + // Subtype reduction removes every arm assignable to `{}`, so a union + // that keeps another non-nullish arm beside an empty literal's `{}` + // was not reduced: that `{}` arm holds only the literal's objects. + const nullish = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void; + const literalEmptyArms = + parts.some((part) => isEmptyObjectLiteralType(part, ctx)) && + parts.some( + (part) => + (part.flags & nullish) === 0 && + !checker.isNeverType(part) && + !isEmptyObjectLiteralType(part, ctx), + ); for (const part of parts) { if (hasArrayPart && emptyTuple(part)) continue; // TypeScript's client can retain impossible intersections in a @@ -4666,7 +4708,17 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { byKey.set(typeKey(NULL_T), NULL_T); continue; } - const mapped = mapType(part, ctx); + let mapped: IrType | null; + if (literalEmptyArms && isEmptyObjectLiteralType(part, ctx)) { + ctx.emptyLiteralArm = true; + try { + mapped = mapType(part, ctx); + } finally { + ctx.emptyLiteralArm = false; + } + } else { + mapped = mapType(part, ctx); + } if (!mapped) { return null; @@ -5836,7 +5888,9 @@ function evalMappedTemplate( if (!checkType || !extendsType) return null; const checked = evalMappedTemplate(checkType, keys, scope, ctx); if (!checked) return null; - const inferred = matchInferReference(checked, extendsType, ctx); + const inferred = + primitiveExtendsCheck(checked, extendsType, ctx) ?? + matchInferReference(checked, extendsType, ctx); if (inferred === undefined) return null; const branch = inferred ? type.getTrueType() : type.getFalseType(); const branchScope = inferred ? new Map([...scope, ...inferred]) : scope; @@ -5845,6 +5899,30 @@ function evalMappedTemplate( return mentionsMappedParams(type, keys, aliases) ? null : type; } +/** `checked extends X` where X is a primitive or literal type, or a union of + * them (`K extends "required" ? ...`), and `checked` is one such type: + * X has no infer positions, so assignability decides the branch (an empty + * binding map for the true branch). A union `checked` distributes and stays + * undecided here (undefined), as do other shapes. */ +function primitiveExtendsCheck( + checked: ts.Type, + extendsType: ts.Type, + ctx: TypeMapperCtx, +): Map | false | undefined { + const primitive = + ts.TypeFlags.StringLike | + ts.TypeFlags.NumberLike | + ts.TypeFlags.BooleanLike | + ts.TypeFlags.BigIntLike | + ts.TypeFlags.Null | + ts.TypeFlags.Undefined; + const isPrimitive = (t: ts.Type): boolean => + !t.isUnionType() && (t.flags & primitive) !== 0 && (t.flags & ts.TypeFlags.TypeParameter) === 0; + const extendsParts = extendsType.isUnionType() ? ts.constituentTypes(extendsType) : [extendsType]; + if (!isPrimitive(checked) || !extendsParts.every(isPrimitive)) return undefined; + return ctx.checker.isTypeAssignableTo(checked, extendsType) ? new Map() : false; +} + /** `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 @@ -7470,25 +7548,29 @@ function mapRecordTypeInner( // An INDEX-SIGNATURE shape is exempt: `Record` legitimately // has zero declared members — the signature is the shape. if (computed && props.length === 0 && !indexValue) return null; - // A DECLARED empty object type — `{}` (spelled or the checker's shared - // intrinsic), `interface Empty {}` — is tsc's TOP type over non-nullish - // values: every number, string, record, array, function, or class - // instance is assignable into the slot, so the exact-record stance - // cannot hold it. It lowers like `unknown` (the dyn), the same rule - // as `object` and the lib's `Object`: assignments convert at the site - // (sources outside the checked-dynamic tree fence there, exactly as unknown does), reads - // narrow back out through the same checked casts. The type INFERRED - // from an empty object literal (`const o = {}`) stays the empty record - // — it describes a value the program built, not an annotation that - // admits everything — and computed empties (`Partial` in a generic - // body) returned null above. Empty shapes WITH an index signature - // (`Record`) are not empty: the signature is the shape. + // An empty object type — `{}` (spelled, the checker's shared + // intrinsic, or inferred from an empty object literal), `interface + // Empty {}` — is tsc's TOP type over non-nullish values: every number, + // string, record, array, function, or class instance is assignable + // into the slot, so the exact-record stance cannot hold it. It lowers + // like `unknown` (the dyn), the same rule as `object` and the lib's + // `Object`: assignments convert at the site (sources outside the + // checked-dynamic tree fence there, exactly as unknown does), reads + // narrow back out through the same checked casts. A literal's inferred + // `{}` is no exception: union subtype reduction folds primitives into + // it (`["12", {}]` is `{}[]`, `flag ? "x" : {}` is `{}`), and `let o = + // {}` admits `o = "x"`. As an arm beside another non-nullish arm (the + // union was not reduced, `string | {} | DataView`) it holds only the + // literal's objects and stays the empty record (emptyLiteralArm). + // Computed empties (`Partial` in a generic body) returned null + // above. Empty shapes WITH an index signature (`Record`) are + // not empty: the signature is the shape. if ( props.length === 0 && indexValue === undefined && checker.getCallSignatures(widened).length === 0 && checker.getConstructSignatures(widened).length === 0 && - !(anonSym !== undefined && anonSym.declarations.some((d) => ts.isObjectLiteralExpression(d))) + !(ctx.emptyLiteralArm && isEmptyObjectLiteralType(widened, ctx)) ) { return DYN; } @@ -8128,3 +8210,19 @@ function innerBlockedMember( } return here; } + +/** The type tsc infers from an empty object literal (`{}` written as a + * value): no members, index signatures or signatures, declared by an object + * literal expression. */ +function isEmptyObjectLiteralType(type: ts.Type, ctx: TypeMapperCtx): boolean { + const { checker } = ctx; + const symbol = type.getSymbol(); + return ( + symbol !== undefined && + symbol.declarations.some((d) => ts.isObjectLiteralExpression(d)) && + checker.getPropertiesOfType(type).length === 0 && + checker.getIndexInfosOfType(type).length === 0 && + checker.getCallSignatures(type).length === 0 && + checker.getConstructSignatures(type).length === 0 + ); +} diff --git a/packages/compiler/src/heap-collection.test.ts b/packages/compiler/src/heap-collection.test.ts new file mode 100644 index 000000000..39baaf147 --- /dev/null +++ b/packages/compiler/src/heap-collection.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "vitest"; +import { heapPressureCheck } from "./heap-collection.js"; + +const MB = 1024 * 1024; + +/** A heap whose used size the test sets; a collection leaves `live` bytes. */ +function fakeHeap(initial: number) { + const heap = { used: initial, live: initial, collections: 0, reads: 0 }; + const check = heapPressureCheck( + () => { + heap.reads++; + return heap.used; + }, + () => { + heap.collections++; + heap.used = heap.live; + }, + ); + return { heap, check }; +} + +test("the check reads the heap every 64th call and at phase ends", () => { + const { heap, check } = fakeHeap(100 * MB); + heap.reads = 0; + for (let i = 0; i < 63; i++) check(false); + expect(heap.reads).toBe(0); + check(false); + expect(heap.reads).toBe(1); + check(true); + expect(heap.reads).toBe(2); +}); + +test("the check collects once the heap doubles and grows by 512 MB", () => { + const { heap, check } = fakeHeap(100 * MB); + // Five times the size, but only 400 MB more: no collection. + heap.used = 500 * MB; + check(true); + expect(heap.collections).toBe(0); + heap.used = 612 * MB; + check(true); + expect(heap.collections).toBe(1); + // The size after the collection is the new baseline: 400 MB live needs 912 MB. + heap.live = 400 * MB; + heap.used = 1000 * MB; + check(true); + expect(heap.collections).toBe(2); + heap.used = 900 * MB; + check(true); + expect(heap.collections).toBe(2); + heap.used = 912 * MB; + check(true); + expect(heap.collections).toBe(3); + // From 1 GB live, doubling is the larger bound. + heap.live = 1024 * MB; + heap.used = 2000 * MB; + check(true); + expect(heap.collections).toBe(4); + heap.used = 2047 * MB; + check(true); + expect(heap.collections).toBe(4); + heap.used = 2048 * MB; + check(true); + expect(heap.collections).toBe(5); +}); diff --git a/packages/compiler/src/heap-collection.ts b/packages/compiler/src/heap-collection.ts new file mode 100644 index 000000000..f7ef34e4e --- /dev/null +++ b/packages/compiler/src/heap-collection.ts @@ -0,0 +1,49 @@ +import { getHeapStatistics, setFlagsFromString } from "node:v8"; +import { runInNewContext } from "node:vm"; +import { setHeapPressureCheck } from "./heap-pressure.js"; + +/** Calls of the check between two reads of the heap size, outside phase ends. */ +const SAMPLE_INTERVAL = 64; +/** The heap grows by at least this much before a collection. */ +const MIN_GROWTH_BYTES = 512 * 1024 * 1024; + +/** + * The check `collectGarbageUnderHeapPressure` installs: every 64th call, and at every phase end, + * reads the used heap size and runs `collect` once it is at least twice the size after the + * previous collection and 512 MB above it. + */ +export function heapPressureCheck( + usedHeap: () => number, + collect: () => void, +): (phase: boolean) => void { + let live = usedHeap(); + let calls = 0; + return (phase) => { + if (!phase && ++calls % SAMPLE_INTERVAL !== 0) return; + const used = usedHeap(); + if (used < Math.max(2 * live, live + MIN_GROWTH_BYTES)) return; + collect(); + live = usedHeap(); + }; +} + +/** + * Runs a full garbage collection from the compiler's allocation-heavy loops + * (`relieveHeapPressure`) as `heapPressureCheck` decides. Without it, V8 lets the heap of a long + * lowering or LLVM emission grow to several times its live size between full collections, and + * Node keeps those pages for the life of the process. + * + * This sets --expose-gc for the process, so only the compiler's own processes call it (the CLI and + * the frontend process, frontend-process.ts), not `compile()` callers. + */ +export function collectGarbageUnderHeapPressure(): void { + setFlagsFromString("--expose-gc"); + const gc: unknown = runInNewContext("gc"); + if (typeof gc !== "function") return; + setHeapPressureCheck( + heapPressureCheck( + () => getHeapStatistics().used_heap_size, + () => (gc as () => void)(), + ), + ); +} diff --git a/packages/compiler/src/heap-pressure.ts b/packages/compiler/src/heap-pressure.ts new file mode 100644 index 000000000..fa8b35b43 --- /dev/null +++ b/packages/compiler/src/heap-pressure.ts @@ -0,0 +1,13 @@ +/** Allocation-heavy loops (lowering reached bodies and generic instances, emitting LLVM + * functions) call `relieveHeapPressure` between units of work. It does nothing unless the process + * installed a check with `setHeapPressureCheck` (collectGarbageUnderHeapPressure in + * heap-collection.ts). `phase` is true where a phase that allocates without these calls ends. */ +let check: (phase: boolean) => void = () => {}; + +export function relieveHeapPressure(phase = false): void { + check(phase); +} + +export function setHeapPressureCheck(next: (phase: boolean) => void): void { + check = next; +} diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 472f5cd14..6ac5cef46 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -82,7 +82,7 @@ import { privateSiblingPath } from "./backend/build-cache.js"; import { nativeCodegenTarget, nativeCodegenTargetRefusal } from "./backend/targets.js"; import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js"; import { createNativeLinkInfo } from "./backend/native-link-info.js"; -import { RuntimePackError, loadRuntimeBitcode } from "./backend/runtime-pack.js"; +import { RuntimePackError } from "./backend/runtime-pack.js"; import { createNativeLinkPlan } from "./backend/link-plan.js"; import { nativeProfileKey, @@ -159,6 +159,7 @@ import { type EarlyExecutableNativeFeatures, } from "./executable/executable-cache.js"; import { compilerImplementationIdentity } from "./library/compiler-self-identity.js"; +import { isPerformanceCode } from "./diagnostics/dynamic-tier.js"; export const VERSION = "0.0.1"; @@ -182,6 +183,7 @@ export { } from "./backend/external-c.js"; export { warmNativeCaches } from "./backend/warm-cache.js"; export { mergeNativeProfiles as mergeProfiles } from "./backend/native-codegen.js"; +export { collectGarbageUnderHeapPressure } from "./heap-collection.js"; export { ANDROID_MIN_API, IPHONEOS_MIN_VERSION, @@ -455,6 +457,15 @@ interface CompileWarningSink { sourceTexts: Map | null; } +/** The build's performance warnings (SC61xx), stored with its cache entry + * so a cache hit repeats them. */ +function performanceWarnings(sink: CompileWarningSink | undefined): { + performance?: ScrDiagnostic[]; +} { + const performance = (sink?.warnings ?? []).filter((d) => isPerformanceCode(d.code)); + return performance.length === 0 ? {} : { performance }; +} + function withCompileWarnings( result: T, sink: CompileWarningSink, @@ -637,7 +648,6 @@ async function emitNativeProgramObject( entryPath: string, opts: CompileRequestOptions, llvm: LlvmInput, - features: EarlyExecutableNativeFeatures, profile: NativeProfileOptions | undefined, ): Promise { const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); @@ -651,24 +661,6 @@ async function emitNativeProgramObject( const llvmBytes = llvmInputBytes(llvm); const partitions = opts.nativeProgramObject === true || target === null ? 1 : nativeProgramPartitions(llvmBytes); - // Optimized programs import small runtime functions from the bitcode of - // the exact runtime units they link, so LLVM can inline across the - // boundary. Release units ship only the bodies that need none of their - // unit-local state, which keeps the import cheap; speed units promote - // that state and offer more. Every partition reads the bitcode again, so - // release imports only into programs small enough for one partition: in - // larger ones the build-time and size cost outgrows the gain. - const runtimeBitcode = - opts.sanitize !== true && - target !== null && - (opts.optimization === "speed" || - (opts.optimization !== "dev" && nativeProgramPartitions(llvmBytes) === 1)) - ? await loadRuntimeBitcode({ - target, - features, - optimization: opts.optimization === "speed" ? "speed" : "release", - }) - : null; try { const artifact = await emitNativeArtifact({ outputPath: linkPath, @@ -678,16 +670,13 @@ async function emitNativeProgramObject( optimization: opts.optimization === "dev" ? "0" : "2", ...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }), partitions, - ...(runtimeBitcode === null - ? {} - : { importBitcode: { paths: runtimeBitcode.paths, digests: runtimeBitcode.digests } }), ...(profile === undefined ? {} : { profile }), }); return { linkPath, partitionPaths: artifact.outputPaths.slice(1), artifactPath, - dependencies: [...artifact.dependencies, ...(runtimeBitcode?.dependencies ?? [])], + dependencies: artifact.dependencies, }; } catch (error) { await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions)); @@ -1457,6 +1446,12 @@ async function compileTracked( sourceTexts: new Map(), }; } + // A cached build repeats its performance warning (SC6101): the code it + // names is just as untyped as when it was built. + if (warningSink !== undefined && earlyHit.performance !== undefined) { + warningSink.warnings = earlyHit.performance; + warningSink.sourceTexts = new Map(); + } const executableCacheOptions = earlyCacheOptions; // Route/proof metadata is independently evictable. A full-compiler // fallback that still finds the validated payload repairs that lightweight @@ -1497,7 +1492,6 @@ async function compileTracked( // A large program's LLVM exceeds the engine's string limit as one // string; the emitter takes it in parts, as on a cold build. await readTextParts(earlyHit.llvmPath), - earlyHit.native, profile, ); nativeInputPath = nativeProgramObject.linkPath; @@ -1613,13 +1607,7 @@ async function compileTracked( ); } try { - nativeProgramObject = await emitNativeProgramObject( - entryPath, - opts, - llvmSource, - nativeFeatures, - profile, - ); + nativeProgramObject = await emitNativeProgramObject(entryPath, opts, llvmSource, profile); timing("native-object"); await recordEarlyExecutableNativeBuild(cacheRoot, executableCacheOptions).catch( () => undefined, @@ -1653,6 +1641,7 @@ async function compileTracked( nativeDependencies: dependencies, frontend: frontendInputs.snapshot(), ...(irPath === undefined ? {} : { irPath }), + ...performanceWarnings(warningSink), }); publishedExecutable = true; }, @@ -1694,6 +1683,7 @@ async function compileTracked( executableRestored: false, frontend: frontendInputs.snapshot(), ...(irPath === undefined ? {} : { irPath }), + ...performanceWarnings(warningSink), }).catch(() => undefined); } await pruneBuildCache(cacheRoot); diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 016824dfc..95ea61fdf 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -2119,6 +2119,11 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * surface — the handles are the same kinds. */ "tls.createServer": { argTypes: [null, null], result: NETSERVER_T }, "tls.createServerCb": { argTypes: [null, null, null], result: NETSERVER_T }, + /** The SNICallback forms: (cert, key, SNICallback[, handler]). The + * callback is http2.createSecureServerSni's shape, a function or a + * function | undefined union. */ + "tls.createServerSni": { argTypes: [null, null, null], result: NETSERVER_T }, + "tls.createServerSniCb": { argTypes: [null, null, null, null], result: NETSERVER_T }, // The runtime options records (divergence 66's stance): a dyn options // value whose members read at runtime; pemDyn extracts a runtime-valued // cert/key member (arg 1 is the precomposed fence label). @@ -3675,6 +3680,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.bagGet": { argTypes: [DYN, STRING, DYN], result: DYN }, "dyn.bagSet": { argTypes: [DYN, STRING, DYN, DYN], result: VOID }, "dyn.objCreate": { argTypes: [DYN], result: DYN }, + // Names a compiled class's prototype object (args[0]) after the class + // (args[1]) for util.inspect. + "dyn.nameClassPrototype": { argTypes: [DYN, STRING], result: VOID }, "dyn.objCreateWithProperties": { argTypes: [DYN, DYN], result: DYN }, "dyn.getPrototype": { argTypes: [DYN], result: DYN }, "dyn.setPrototype": { argTypes: [DYN, DYN], result: DYN }, @@ -3733,6 +3741,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "regex.groupParseInt": { argTypes: [arrayOf(STRING), F64, F64], result: F64 }, // `.index` / `.input` (args[1]) of a match result. Borrowed; never throws. "regex.resultProp": { argTypes: [arrayOf(STRING), STRING], result: DYN }, + // The string keys `for...in` visits on an array (args[0], any element + // type) after its indices. Borrowed; never throws. + "array.namedKeys": { argTypes: [null], result: arrayOf(STRING) }, + // Whether the array (args[0]) still has the key (args[1]) from its + // namedKeys list. Borrowed; never throws. + "array.hasNamedKey": { argTypes: [null, STRING], result: BOOL }, // node:events EventEmitter: receivers are emitter-hierarchy objects and // the chaining forms (on/off/removeAll/setMax) return the receiver's // own class — program-dependent object types, checked in the libCall @@ -4625,6 +4639,14 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * lone surrogate as U+FFFD). Borrowed arg; owned (+1) string; never throws. */ "text.decode": { argTypes: [BYTES_U8], result: STRING }, "bytes.bufferSource": { argTypes: [DYN], result: BYTES_U8 }, + /** Whether an error-first callback's Buffer result is absent: the zlib + * and crypto callback adapters pass NULL after an error. Borrowed arg; + * never throws. */ + "bytes.isAbsent": { argTypes: [BYTES_U8], result: BOOL }, + /** The absent Buffer result itself (NULL), where an error-path callback + * body passes its undefined result on into a Buffer slot. Owned (+1); + * never throws. */ + "bytes.absent": { argTypes: [], result: BYTES_U8 }, /** The Buffer brand of a Uint8Array source onto a fresh Uint8Array * result (map and filter of a Buffer return a Buffer). */ "bytes.copyBrand": { argTypes: [BYTES_U8, BYTES_U8], result: VOID }, diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index 446b3512d..be933ab38 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -262,7 +262,8 @@ test("dynamic stores disqualify the field names they can reach", () => { expect([readOnly.isField("C", "a"), readOnly.isField("C", "b")]).toEqual([true, true]); // Reflection that only reads the view (Object.keys/values/entries, for-in, // own-key lists, typeof, toString and inspection) stores nothing, nor does - // creating an object from a prototype or reading a prototype. + // creating an object from a prototype, reading a prototype or naming a + // class prototype for inspection. for (const reader of [ "dyn.objKeys", "dyn.objKeyStrings", @@ -282,6 +283,7 @@ test("dynamic stores disqualify the field names they can reach", () => { "insp.dynS", "dyn.objCreate", "dyn.getPrototype", + "dyn.nameClassPrototype", // Registering or removing a process event listener stores nothing either. "process.onSignal", "process.offSignal", diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 781fa86bd..6ff148c55 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -376,6 +376,7 @@ const DYN_READ_ONLY = new Set([ "dyn.collectionIteratorIndex", "dyn.mapSeedEntries", "dyn.mapSeedEntry", + "dyn.nameClassPrototype", "dyn.numberConstructor", "dyn.objCreate", "dyn.objEntries", diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 75968b262..aada3b053 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -209,6 +209,45 @@ test("a string read at the counter is an integer consumer", () => { expect(matchIntegerCountedForLoop(s, withText)).toBeNull(); }); +test("a string length bounds a counted loop while its binding is unchanged", () => { + const withText = new Map(locals); + withText.set("s", { id: "s", name: "s", type: STRING, mutable: true }); + const text = (): IrExpr => ({ kind: "varRef", localId: "s", type: STRING, loc }); + const length: IrExpr = { + kind: "strIntrinsic", + method: "length", + receiver: text(), + args: [], + type: F64, + loc, + }; + const s = counted(length); + s.body = [ + { + kind: "exprStmt", + expr: { + kind: "strIntrinsic", + method: "charCodeAt", + receiver: text(), + args: [ref("i")], + type: F64, + loc, + }, + loc, + }, + ]; + expect(matchIntegerCountedForLoop(s, withText)?.localId).toBe("i"); + // Rebinding the string in the body changes the length the condition reads. + const rebinding = { + ...s, + body: [...s.body, { kind: "assign", localId: "s", value: text(), loc } as IrStmt], + }; + expect(matchIntegerCountedForLoop(rebinding, withText)).toBeNull(); + const boxed = new Map(withText); + boxed.set("s", { ...withText.get("s")!, boxed: true }); + expect(matchIntegerCountedForLoop(s, boxed)).toBeNull(); +}); + test("a stable array length bounds a counted loop; a resizing body keeps it a condition", () => { const ARR = arrayOf(F64); const array = (localId: string): IrExpr => ({ kind: "varRef", localId, type: ARR, loc }); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 3ba186376..9b4675685 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -83,6 +83,13 @@ function stableLoopValue( stableLoopValue(value.left, locals, body, invariant, counter, stableLength) && stableLoopValue(value.right, locals, body, invariant, counter, stableLength) ); + case "strIntrinsic": + // A string's length is fixed: stable while its binding is. + return ( + value.method === "length" && + value.receiver.kind === "varRef" && + stableLoopValue(value.receiver, locals, body, invariant, counter) + ); case "bytesIntrinsic": case "arrIntrinsic": return ( diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index f562d20e0..88ccbbc33 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -4262,6 +4262,12 @@ function validateFunction( if (!ok) err(`libCall dyn.objEntryPairs must return a [string, any] tuple array`, e.loc); return true; } + if (e.fn === "array.namedKeys" || e.fn === "array.hasNamedKey") { + if (e.args[0]?.type.kind !== "array") { + err(`libCall ${e.fn} arg 0: expected an array, got ${e.args[0]?.type.kind}`, e.loc); + } + return true; + } if (e.fn === "fetch.streamFrom") { const t = e.args[0]?.type; const ok = diff --git a/packages/compiler/src/startup-cache.ts b/packages/compiler/src/startup-cache.ts index c2f82fdad..1bc74bef0 100644 --- a/packages/compiler/src/startup-cache.ts +++ b/packages/compiler/src/startup-cache.ts @@ -25,3 +25,4 @@ export function precompiledRuntimePackTarget( export { checkerCacheIdentity } from "./frontend/ts7/checker-backend.js"; export { readCoverageVerdict } from "./coverage/verdict-startup.js"; export { coverageEnvelope, coveragePasses, renderCoverage } from "./coverage/report.js"; +export { renderPerformanceWarnings } from "./diagnostics/dynamic-tier.js"; diff --git a/packages/compiler/test/callback-pending-emission.test.ts b/packages/compiler/test/callback-pending-emission.test.ts new file mode 100644 index 000000000..9dcbbf0a1 --- /dev/null +++ b/packages/compiler/test/callback-pending-emission.test.ts @@ -0,0 +1,57 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; +import { emitLlvmModule } from "../src/backend/llvm/emitter.js"; + +// A specialized array helper calls its known callback directly after +// checking the closure's entry. A callback that cannot throw skips the +// pending test on that path, and the fallback through the closure's own +// entry keeps it; a callback that can throw keeps it on both paths. +test("known callbacks outside the may-throw set test the pending flag only on the fallback", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-callback-pending-")); + try { + const entry = join(dir, "main.ts"); + const output = join(dir, "main.ir.json"); + writeFileSync( + entry, + `const values = [1, 2, 3, 4]; +console.log(values.every((v) => v > 0)); +console.log(values.map((v) => v * 2).join(",")); +try { + console.log(values.every((v) => { + if (v > 2) throw new Error("stop at " + v); + return true; + })); +} catch (error) { + console.log("caught", (error as Error).message); +} +`, + ); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const llvm = emitLlvmModule(module, {}); + const guards = [ + ...llvm.matchAll( + /(%t\d+) = phi i1 \[ false, %callback\.direct\d+ \], \[ true, %callback\.indirect\d+ \]/g, + ), + ]; + // The quiet every and map callbacks; not the throwing one. + expect(guards.length).toBe(2); + for (const [, guard] of guards) + expect(llvm).toMatch(new RegExp(`br i1 ${guard}, label %exc\\.t\\d+`)); + const helpers = llvm.split(/\n(?=define )/).filter((fn) => fn.includes("callback.direct")); + expect(helpers.filter((fn) => !/phi i1 \[ false, %callback\.direct/.test(fn))).toHaveLength(1); + + const exe = join(dir, "main"); + const built = await compile(entry, { outputKind: "exe", outDir: dir, outPath: exe }); + if (!built.ok) throw new Error(JSON.stringify(built.diagnostics)); + expect(execFileSync(exe, { encoding: "utf8" })).toBe("true\n2,4,6,8\ncaught stop at 3\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/compiler/test/format-float-arguments.test.ts b/packages/compiler/test/format-float-arguments.test.ts index 8aa08469d..e59e023ca 100644 --- a/packages/compiler/test/format-float-arguments.test.ts +++ b/packages/compiler/test/format-float-arguments.test.ts @@ -27,8 +27,9 @@ test("util.format %f lowers a boolean argument once", async () => { const helpers = mod.functions .map((f) => f.name) .filter((name) => name.startsWith("%arr.")) - .map((name) => name.replace(/\.\d+$/, "")); - expect(helpers).toEqual(["%arr.some", "%arr.every"]); + .map((name) => name.replace(/\.\d+$/, "")) + .sort(); + expect(helpers).toEqual(["%arr.every", "%arr.some"]); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/packages/compiler/test/int32-dynamic-exposure.test.ts b/packages/compiler/test/int32-dynamic-exposure.test.ts new file mode 100644 index 000000000..09330e057 --- /dev/null +++ b/packages/compiler/test/int32-dynamic-exposure.test.ts @@ -0,0 +1,84 @@ +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 } from "../src/index.js"; +import { analyzeInt32Slots, dynamicFieldStores } from "../src/ir/int32-slots.js"; +import type { IrModule } from "../src/ir/ir.js"; + +// Instances of these classes reach checked-dynamic code (an unknown[] array, +// instanceof, inspection, JSON and string conversion), but the program never +// stores a property through a dynamic value. The helpers the compiler emits +// for those reads (prototype objects, inspection names) store nothing, so the +// classes keep their int32 fields. +const source = ` +import { inspect } from "node:util"; + +class Node { + kind: number; + flags = 0; + constructor(kind: number) { + this.kind = kind; + } +} +class Identifier extends Node { + text = "id"; +} +class Type { + constructor( + public flags: number, + public id: number, + ) {} +} + +const values: unknown[] = [new Node(1), new Identifier(2), new Type(4, 7), "s", 3]; +let total = 0; +for (const value of values) { + if (value instanceof Identifier) total += value.kind | value.flags; + else if (value instanceof Node) total += value.kind; + else if (value instanceof Type) total += value.flags & 6; +} +console.log(total, values, inspect(new Type(1, 2))); +console.log(JSON.stringify(values), String(values[0]), typeof values[2]); +`; + +async function lowered(): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-dynamic-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + 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")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("classes that only reach dynamic reads keep their int32 fields", async () => { + const mod = await lowered(); + // Name the functions whose dynamic helpers count as stores that can name + // any property: such a helper either stores into the value or is missing + // from the analysis's read-only list (DYN_READ_ONLY in int32-slots.ts). + const storing = mod.functions + .filter((fn) => dynamicFieldStores({ ...mod, functions: [fn] }).all) + .map((fn) => fn.name); + expect(storing).toEqual([]); + expect(dynamicFieldStores(mod).names.size).toBe(0); + + const slots = analyzeInt32Slots(mod); + const className = (jsName: string): string => + mod.classes!.find((c) => (c.jsName ?? c.name) === jsName || c.name.endsWith(`.${jsName}`))! + .name; + expect([ + slots.isField(className("Node"), "kind"), + slots.isField(className("Node"), "flags"), + slots.isField(className("Identifier"), "kind"), + slots.isField(className("Type"), "flags"), + slots.isField(className("Type"), "id"), + ]).toEqual([true, true, true, true, true]); + // The program reaches the helper that names class prototypes for inspection. + expect(mod.functions.some((fn) => fn.name.startsWith("%prototype.data."))).toBe(true); +}); diff --git a/packages/compiler/test/string-char-at-emission.test.ts b/packages/compiler/test/string-char-at-emission.test.ts new file mode 100644 index 000000000..8e5814120 --- /dev/null +++ b/packages/compiler/test/string-char-at-emission.test.ts @@ -0,0 +1,55 @@ +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 { emitLlvmModule } from "../src/backend/llvm/emitter.js"; +import { literalCapWord } from "../src/backend/llvm/string-key-hash.js"; +import type { IrModule } from "../src/ir/ir.js"; + +async function lower(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-char-at-")); + try { + 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")); + expect(validateModule(mod)).toEqual([]); + return mod; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const options = { targetTriple: "x86_64-unknown-linux-gnu", pointerBits: 64 } as const; + +test("charAt reads proven-ASCII strings through the module's character table", async () => { + const mod = await lower( + 'const s = process.argv.length > 9 ? "x" : "hello";\nconst i: number = process.argv.length - 1;\nconsole.log(s.charAt(i), s[i]);\n', + ); + const ir = emitLlvmModule(mod, options); + expect(ir).toContain("define internal ptr @sc_str_char_at(ptr %s, double %i)"); + expect(ir).toContain("call ptr @scr_str_char_at(ptr %s, double %i) cold"); + // Each entry is an immortal one-character literal with the runtime's + // capacity word (length, proven-ASCII bit and key hash). + const word = literalCapWord(new Uint8Array([65])); + expect(ir).toContain(`{ i64 -1, i64 1, i64 ${word}, [2 x i8] [i8 65, i8 0] }`); + expect(ir.match(/^@sc_ascii_chars = internal constant \[128 x /gm)).toHaveLength(1); +}); + +test("charAt at a counted-loop index uses the integer helper", async () => { + const mod = await lower( + 'const s = process.argv.length > 9 ? "x" : "hello";\nlet out = "";\nfor (let i = 0; i < s.length; i++) out += s.charAt(i);\nconsole.log(out);\n', + ); + const ir = emitLlvmModule(mod, options); + expect(ir).toContain("define internal ptr @sc_str_char_at_int(ptr %s, i64 %i)"); + expect(ir).toMatch(/call ptr @sc_str_char_at_int\(ptr %\w+, i64 %\w+\)/); +}); + +test("programs without charAt carry no character table", async () => { + const mod = await lower('console.log("hello".length + process.argv.length);\n'); + expect(emitLlvmModule(mod, options)).not.toContain("@sc_ascii_chars"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6211-worker-message-batches.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6211-worker-message-batches.ts.json new file mode 100644 index 000000000..a9cc11603 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6211-worker-message-batches.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6211-worker-message-batches.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6212-worker-message-batch-ports.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6212-worker-message-batch-ports.ts.json new file mode 100644 index 000000000..76d4c8da7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6212-worker-message-batch-ports.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6212-worker-message-batch-ports.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6721-tls-alpn-protocols.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6721-tls-alpn-protocols.ts.json new file mode 100644 index 000000000..b86035ce7 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6721-tls-alpn-protocols.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6721-tls-alpn-protocols.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6722-tls-min-max-version.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6722-tls-min-max-version.ts.json new file mode 100644 index 000000000..0100ffe8f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6722-tls-min-max-version.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6722-tls-min-max-version.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6723-tls-connect-timeout.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6723-tls-connect-timeout.ts.json new file mode 100644 index 000000000..cc239efba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6723-tls-connect-timeout.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6723-tls-connect-timeout.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6820-stream-push-nullable-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6820-stream-push-nullable-chunks.ts.json new file mode 100644 index 000000000..26bb4ae9e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6820-stream-push-nullable-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6820-stream-push-nullable-chunks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6821-stream-write-nullable-chunks.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6821-stream-write-nullable-chunks.cjs.json new file mode 100644 index 000000000..e296beb66 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6821-stream-write-nullable-chunks.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6821-stream-write-nullable-chunks.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6823-recursive-alias-generic-signature.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6823-recursive-alias-generic-signature.ts.json new file mode 100644 index 000000000..3185f3606 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6823-recursive-alias-generic-signature.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6823-recursive-alias-generic-signature.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6860-unary-plus-primitive-unions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6860-unary-plus-primitive-unions.ts.json new file mode 100644 index 000000000..9847a1330 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6860-unary-plus-primitive-unions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6860-unary-plus-primitive-unions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6861-unary-plus-optional-values.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6861-unary-plus-optional-values.js.json new file mode 100644 index 000000000..7bf8c20a2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6861-unary-plus-optional-values.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6861-unary-plus-optional-values.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6905-isfinite-coercion.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6905-isfinite-coercion.ts.json new file mode 100644 index 000000000..210987798 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6905-isfinite-coercion.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6905-isfinite-coercion.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6906-tonumber-arguments.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6906-tonumber-arguments.mjs.json new file mode 100644 index 000000000..2fc650f3a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6906-tonumber-arguments.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6906-tonumber-arguments.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6908-generic-class-literal-conditional.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6908-generic-class-literal-conditional.ts.json new file mode 100644 index 000000000..d0403f33f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6908-generic-class-literal-conditional.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6908-generic-class-literal-conditional.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6909-empty-object-type-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6909-empty-object-type-values.ts.json new file mode 100644 index 000000000..e94a525ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6909-empty-object-type-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6909-empty-object-type-values.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6910-empty-object-type-values-js.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6910-empty-object-type-values-js.mjs.json new file mode 100644 index 000000000..1e07899f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6910-empty-object-type-values-js.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6910-empty-object-type-values-js.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6911-structural-constructor-types.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6911-structural-constructor-types.ts.json new file mode 100644 index 000000000..3d1438687 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6911-structural-constructor-types.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6911-structural-constructor-types.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6920-zlib-callback-error-result.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6920-zlib-callback-error-result.ts.json new file mode 100644 index 000000000..05e138441 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6920-zlib-callback-error-result.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6920-zlib-callback-error-result.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6931-fs-url-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6931-fs-url-paths.ts.json new file mode 100644 index 000000000..6a1574e04 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6931-fs-url-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6931-fs-url-paths.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json new file mode 100644 index 000000000..4cf80592f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6933-tls-sni-callback.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6933-tls-sni-callback.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json new file mode 100644 index 000000000..ae362a423 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6934-tls-handshake-disconnect.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6934-tls-handshake-disconnect.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6942-js-buffer-view-of-declared-field.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6942-js-buffer-view-of-declared-field.js.json new file mode 100644 index 000000000..f23e96cb8 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6942-js-buffer-view-of-declared-field.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6942-js-buffer-view-of-declared-field.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json new file mode 100644 index 000000000..31675c66e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6943-js-class-branch-bags.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6943-js-class-branch-bags.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6944-js-super-call-in-if-condition.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6944-js-super-call-in-if-condition.js.json new file mode 100644 index 000000000..ba20299f2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6944-js-super-call-in-if-condition.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6944-js-super-call-in-if-condition.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6950-string-field-fast-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6950-string-field-fast-paths.ts.json new file mode 100644 index 000000000..db05994a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6950-string-field-fast-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6950-string-field-fast-paths.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6951-unknown-cast-class-methods.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6951-unknown-cast-class-methods.ts.json new file mode 100644 index 000000000..281dcba85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6951-unknown-cast-class-methods.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6951-unknown-cast-class-methods.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6960-replace-char-blocks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6960-replace-char-blocks.ts.json new file mode 100644 index 000000000..ccc217b7a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6960-replace-char-blocks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6960-replace-char-blocks.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6987-array-view-named-properties.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6987-array-view-named-properties.ts.json new file mode 100644 index 000000000..c12126453 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6987-array-view-named-properties.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6987-array-view-named-properties.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6990-record-writes-from-js/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6990-record-writes-from-js/main.ts.json new file mode 100644 index 000000000..8eeecc9dd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6990-record-writes-from-js/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6990-record-writes-from-js/helpers.js", + "/tests/corpus/6990-record-writes-from-js/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6991-inspect-class-name-instanceof.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6991-inspect-class-name-instanceof.ts.json new file mode 100644 index 000000000..a6629e2a9 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6991-inspect-class-name-instanceof.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6991-inspect-class-name-instanceof.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json new file mode 100644 index 000000000..0c6cbd9d1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7000-umd-package-entry/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7000-umd-package-entry/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-exact-capacity-builders.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-exact-capacity-builders.ts.json new file mode 100644 index 000000000..20dc485de --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/array-exact-capacity-builders.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/array-exact-capacity-builders.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fs-url-path-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fs-url-path-fences.ts.json new file mode 100644 index 000000000..c25c78ec0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/fs-url-path-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/fs-url-path-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json new file mode 100644 index 000000000..ee2ea72f0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/tls-sni-callback-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/tls-sni-callback-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json new file mode 100644 index 000000000..7b63a5564 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-path-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fs-url-path-fences.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-paths.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-paths.ts.json new file mode 100644 index 000000000..cdc4ee43f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/node-types/fs-url-paths.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/node-types/fs-url-paths.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/dev-pack.json b/packages/libnode-common/dev-pack.json index 7544d44c4..6c521cbb6 100644 --- a/packages/libnode-common/dev-pack.json +++ b/packages/libnode-common/dev-pack.json @@ -1,14 +1,20 @@ { - "comment": "Temporary libnode source for CI until the NI-13 packs (@scriptc/libnode-) exist: the community dockfries/libnode build of the newest patch of the pinned Node line (its v24.22.0 asset was withdrawn when v24.22.1 shipped), plus Node's official headers for the bridge. fetch-dev-pack.mjs verifies both hashes.", + "comment": "Temporary libnode source for CI until the NI-13 packs (@scriptc/libnode-) exist: the community dockfries/libnode build of Node 24.22.1 and Node's official headers for the bridge. The first URL is scriptc's CI-only mirror (prerelease libnode-dev-24.22.1), the others the original locations; fetch-dev-pack.mjs tries them in order and verifies the sha256.", "nodeVersion": "24.22.1", "linux-x64": { "libnode": { - "url": "https://github.com/dockfries/libnode/releases/download/v24.22.1/libnode-linux-x64.tar.gz", + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/libnode-linux-x64.tar.gz", + "https://github.com/dockfries/libnode/releases/download/v24.22.1/libnode-linux-x64.tar.gz" + ], "sha256": "b02809c5d332516cc72890fec0cbbca404bac526bd0f841e00a8a6056ec1ff54", "file": "libnode.so.137" }, "headers": { - "url": "https://nodejs.org/dist/v24.22.1/node-v24.22.1-headers.tar.gz", + "urls": [ + "https://github.com/vercel-labs/scriptc/releases/download/libnode-dev-24.22.1/node-v24.22.1-headers.tar.gz", + "https://nodejs.org/dist/v24.22.1/node-v24.22.1-headers.tar.gz" + ], "sha256": "322e0a27db9088093ab8347bdc8c7e0aaa21dc9ed548390bf06b394494c09267", "dir": "node-v24.22.1/include/node" } diff --git a/packages/libnode-common/fetch-dev-pack.mjs b/packages/libnode-common/fetch-dev-pack.mjs index 5a4922db3..689b683f6 100644 --- a/packages/libnode-common/fetch-dev-pack.mjs +++ b/packages/libnode-common/fetch-dev-pack.mjs @@ -30,15 +30,32 @@ if (pins === undefined) { const work = mkdtempSync(join(tmpdir(), "scriptc-libnode-dev-")); -async function fetchVerified({ url, sha256 }, name) { - const response = await fetch(url); - if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); - const bytes = Buffer.from(await response.arrayBuffer()); - const actual = createHash("sha256").update(bytes).digest("hex"); - if (actual !== sha256) throw new Error(`${url}: sha256 ${actual}, expected ${sha256}`); - const path = join(work, name); - writeFileSync(path, bytes); - execFileSync("tar", ["-xzf", path, "-C", work]); +/** Downloads from the first URL that answers with the pinned bytes: the + * mirror, then the original locations. */ +async function fetchVerified({ urls, sha256 }, name) { + const failures = []; + for (const url of urls) { + let bytes; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + bytes = Buffer.from(await response.arrayBuffer()); + } catch (error) { + failures.push(`${url}: ${error.message}`); + continue; + } + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== sha256) { + failures.push(`${url}: sha256 ${actual}, expected ${sha256}`); + continue; + } + console.log(`fetched ${name} from ${url} (sha256 ${sha256})`); + const path = join(work, name); + writeFileSync(path, bytes); + execFileSync("tar", ["-xzf", path, "-C", work]); + return; + } + throw new Error(`no source for ${name} matched its pin:\n${failures.join("\n")}`); } await fetchVerified(pins.libnode, "libnode.tar.gz"); diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index 4887f23ff..c64b85947 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -42,14 +42,100 @@ static void scr_arr_oom(void) { static const ScrArrExt scr_arr_no_ext; SCR_TL bool (*scr_arr_match_copy)(ScrArr *a, ScrDyn *target) = NULL; -#define SCR_AX(a) ((const ScrArrExt *)((a)->ext ? (a)->ext : &scr_arr_no_ext)) +static inline const ScrArrExt *scr_arr_ax(const ScrArr *a) { + const ScrArrExt *x = scr_arr_ext_of(a); + return x ? x : &scr_arr_no_ext; +} +#define SCR_AX(a) scr_arr_ax(a) + +static void scr_arr_move_inline(ScrArr *a, size_t cap, size_t unit); + +/* Bytes before the dense slots' allocation start: the ext pointer's word. */ +static size_t scr_arr_prefix(const ScrArr *a) { + return (a->spare & SCR_ARR_HAS_EXT) ? SCR_ARR_EXT_PREFIX : 0; +} + +/* The start of the block the dense storage was allocated as. */ +static void *scr_arr_alloc_start(const ScrArr *a) { + return (char *)scr_arr_block(a) - scr_arr_prefix(a); +} + +/* Bytes per dense slot with its state: an 8-byte value, or a boolean byte. */ +static size_t scr_arr_slot_unit(const ScrArr *a) { + return a->elem == SCR_ELEM_BOOL ? 2 * sizeof(uint8_t) : sizeof(uint64_t) + sizeof(uint8_t); +} + +/* Bytes of dense storage (head room included) after the prefix. */ +static size_t scr_arr_storage_bytes(const ScrArr *a) { + return ((size_t)(a->data - scr_arr_block(a)) + a->cap) * scr_arr_slot_unit(a); +} ScrArrExt *scr_arr_ext(ScrArr *a) { - if (!a->ext) { - a->ext = scr_mem_calloc(sizeof(ScrArrExt)); - if (!a->ext) scr_arr_oom(); + ScrArrExt *x = scr_arr_ext_of(a); + if (x) return x; + x = scr_mem_calloc(sizeof(ScrArrExt)); + if (!x) scr_arr_oom(); + /* The pointer goes in the word before the dense allocation, which an + * array with ext always has: inline slots move out, and an array without + * storage gets an allocation of the word alone. */ + if (a->spare & SCR_ARR_INLINE_SLOTS) scr_arr_move_inline(a, a->cap, scr_arr_slot_unit(a)); + char *start; + if (!a->data) { + start = scr_mem_alloc(SCR_ARR_EXT_PREFIX); + if (!start) scr_arr_oom(); + a->data = (uint64_t *)(start + SCR_ARR_EXT_PREFIX); + a->cap = 0; + } else { + size_t bytes = scr_arr_storage_bytes(a); + size_t head = (size_t)(a->data - scr_arr_block(a)); + if (a->rc == SIZE_MAX) { + /* An immortal array's storage may be static: copy it, never move it. */ + start = scr_mem_alloc(bytes + SCR_ARR_EXT_PREFIX); + if (!start) scr_arr_oom(); + memcpy(start + SCR_ARR_EXT_PREFIX, scr_arr_block(a), bytes); + } else { + start = scr_mem_realloc(scr_arr_block(a), bytes + SCR_ARR_EXT_PREFIX); + if (!start) scr_arr_oom(); + memmove(start + SCR_ARR_EXT_PREFIX, start, bytes); + } + a->data = (uint64_t *)(start + SCR_ARR_EXT_PREFIX) + head; } - return a->ext; + memcpy(start, &x, sizeof x); + a->spare |= SCR_ARR_HAS_EXT; + return x; +} + +/* Registry of element ops by id (scr_arr_ops); slot 0 is no ops. */ +const ScrArrOps *scr_arr_ops_table[SCR_ARR_OPS_OVERFLOW] = {0}; +static uint32_t scr_arr_ops_count = 1; + +/* Assign `ops` its id on its first array; any thread may get there first, + * and every thread then sees the same id. A lost race leaves one table + * slot unused. Once the table is full, later descriptors get + * SCR_ARR_OPS_OVERFLOW. */ +uint32_t scr_arr_ops_register(ScrArrOps *ops) { + uint32_t id = __atomic_load_n(&ops->id, __ATOMIC_ACQUIRE); + if (id) return id; + uint32_t fresh = __atomic_fetch_add(&scr_arr_ops_count, 1, __ATOMIC_RELAXED); + if (fresh < SCR_ARR_OPS_OVERFLOW) { + __atomic_store_n(&scr_arr_ops_table[fresh], ops, __ATOMIC_RELEASE); + } else { + fresh = SCR_ARR_OPS_OVERFLOW; + } + uint32_t expected = 0; + if (__atomic_compare_exchange_n(&ops->id, &expected, fresh, false, __ATOMIC_ACQ_REL, + __ATOMIC_ACQUIRE)) + return fresh; + return expected; +} + +const ScrArrOps *scr_arr_ops_overflow(const ScrArr *a) { return scr_arr_ext_of(a)->ops; } + +/* Record `ops` as the element ops of `a` (NULL for none). */ +static void scr_arr_set_ops(ScrArr *a, const ScrArrOps *ops) { + uint32_t id = scr_arr_ops_id(ops); + a->spare = (uint16_t)((a->spare & ((1u << SCR_ARR_OPS_SHIFT) - 1)) | (id << SCR_ARR_OPS_SHIFT)); + if (id == SCR_ARR_OPS_OVERFLOW) scr_arr_ext(a)->ops = ops; } /* Canonical array indices grow indexed storage and may leave holes. Negative, @@ -133,24 +219,36 @@ static bool scr_elem_is_ref(ScrElemKind k) { k == SCR_ELEM_REF; } -static void scr_elem_release(const ScrArr *a, uint64_t slot) { +/* Releases an element slot of kind `elem`, with element ops `ops` for + * SCR_ELEM_REF. An array's kind and ops never change, so loops look them up + * once (element releases may run teardown, which reloads anything read + * through the array). */ +static inline void scr_elem_release_with(uint8_t elem, const ScrArrOps *ops, uint64_t slot) { void *p = scr_slot_to_ptr(slot); if (!p) return; - if (a->elem == SCR_ELEM_STR) scr_str_release((ScrStr *)p); - else if (a->elem == SCR_ELEM_ARR) scr_arr_release((ScrArr *)p); - else if (a->elem == SCR_ELEM_BYTES) scr_bytes_release((ScrBytes *)p); - else if (a->elem == SCR_ELEM_REF) a->ops->release(p); + if (elem == SCR_ELEM_STR) scr_str_release((ScrStr *)p); + else if (elem == SCR_ELEM_ARR) scr_arr_release((ScrArr *)p); + else if (elem == SCR_ELEM_BYTES) scr_bytes_release((ScrBytes *)p); + else if (elem == SCR_ELEM_REF) ops->release(p); +} + +static inline uint64_t scr_elem_retain_with(uint8_t elem, const ScrArrOps *ops, uint64_t slot) { + if (!scr_elem_is_ref(elem)) return slot; + void *p = scr_slot_to_ptr(slot); + if (!p) return slot; + if (elem == SCR_ELEM_STR) p = scr_str_retain((ScrStr *)p); + else if (elem == SCR_ELEM_ARR) p = scr_arr_retain((ScrArr *)p); + else if (elem == SCR_ELEM_BYTES) p = scr_bytes_retain((ScrBytes *)p); + else p = ops->retain(p); + return scr_slot_from_ptr(p); +} + +static void scr_elem_release(const ScrArr *a, uint64_t slot) { + scr_elem_release_with(a->elem, scr_arr_ops(a), slot); } static uint64_t scr_elem_retain_slot(const ScrArr *a, uint64_t slot) { - if (!scr_elem_is_ref(a->elem)) return slot; - void *p = scr_slot_to_ptr(slot); - if (!p) return slot; - if (a->elem == SCR_ELEM_STR) p = scr_str_retain((ScrStr *)p); - else if (a->elem == SCR_ELEM_ARR) p = scr_arr_retain((ScrArr *)p); - else if (a->elem == SCR_ELEM_BYTES) p = scr_bytes_retain((ScrBytes *)p); - else p = a->ops->retain(p); - return scr_slot_from_ptr(p); + return scr_elem_retain_with(a->elem, scr_arr_ops(a), slot); } static uint64_t scr_arr_retain_state_slot(const ScrArr *a, uint64_t slot, @@ -310,7 +408,6 @@ static void scr_arr_sparse_drop_prefix(ScrArr *a, size_t count) { * `spare` records it. Growth past the inline capacity moves the slots to an * ordinary allocation; the inline room then stays unused until the array is * freed. */ -#define SCR_ARR_INLINE_SLOTS 1u /* Where inline slots start in the array's block (8-byte aligned on every * target). */ #define SCR_ARR_INLINE_OFFSET ((sizeof(ScrArr) + 7) & ~(size_t)7) @@ -329,11 +426,10 @@ static bool scr_arr_inline(const ScrArr *a) { return (a->spare & SCR_ARR_INLINE_ * slot just before `data` holds its size (scr_arr_head). Every reader * indexes from `data` 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 + * Static and inline storage never has head room. SCR_ARR_UNSHIFTED in + * `spare` records that an unshift had to move every element, so the next + * one that would have to move them again opens head room instead + * (scr_arr_open_head). */ /* Arrays shorter than this keep moving their elements on unshift. */ #define SCR_ARR_HEAD_MIN ((size_t)16) @@ -351,8 +447,6 @@ static void scr_arr_set_head(ScrArr *a, size_t head) { a->spare |= SCR_ARR_HEAD_ROOM; } -/* 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. */ @@ -404,8 +498,8 @@ static bool scr_arr_drop_front(ScrArr *a, size_t count) { } /* Slots requested by the small constructions. The small allocator rounds - * them up to fill its block: 3 eight-byte slots after a plain header in an - * 80-byte block (88 after a collector header), or 8 booleans in a 64-byte + * them up to fill its block: 3 eight-byte slots after a plain header in a + * 64-byte block (72 after a collector header), or 4 booleans in a 40-byte * block. Most small arrays hold up to three elements; two slots make * arrays that grow past them move their storage often enough to slow * programs that build many short lists. */ @@ -497,8 +591,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 || (a->spare & (SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM)) || - (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 | SCR_ARR_HAS_EXT)) || + need > SCR_ARR_DENSE_LIMIT) { scr_arr_grow_dense_slow(a, need, mode); return; } @@ -524,6 +618,7 @@ static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode /* Sparse entries below a raised capacity move into the packed slots. */ static void scr_arr_absorb_sparse(ScrArr *a) { + if (!(a->spare & SCR_ARR_HAS_EXT)) return; 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); @@ -585,8 +680,10 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode) { /* Values and states share one allocation. Move the old states to its new * end before publishing the larger capacity: their previous location is * now part of the value area. */ - uint64_t *data = scr_mem_realloc(a->data, cap * unit); - if (!data) scr_arr_oom(); + size_t prefix = scr_arr_prefix(a); + char *start = scr_mem_realloc((char *)a->data - prefix, prefix + cap * unit); + if (!start) scr_arr_oom(); + uint64_t *data = (uint64_t *)(start + prefix); uint8_t *end = (uint8_t *)data + cap * (width + 1); if (a->cap) memmove(end - a->cap, (uint8_t *)data + a->cap * width, a->cap); memset(end - cap, SCR_ARR_HOLE, cap - a->cap); @@ -621,8 +718,12 @@ static bool scr_arr_open_head(ScrArr *a, size_t count) { 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(); + size_t prefix = scr_arr_prefix(a); + char *start = scr_mem_alloc(prefix + total * SCR_ARR_SLOT_BYTES); + if (!start) scr_arr_oom(); + /* The ext pointer's word moves with the allocation. */ + if (prefix) memcpy(start, scr_arr_alloc_start(a), prefix); + uint64_t *block = (uint64_t *)(start + prefix); /* The window's states end `room` bytes before the allocation does; the * room's states past it are holes. */ uint8_t *end = (uint8_t *)(block + total) + a->cap; @@ -630,7 +731,7 @@ static bool scr_arr_open_head(ScrArr *a, size_t count) { memset(end, SCR_ARR_HOLE, room); memcpy(end - len, scr_arr_state_run(a, 0, len), len); memset(end - a->cap, SCR_ARR_HOLE, a->cap - len); - scr_mem_free(scr_arr_block(a)); + scr_mem_free(scr_arr_alloc_start(a)); a->data = block + room; head = room; } @@ -700,21 +801,40 @@ static uint8_t scr_arr_state_at_storage(const ScrArrStorage *s, size_t index, return s->sparse[pos].state; } -static bool scr_arr_slot_at_storage(const ScrArrStorage *s, size_t index, - uint64_t *out) { - return scr_arr_state_at_storage(s, index, out) != SCR_ARR_HOLE; +/* A state and its slot (zero for a hole). */ +typedef struct { + uint64_t slot; + uint8_t state; +} ScrArrStateSlot; + +/* scr_arr_state_at past the dense capacity: the sorted sparse entries. It + * only reads memory, so callers keep the array's fields in registers across + * it. */ +static __attribute__((noinline, pure)) ScrArrStateSlot scr_arr_sparse_state_at(const ScrArr *a, + size_t index) { + const ScrArrExt *x = SCR_AX(a); + size_t pos = scr_arr_sparse_lower_bound(x->sparse, x->sparse_len, index); + if (pos == x->sparse_len || x->sparse[pos].index != index) return (ScrArrStateSlot){0, SCR_ARR_HOLE}; + return (ScrArrStateSlot){x->sparse[pos].slot, x->sparse[pos].state}; } -static bool scr_arr_slot_at(const ScrArr *a, size_t index, uint64_t *out) { - ScrArrStorage s = {a->data, scr_arr_states_end(a), a->cap, SCR_AX(a)->sparse, - SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, NULL}; - return scr_arr_slot_at_storage(&s, index, out); +/* The state at `index` and, unless it is a hole, its slot in `out`: the + * dense storage below `cap` (without touching the out-of-line storage), the + * sparse entries above it. */ +static inline uint8_t scr_arr_state_at(const ScrArr *a, size_t index, uint64_t *out) { + if (index < a->cap) { + uint8_t state = *scr_arr_state_in(scr_arr_states_end(a), index); + if (state == SCR_ARR_HOLE) return SCR_ARR_HOLE; + if (out) *out = scr_arr_dense_get(a, index); + return state; + } + ScrArrStateSlot sparse = scr_arr_sparse_state_at(a, index); + if (sparse.state != SCR_ARR_HOLE && out) *out = sparse.slot; + return sparse.state; } -static uint8_t scr_arr_state_at(const ScrArr *a, size_t index, uint64_t *out) { - ScrArrStorage s = {a->data, scr_arr_states_end(a), a->cap, SCR_AX(a)->sparse, - SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, NULL}; - return scr_arr_state_at_storage(&s, index, out); +static inline bool scr_arr_slot_at(const ScrArr *a, size_t index, uint64_t *out) { + return scr_arr_state_at(a, index, out) != SCR_ARR_HOLE; } /* Store an owned slot into an empty index. The caller has already established @@ -843,7 +963,7 @@ static void scr_arr_prop_set(ScrArr *a, double key, uint64_t slot) { char *copy = malloc(len + 1); if (!copy) scr_arr_oom(); memcpy(copy, text, len + 1); - a->ext->props[a->ext->prop_len++] = (ScrArrProp){copy, len, slot, SCR_ARR_VALUE}; + scr_arr_ext_of(a)->props[scr_arr_ext_of(a)->prop_len++] = (ScrArrProp){copy, len, slot, SCR_ARR_VALUE}; } static void scr_arr_prop_set_undefined(ScrArr *a, double key) { @@ -864,7 +984,7 @@ static void scr_arr_prop_set_undefined(ScrArr *a, double key) { char *copy = malloc(len + 1); if (!copy) scr_arr_oom(); memcpy(copy, text, len + 1); - a->ext->props[a->ext->prop_len++] = (ScrArrProp){copy, len, 0, SCR_ARR_UNDEFINED}; + scr_arr_ext_of(a)->props[scr_arr_ext_of(a)->prop_len++] = (ScrArrProp){copy, len, 0, SCR_ARR_UNDEFINED}; } static bool scr_arr_prop_delete(ScrArr *a, double key) { @@ -877,7 +997,7 @@ static bool scr_arr_prop_delete(ScrArr *a, double key) { free(SCR_AX(a)->props[pos].key); memmove(SCR_AX(a)->props + pos, SCR_AX(a)->props + pos + 1, (SCR_AX(a)->prop_len - pos - 1) * sizeof(*SCR_AX(a)->props)); - a->ext->prop_len--; + scr_arr_ext_of(a)->prop_len--; if (old_state == SCR_ARR_VALUE && scr_elem_is_ref(a->elem)) { scr_elem_release(a, old); } @@ -885,17 +1005,25 @@ static bool scr_arr_prop_delete(ScrArr *a, double key) { } static ScrArrStorage scr_arr_take_storage(ScrArr *a) { + ScrArrExt *x = scr_arr_ext_of(a); ScrArrStorage old = {a->data, scr_arr_states_end(a), a->cap, SCR_AX(a)->sparse, SCR_AX(a)->sparse_len, SCR_AX(a)->sparse_cap, a->elem, - scr_arr_inline(a) ? NULL : scr_arr_block(a)}; - a->spare &= (uint16_t)~(SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM); + scr_arr_inline(a) ? NULL : scr_arr_alloc_start(a)}; + a->spare &= (uint16_t)~(SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM | SCR_ARR_HAS_EXT); a->data = NULL; a->cap = 0; a->filled = 0; - if (a->ext) { - a->ext->sparse = NULL; - a->ext->sparse_len = 0; - a->ext->sparse_cap = 0; + if (x) { + /* The ext pointer's word left with the old allocation: the array keeps + * its ext in an allocation of the word alone. */ + char *start = scr_mem_alloc(SCR_ARR_EXT_PREFIX); + if (!start) scr_arr_oom(); + memcpy(start, &x, sizeof x); + a->data = (uint64_t *)(start + SCR_ARR_EXT_PREFIX); + a->spare |= SCR_ARR_HAS_EXT; + x->sparse = NULL; + x->sparse_len = 0; + x->sparse_cap = 0; } return old; } @@ -910,7 +1038,7 @@ static void scr_arr_free_storage(ScrArrStorage *s) { * operations move ownership with the slots and move the states alongside * them; they never inspect an uninitialized hole payload. */ static bool scr_arr_is_dense(const ScrArr *a) { - return a->len <= a->cap && SCR_AX(a)->sparse_len == 0; + return a->len <= a->cap && (!(a->spare & SCR_ARR_HAS_EXT) || scr_arr_ext_of(a)->sparse_len == 0); } static void scr_arr_move_dense(ScrArr *a, size_t to, size_t from, size_t count) { @@ -1025,9 +1153,11 @@ static void scr_arr_copy_dense(ScrArr *dst, size_t to, const ScrArr *src, } bool refs = scr_elem_is_ref(src->elem); if (!refs && !materialize_holes) return; + uint8_t elem = src->elem; + const ScrArrOps *ops = scr_arr_ops(src); for (size_t i = to; i < to + count; i++) { if (*scr_arr_state_in(dst_states, i) == SCR_ARR_VALUE) { - if (refs) dst->data[i] = scr_elem_retain_slot(src, dst->data[i]); + if (refs) dst->data[i] = scr_elem_retain_with(elem, ops, dst->data[i]); } else if (materialize_holes && *scr_arr_state_in(dst_states, i) == SCR_ARR_HOLE) { scr_arr_dense_put(dst, i, 0); *scr_arr_state_in(dst_states, i) = SCR_ARR_UNDEFINED; @@ -1060,9 +1190,7 @@ static inline __attribute__((always_inline)) ScrArr *scr_arr_make(ScrElemKind el a->cyc = 0; a->spare = 0; a->filled = 0; - a->ops = NULL; a->data = NULL; - a->ext = NULL; if (inline_cap) scr_arr_use_inline(a, inline_cap); else if (initial_cap > 0) scr_arr_grow_dense_to(a, initial_cap, SCR_ARR_GROW_EXACT); #ifdef SCR_RC_AUDIT @@ -1094,9 +1222,7 @@ ScrArr *scr_arr_new_inline(ScrElemKind elem, size_t cap) { a->cyc = 0; a->spare = 0; a->filled = 0; - a->ops = NULL; a->data = NULL; - a->ext = NULL; scr_arr_use_inline(a, cap); #ifdef SCR_RC_AUDIT scr_live_arrays++; @@ -1159,20 +1285,25 @@ void scr_arr_trace_v(void *a0, ScrTraceVisit visit, void *ctx) { } /* Frees the out-of-line storage; callers check for it first, since most - * arrays have none. */ + * arrays have none. The word that pointed at it stays in the dense + * allocation until that is freed. */ static void scr_arr_ext_free(ScrArr *a) { - ScrArrExt *x = a->ext; + ScrArrExt *x = scr_arr_ext_of(a); scr_arr_sparse_free(x->sparse); for (size_t i = 0; i < x->prop_len; i++) free(x->props[i].key); free(x->props); scr_mem_free(x); - a->ext = NULL; +} + +/* Frees the dense allocation and the out-of-line storage of a dying array. */ +static inline __attribute__((always_inline)) void scr_arr_free_parts(ScrArr *a) { + if (a->spare & SCR_ARR_HAS_EXT) scr_arr_ext_free(a); + if (!scr_arr_inline(a)) scr_mem_free(scr_arr_alloc_start(a)); } static void scr_arr_gc_free(void *a0) { ScrArr *a = (ScrArr *)a0; - if (!scr_arr_inline(a)) scr_mem_free(scr_arr_block(a)); - if (a->ext) scr_arr_ext_free(a); + scr_arr_free_parts(a); #ifdef SCR_RC_AUDIT scr_live_arrays--; #endif @@ -1199,9 +1330,8 @@ static ScrArr *scr_arr_make_ops(const ScrArrOps *ops, size_t initial_cap, bool s a->cyc = cyc; a->spare = 0; a->filled = 0; - a->ops = ops; a->data = NULL; - a->ext = NULL; + scr_arr_set_ops(a, ops); if (inline_cap) scr_arr_use_inline(a, inline_cap); else if (initial_cap > 0) scr_arr_grow_dense_to(a, initial_cap, SCR_ARR_GROW_EXACT); #ifdef SCR_RC_AUDIT @@ -1218,9 +1348,9 @@ ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops) { return scr_arr_make_ops(op static void scr_arr_destroy(void *object) { ScrArr *a = object; - if (a->ext) { - scr_dyn_release(a->ext->metadata); - a->ext->metadata = NULL; + if (scr_arr_ext_of(a)) { + scr_dyn_release(scr_arr_ext_of(a)->metadata); + scr_arr_ext_of(a)->metadata = NULL; } if (scr_elem_is_ref(a->elem)) { size_t end = a->len < a->cap ? a->len : a->cap; @@ -1243,30 +1373,32 @@ static void scr_arr_destroy(void *object) { } } else { uint8_t *states = scr_arr_states_end(a); + uint8_t elem = a->elem; + const ScrArrOps *ops = scr_arr_ops(a); for (size_t i = 0; i < end; i++) { if (*scr_arr_state_in(states, i) != SCR_ARR_HOLE) { uint64_t old = a->data[i]; uint8_t state = *scr_arr_state_in(states, i); *scr_arr_state_in(states, i) = 0; a->data[i] = 0; - if (state == SCR_ARR_VALUE) scr_elem_release(a, old); + if (state == SCR_ARR_VALUE) scr_elem_release_with(elem, ops, old); } } } - if (a->ext) { - while (a->ext->sparse_len > 0) { - size_t i = a->ext->sparse_len - 1; - uint64_t old = a->ext->sparse[i].slot; - uint8_t state = a->ext->sparse[i].state; - a->ext->sparse_len = i; + if (scr_arr_ext_of(a)) { + while (scr_arr_ext_of(a)->sparse_len > 0) { + size_t i = scr_arr_ext_of(a)->sparse_len - 1; + uint64_t old = scr_arr_ext_of(a)->sparse[i].slot; + uint8_t state = scr_arr_ext_of(a)->sparse[i].state; + scr_arr_ext_of(a)->sparse_len = i; if (state == SCR_ARR_VALUE) scr_elem_release(a, old); } - while (a->ext->prop_len > 0) { - size_t i = a->ext->prop_len - 1; - uint64_t old = a->ext->props[i].slot; - uint8_t state = a->ext->props[i].state; - free(a->ext->props[i].key); - a->ext->prop_len = i; + while (scr_arr_ext_of(a)->prop_len > 0) { + size_t i = scr_arr_ext_of(a)->prop_len - 1; + uint64_t old = scr_arr_ext_of(a)->props[i].slot; + uint8_t state = scr_arr_ext_of(a)->props[i].state; + free(scr_arr_ext_of(a)->props[i].key); + scr_arr_ext_of(a)->prop_len = i; if (state == SCR_ARR_VALUE) scr_elem_release(a, old); } } @@ -1274,8 +1406,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(scr_arr_block(a)); - if (a->ext) scr_arr_ext_free(a); + scr_arr_free_parts(a); #ifdef SCR_RC_AUDIT scr_live_arrays--; #endif @@ -1303,7 +1434,10 @@ bool scr_arr_has(const ScrArr *a, double i) { return scr_arr_slot_at(a, idx, NULL); } -double scr_arr_state(const ScrArr *a, double i) { +/* Emitted element reads call this on their cold path. It stays out of line + * so that, under link-time optimization, those reads stay small enough to + * inline where they are used. */ +__attribute__((noinline)) double scr_arr_state(const ScrArr *a, double i) { size_t idx; if (!scr_arr_valid_index(i, &idx)) { uint8_t state; @@ -1336,6 +1470,14 @@ static void scr_arr_invalid_length(double length) { scr_throw_error_msg(SCR_ERR_RANGE, msg, sizeof msg - 1); } +static void scr_arr_reserve_exact(ScrArr *a, size_t need); + +/* A length set on an empty array without storage of its own (`new + * Array(n)`, Array.prototype.map's result) reserves exactly that many + * slots, as holes, up to this many; the writes that fill the array then + * need no geometric growth. */ +#define SCR_ARR_PRESIZE_LIMIT ((size_t)1 << 16) + void scr_arr_set_len(ScrArr *a, double length) { if (!(length >= 0) || !isfinite(length) || length != trunc(length) || length > (double)SCR_ARR_MAX_LENGTH) { @@ -1359,13 +1501,13 @@ void scr_arr_set_len(ScrArr *a, double length) { if (state == SCR_ARR_VALUE) scr_elem_release(a, old); } } - if (a->ext) { - size_t keep = scr_arr_sparse_lower_bound(a->ext->sparse, a->ext->sparse_len, next); - while (a->ext->sparse_len > keep) { - size_t i = a->ext->sparse_len - 1; - uint64_t old = a->ext->sparse[i].slot; - uint8_t state = a->ext->sparse[i].state; - a->ext->sparse_len = i; + if (scr_arr_ext_of(a)) { + size_t keep = scr_arr_sparse_lower_bound(scr_arr_ext_of(a)->sparse, scr_arr_ext_of(a)->sparse_len, next); + while (scr_arr_ext_of(a)->sparse_len > keep) { + size_t i = scr_arr_ext_of(a)->sparse_len - 1; + uint64_t old = scr_arr_ext_of(a)->sparse[i].slot; + uint8_t state = scr_arr_ext_of(a)->sparse[i].state; + scr_arr_ext_of(a)->sparse_len = i; if (state == SCR_ARR_VALUE) scr_elem_release(a, old); } } @@ -1375,8 +1517,10 @@ void scr_arr_set_len(ScrArr *a, double length) { memset(scr_arr_dense_at(a, dense_stop), 0, (dense_end - dense_stop) * scr_arr_slot_width(a)); } - if (a->ext) a->ext->sparse_len = scr_arr_sparse_lower_bound(a->ext->sparse, a->ext->sparse_len, next); + if (scr_arr_ext_of(a)) scr_arr_ext_of(a)->sparse_len = scr_arr_sparse_lower_bound(scr_arr_ext_of(a)->sparse, scr_arr_ext_of(a)->sparse_len, next); } + } else if (a->len == 0 && next <= SCR_ARR_PRESIZE_LIMIT && (a->cap == 0 || scr_arr_inline(a))) { + scr_arr_reserve_exact(a, next); } a->len = next; } @@ -1709,7 +1853,7 @@ void *scr_arr_get_ref(ScrArr *a, double i) { if (!p) return NULL; if (a->elem == SCR_ELEM_STR) scr_str_retain((ScrStr *)p); else if (a->elem == SCR_ELEM_BYTES) scr_bytes_retain((ScrBytes *)p); - else if (a->elem == SCR_ELEM_REF) p = a->ops->retain(p); + else if (a->elem == SCR_ELEM_REF) p = scr_arr_ops(a)->retain(p); else scr_arr_retain((ScrArr *)p); return p; } @@ -1903,6 +2047,33 @@ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src) { return SCR_ARR_LEN_F64(a->len); } +/* Dense capacity of exactly `need` (rounded to the storage block) for an + * array whose final length is known: a fresh concat result, a spread + * literal, or a length set on an empty array. Arrays with sparse entries, + * and lengths past the dense cutoff, keep their ordinary growth. */ +static void scr_arr_reserve_exact(ScrArr *a, size_t need) { + if (need <= a->cap || need > SCR_ARR_DENSE_LIMIT || SCR_AX(a)->sparse_len != 0) return; + scr_arr_grow_dense_to(a, need, SCR_ARR_GROW_EXACT); +} + +double scr_arr_concat_copy_exact(ScrArr *a, const ScrArr *src) { + if (src != a && scr_arr_is_dense(src) && src->len <= SCR_ARR_MAX_LENGTH - a->len) + scr_arr_reserve_exact(a, (size_t)a->len + src->len); + return scr_arr_concat_copy(a, src); +} + +void scr_arr_reserve_spread(ScrArr *a, const ScrArr *src, size_t plain) { + if (src == a || !scr_arr_is_dense(src)) return; + size_t need = (size_t)a->len + src->len; + if (plain > SCR_ARR_DENSE_LIMIT || need > SCR_ARR_DENSE_LIMIT) return; + scr_arr_reserve_exact(a, need + plain); +} + +void scr_arr_reserve_len(ScrArr *a, double n) { + if (!(n >= 1) || n > (double)SCR_ARR_DENSE_LIMIT) return; + scr_arr_reserve_exact(a, (size_t)n); +} + /* ── unshift / reverse ────────────────────────────────────────────────── * Single-element unshift takes ownership, matching push. The emitter calls * it from right to left after evaluating every variadic argument, preserving @@ -2193,7 +2364,7 @@ ScrArr *scr_arr_splice(ScrArr *a, double start, double deleteCount) { size_t hint = scr_arr_is_dense(a) || n <= SCR_ARR_DENSE_LIMIT ? n : SCR_ARR_DENSE_LIMIT; ScrArr *out = a->elem == SCR_ELEM_REF - ? scr_arr_new_ops(a->ops, hint ? hint : 1) + ? scr_arr_new_ops(scr_arr_ops(a), hint ? hint : 1) : scr_arr_new(a->elem, hint ? hint : 1); out->len = n; size_t old_len = a->len; @@ -2273,7 +2444,7 @@ ScrArr *scr_arr_splice_insert(ScrArr *a, double start, double deleteCount, ScrArr *snapshot = items == a ? scr_arr_slice(a, 0, INFINITY) : NULL; if (snapshot) items = snapshot; ScrArr *removed = a->elem == SCR_ELEM_REF - ? scr_arr_new_ops(a->ops, n) + ? scr_arr_new_ops(scr_arr_ops(a), n) : scr_arr_new(a->elem, n); if (n) { memcpy(removed->data, scr_arr_dense_at(a, from), n * scr_arr_slot_width(a)); @@ -2319,7 +2490,7 @@ void scr_arr_splice_drop(ScrArr *a, double start, double deleteCount, size_t cou bool refs = scr_elem_is_ref(a->elem); if (!scr_arr_is_dense(a) || !scr_arr_dense_ok(a, next_len, count)) { ScrArr *items = a->elem == SCR_ELEM_REF - ? scr_arr_new_ops(a->ops, count) + ? scr_arr_new_ops(scr_arr_ops(a), count) : scr_arr_new(a->elem, count); scr_arr_push_many(items, count, slots); scr_arr_release(scr_arr_splice_insert(a, start, deleteCount, items)); @@ -2400,10 +2571,10 @@ ScrArr *scr_arr_flat_copy(const ScrArr *a, ScrArr *out, bool flatten) { static bool scr_arr_ref_eq(const ScrArr *a, uint64_t slot, void *v) { void *p = scr_slot_to_ptr(slot); if (a->elem == SCR_ELEM_STR) return scr_str_eq((ScrStr *)p, (ScrStr *)v); - if (a->elem == SCR_ELEM_REF && a->ops->retain == scr_dyn_retain_v) { + if (a->elem == SCR_ELEM_REF && scr_arr_ops(a)->retain == scr_dyn_retain_v) { return scr_dyn_strict_eq((ScrDyn *)p, (ScrDyn *)v); } - if (a->elem == SCR_ELEM_REF && a->ops->retain == scr_closure_retain_v) + if (a->elem == SCR_ELEM_REF && scr_arr_ops(a)->retain == scr_closure_retain_v) return scr_closure_identity_equal((ScrClosure *)p, (ScrClosure *)v); return p == v; } @@ -2461,7 +2632,7 @@ bool scr_arr_includes_bool(ScrArr *a, bool v) { } bool scr_arr_includes_ref(ScrArr *a, void *v) { - if (a->elem == SCR_ELEM_REF && a->ops->retain == scr_dyn_retain_v) { + if (a->elem == SCR_ELEM_REF && scr_arr_ops(a)->retain == scr_dyn_retain_v) { const ScrDyn *needle = (const ScrDyn *)v; if (needle->kind == SCR_DYN_NUM && needle->v.num != needle->v.num) { const uint8_t *states = scr_arr_states_end(a); @@ -2525,7 +2696,7 @@ ScrArr *scr_arr_slice(ScrArr *a, double start, double end) { size_t hint = to <= a->cap || n <= SCR_ARR_DENSE_LIMIT ? n : SCR_ARR_DENSE_LIMIT; ScrArr *out = a->elem == SCR_ELEM_REF - ? scr_arr_new_ops(a->ops, hint ? hint : 1) + ? scr_arr_new_ops(scr_arr_ops(a), hint ? hint : 1) : scr_arr_new(a->elem, hint ? hint : 1); out->len = n; if (to <= a->cap && to <= a->filled && a->elem != SCR_ELEM_BOOL && !scr_elem_is_ref(a->elem)) { @@ -2828,7 +2999,7 @@ static ScrSortCompare scr_sort_compare_for(const ScrArr *a, ScrClosure *f, uint3 if (a->elem == SCR_ELEM_STR) c.retain = scr_sort_retain_str; else if (a->elem == SCR_ELEM_ARR) c.retain = scr_sort_retain_arr; else if (a->elem == SCR_ELEM_BYTES) c.retain = scr_sort_retain_bytes; - else if (a->elem == SCR_ELEM_REF) c.retain = a->ops->retain; + else if (a->elem == SCR_ELEM_REF) c.retain = scr_arr_ops(a)->retain; else scr_trap("scriptc: invalid comparator sort element kind\n"); } return c; @@ -4441,6 +4612,8 @@ const uint64_t *scr_arr_dense_slots(const ScrArr *a, size_t *len) { * slots of `a` in place. */ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned, int32_t order, ScrClosure *key) { + uint8_t elem = a->elem; + const ScrArrOps *ops = scr_arr_ops(a); if (a->rc == SIZE_MAX || !scr_arr_dense_values(a)) return false; size_t n = a->len; if (n < 2) return true; @@ -4459,10 +4632,10 @@ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned, in if (!src) scr_arr_oom(); memcpy(src, a->data, n * sizeof(*src)); if (retained) - for (size_t i = 0; i < n; i++) src[i] = scr_elem_retain_slot(a, src[i]); + for (size_t i = 0; i < n; i++) src[i] = scr_elem_retain_with(elem, ops, src[i]); if (!scr_sort_slots(src, n, &c, true)) { if (retained) - for (size_t i = 0; i < n; i++) scr_elem_release(a, src[i]); + for (size_t i = 0; i < n; i++) scr_elem_release_with(elem, ops, src[i]); free(src); return true; } @@ -4470,7 +4643,7 @@ bool scr_arr_sort_dense(ScrArr *a, ScrClosure *f, uint32_t arity, bool owned, in /* The comparator published the array: the first write fails as in Node * (scr_throw_published_array's index write). */ if (retained) - for (size_t i = 0; i < n; i++) scr_elem_release(a, src[i]); + for (size_t i = 0; i < n; i++) scr_elem_release_with(elem, ops, src[i]); static const char assign[] = "Cannot assign to read only property '0' of object '[object Array]'"; static const char add[] = "Cannot add property 0, object is not extensible"; if (a->len > 0) scr_throw_error_msg(SCR_ERR_TYPE, assign, sizeof assign - 1); @@ -4617,16 +4790,103 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) { return out; } +bool scr_arr_numeric_key(const char *key, size_t len, double *out) { + char text[64]; + if (len == 0 || len >= sizeof text) return false; + memcpy(text, key, len); + text[len] = '\0'; + char *end; + double number = strtod(text, &end); + if (end != text + len) return false; + char spelled[64]; + if (scr_f64_to_str(number, spelled) != len || memcmp(spelled, key, len) != 0) return false; + size_t index; + if (scr_arr_valid_index(number, &index)) return false; + *out = number; + return true; +} + void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) { const ScrDyn *metadata = SCR_AX(a)->metadata; if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE && scr_arr_match_copy(a, target)) return; - if (!SCR_AX(a)->metadata) return; - for (size_t i = 0; i < SCR_AX(a)->metadata->v.obj.len; i++) { - ScrDynEntry *entry = &SCR_AX(a)->metadata->v.obj.entries[i]; + metadata = SCR_AX(a)->metadata; + if (!metadata) return; + for (size_t i = 0; i < metadata->v.obj.len; i++) { + const ScrDynEntry *entry = &metadata->v.obj.entries[i]; ScrStr *key = scr_str_new(entry->key, entry->key_len); - scr_dyn_key_set(target, key, entry->value); + if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) { + scr_dyn_key_set(target, key, entry->value); + } else { + /* Properties defined through a view keep their attributes. */ + ScrDyn *name = scr_dyn_new_str(key); + ScrDyn *descriptor = scr_dyn_own_descriptor(metadata, key); + if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor)); + scr_dyn_release(descriptor); + scr_dyn_release(name); + } scr_str_release(key); } } + +ScrArr *scr_arr_named_keys(ScrArr *a) { + /* Most arrays have no out-of-line storage: they share one immortal empty + * result, which `for...in` only reads. */ + static ScrArr none = {.rc = SIZE_MAX, .elem = SCR_ELEM_STR}; + const ScrArrExt *x = scr_arr_ext_of(a); + if (!x) return &none; + ScrArr *keys = scr_arr_new(SCR_ELEM_STR, 0); + for (size_t i = 0; i < x->prop_len; i++) + scr_arr_push_ref(keys, scr_str_new(x->props[i].key, x->props[i].key_len)); + const ScrDyn *metadata = x->metadata; + if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE) { + static const char *const names[] = {"index", "input", "groups"}; + for (size_t i = 0; i < 3; i++) scr_arr_push_ref(keys, scr_str_new(names[i], strlen(names[i]))); + return keys; + } + if (!metadata || metadata->kind != SCR_DYN_OBJ) return keys; + for (size_t i = 0; i < metadata->v.obj.len; i++) { + const ScrDynEntry *entry = &metadata->v.obj.entries[i]; + if (!entry->enumerable || scr_arr_prop_index(a, entry->key, entry->key_len) != SIZE_MAX) continue; + scr_arr_push_ref(keys, scr_str_new(entry->key, entry->key_len)); + } + return keys; +} + +bool scr_arr_has_named_key(ScrArr *a, ScrStr *key) { + const ScrArrExt *x = scr_arr_ext_of(a); + if (!x) return false; + if (scr_arr_prop_index(a, key->data, key->len) != SIZE_MAX) return true; + const ScrDyn *metadata = x->metadata; + if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE) + return true; /* index, input and groups, the only keys a pending result lists */ + if (!metadata || metadata->kind != SCR_DYN_OBJ) return false; + for (size_t i = 0; i < metadata->v.obj.len; i++) { + const ScrDynEntry *entry = &metadata->v.obj.entries[i]; + if (entry->key_len == key->len && memcmp(entry->key, key->data, key->len) == 0) return true; + } + return false; +} + +void scr_arr_adopt_view_properties(ScrArr *a, ScrDyn *view, ScrArr *target) { + ScrArrExt *from = scr_arr_ext_of(target); + if (from && from->prop_len) { + /* Both arrays have the same element type, so the slots keep their owner. */ + ScrArrExt *x = scr_arr_ext(a); + ScrArrProp *props = x->props; + size_t len = x->prop_len, cap = x->prop_cap; + x->props = from->props; + x->prop_len = from->prop_len; + x->prop_cap = from->prop_cap; + from->props = props; + from->prop_len = len; + from->prop_cap = cap; + } + ScrDyn *named = view && view->kind == SCR_DYN_ARR ? view->v.arr.properties : NULL; + if (!named || named->v.obj.len == 0) return; + ScrArrExt *x = scr_arr_ext(a); + ScrDyn *old = x->metadata; + x->metadata = scr_dyn_retain(named); + scr_dyn_release(old); +} diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 1678d1568..6c41b3dbd 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -60,6 +60,9 @@ typedef struct ScrArrOps { void *(*retain)(void *); void (*release)(void *); ScrTraceFn trace; + /* Registry id (scr_arr_ops_table), 0 until scr_arr_ops_register assigns + * it on the first array of the type. Descriptors are writable for it. */ + uint32_t id; } ScrArrOps; typedef struct ScrArr { @@ -78,7 +81,10 @@ 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). */ + /* The low SCR_ARR_OPS_SHIFT bits: storage flags (SCR_ARR_INLINE_SLOTS, + * SCR_ARR_HEAD_ROOM, SCR_ARR_UNSHIFTED, SCR_ARR_HAS_EXT). The rest: the + * registry id of the element type's ops (scr_arr_ops), 0 for every kind + * but SCR_ELEM_REF. */ 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 @@ -87,17 +93,15 @@ typedef struct ScrArr { * undefined) or move states below it lowers it first (scr_arr_cut); * 0 is always valid. */ uint32_t filled; - /* SCR_ELEM_REF only; NULL for every other element kind. */ - const ScrArrOps *ops; /* Dense values: `cap` 8-byte slots, or `cap` bytes for SCR_ELEM_BOOL * (scr_arr_slot_width), followed in the same allocation by one state byte * per slot in reverse slot order (scr_arr_states_end). Sparse entries and properties keep 8-byte - * slots for every kind; a bool slot is 0 or 1 wherever it lives. */ + * slots for every kind; a bool slot is 0 or 1 wherever it lives. Rarely + * used storage (sparse indices, noncanonical numeric properties, + * match-result metadata) lives out of line, and an array that has it + * (SCR_ARR_HAS_EXT) keeps a pointer to it in the word before the dense + * allocation (scr_arr_ext_of). */ uint64_t *data; - /* Rarely used storage (sparse indices, noncanonical numeric properties, - * match-result metadata) lives out of line: NULL until first needed, so - * the common dense array stays small. */ - struct ScrArrExt *ext; } ScrArr; typedef struct ScrArrExt { @@ -113,11 +117,14 @@ typedef struct ScrArrExt { * scr_regex.c), and match_index is the result's UTF-16 start index. */ struct ScrDyn *metadata; double match_index; + /* The element ops of an array whose ops have no registry id + * (SCR_ARR_OPS_OVERFLOW). */ + const ScrArrOps *ops; } ScrArrExt; /* The LLVM backend mirrors ScrArr as %ScrArr: its inline retain/release read * `cyc` (field 4, i8) to decide mark-live / candidate buffering exactly like * scr_arr_retain and scr_arr_release; its inline element access uses len and - * cap (fields 1 and 2, i32), filled (field 6, i32) and data (field 8), and + * cap (fields 1 and 2, i32), filled (field 6, i32) and data (field 7), and * finds the state of slot i at data + cap * (slot width + 1) - 1 - i. */ _Static_assert(offsetof(ScrArr, len) == sizeof(size_t) && sizeof(((ScrArr *)0)->len) == 4, "emitted length reads load ScrArr.len as %ScrArr field 1, an i32"); @@ -127,10 +134,68 @@ _Static_assert(offsetof(ScrArr, cyc) == sizeof(size_t) + 9, "inline RC fast paths read ScrArr.cyc as %ScrArr field 4"); _Static_assert(offsetof(ScrArr, filled) == sizeof(size_t) + 12, "inline element access reads ScrArr.filled as %ScrArr field 6"); -_Static_assert(offsetof(ScrArr, data) == sizeof(size_t) + 16 + sizeof(void *), - "inline element reads use ScrArr.data as %ScrArr field 8"); -_Static_assert(sizeof(ScrArr) == sizeof(size_t) + 16 + 3 * sizeof(void *), - "ScrArr is 48 bytes on 64-bit targets"); +_Static_assert(offsetof(ScrArr, data) == sizeof(size_t) + 16, + "inline element reads use ScrArr.data as %ScrArr field 7"); +_Static_assert(sizeof(ScrArr) == sizeof(size_t) + 16 + sizeof(void *), + "ScrArr is 32 bytes on 64-bit targets"); + +/* Storage flags in `spare`. Inline slots: the dense slots live in the + * array's own block, after the header. Head room: `data` has advanced past + * vacated slots of its allocation, and the slot before `data` holds their + * count. Unshifted: an unshift had to move every element. Has ext: the word + * before the dense allocation (head room included) points at the array's + * ScrArrExt; such an array always has an allocation of its own, without + * inline slots. */ +#define SCR_ARR_INLINE_SLOTS 1u +#define SCR_ARR_HEAD_ROOM 2u +#define SCR_ARR_UNSHIFTED 4u +#define SCR_ARR_HAS_EXT 8u + +/* The start of the dense slots' allocation: `data` less any head room. */ +static inline uint64_t *scr_arr_block(const ScrArr *a) { + return (a->spare & SCR_ARR_HEAD_ROOM) ? a->data - (size_t)a->data[-1] : a->data; +} + +/* Bytes before the dense allocation of an array with out-of-line storage: + * its pointer, in a word that keeps the 8-byte slots aligned on every + * target. */ +#define SCR_ARR_EXT_PREFIX sizeof(uint64_t) + +/* The out-of-line storage of `a`, or NULL while it has none. */ +static inline struct ScrArrExt *scr_arr_ext_of(const ScrArr *a) { + if (!(a->spare & SCR_ARR_HAS_EXT)) return NULL; + struct ScrArrExt *x; + memcpy(&x, (const char *)scr_arr_block(a) - SCR_ARR_EXT_PREFIX, sizeof x); + return x; +} + +/* Element ops by registry id: `spare >> SCR_ARR_OPS_SHIFT` of an array. + * Id 0 is no ops; ids are assigned on each descriptor's first array, in + * any thread. A program with more element types than the field holds keeps + * the ops of the rest in the array's ext (SCR_ARR_OPS_OVERFLOW). */ +#define SCR_ARR_OPS_SHIFT 4 +#define SCR_ARR_OPS_OVERFLOW (UINT16_MAX >> SCR_ARR_OPS_SHIFT) +extern const ScrArrOps *scr_arr_ops_table[SCR_ARR_OPS_OVERFLOW]; +/* Assigns the id on a descriptor's first array; out of line, so the id + * load in scr_arr_ops_id stays the only cost on later arrays. */ +__attribute__((cold, noinline)) uint32_t scr_arr_ops_register(ScrArrOps *ops); + +/* The registry id an array of element ops `ops` stores (0 for NULL). */ +static inline uint32_t scr_arr_ops_id(const ScrArrOps *ops) { + if (!ops) return 0; + uint32_t id = __atomic_load_n(&ops->id, __ATOMIC_ACQUIRE); + return id ? id : scr_arr_ops_register((ScrArrOps *)ops); +} + +/* The element ops of an array whose ops have no registry id. */ +const ScrArrOps *scr_arr_ops_overflow(const ScrArr *a); + +/* The element ops of `a`: NULL for every kind but SCR_ELEM_REF. */ +static inline const ScrArrOps *scr_arr_ops(const ScrArr *a) { + uint32_t id = (uint32_t)a->spare >> SCR_ARR_OPS_SHIFT; + if (__builtin_expect(id == SCR_ARR_OPS_OVERFLOW, 0)) return scr_arr_ops_overflow(a); + return scr_arr_ops_table[id]; +} /* Bytes per dense value slot. */ static inline size_t scr_arr_slot_width(const ScrArr *a) { @@ -252,6 +317,22 @@ static inline ScrArr *scr_arr_retain(ScrArr *a) { * the candidate-root buffer like every headered type. */ void scr_arr_release(ScrArr *a); void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target); +/* The commit of a live array view calls this on `a`, the array converted + * from `view`, before `target` takes its storage: `a` takes the view's named + * (non-index) properties as its metadata and the noncanonical numeric + * properties of `target`, so neither is lost when the storage is swapped. */ +void scr_arr_adopt_view_properties(ScrArr *a, struct ScrDyn *view, ScrArr *target); +/* True when `key` is the string spelling of a number that is not an array + * index ("-1", "1.5", "NaN", "Infinity"), with the number in *out. Arrays + * keep such properties in their noncanonical property table. */ +bool scr_arr_numeric_key(const char *key, size_t len, double *out); +/* Whether the array still has the named or noncanonical numeric property + * `key`: `for...in` skips a key deleted before its turn. Borrows both. */ +bool scr_arr_has_named_key(ScrArr *a, ScrStr *key); +/* The enumerable string keys `for...in` visits after the indices: the + * noncanonical numeric properties, then the named properties in insertion + * order. +1. */ +ScrArr *scr_arr_named_keys(ScrArr *a); /* Installed by scr_regex.c when it records a pending match source; this * unit links without the regex runtime. Writes the pending metadata of * `a` into its dynamic copy or materializes it, answering whether it @@ -377,6 +458,15 @@ double scr_arr_unshift_many(ScrArr *a, size_t count, const uint64_t *slots); double scr_arr_push_spread(ScrArr *a, const ScrArr *src); /* Append a concat argument by indexed property copy, preserving holes. */ double scr_arr_concat_copy(ScrArr *a, const ScrArr *src); +/* The same append for Array.prototype.concat's fresh result: dense storage + * grows to exactly the combined length instead of geometrically. */ +double scr_arr_concat_copy_exact(ScrArr *a, const ScrArr *src); +/* Exact dense capacity for an array literal about to copy `src` and then + * `plain` more elements (a spread position). */ +void scr_arr_reserve_spread(ScrArr *a, const ScrArr *src, size_t plain); +/* Exact dense capacity for `n` elements of an array that is filled to that + * length next (Array.from({ length: n })); no effect for other lengths. */ +void scr_arr_reserve_len(ScrArr *a, double n); /* unshift returns the new length and _ref takes ownership. The spread form * borrows a same-element-kind source, retains copied refs, and snapshots diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index ec4dad794..a6cd813de 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -1274,7 +1274,7 @@ static void scr_dyn_all_result(ScrPromise *destination, ScrPromise *source) { scr_promise_fulfill_ref(destination, result, scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); } -static const ScrArrOps scr_dyn_arr_ops = { scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v }; +static ScrArrOps scr_dyn_arr_ops = { scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v }; /* ── Thenable adoption (PromiseResolve over a checked-dynamic value) ── * A non-promise object whose `then` is callable resolves through diff --git a/packages/runtime/src/scr_bytes_io.c b/packages/runtime/src/scr_bytes_io.c index eb3661fc6..5532d96da 100644 --- a/packages/runtime/src/scr_bytes_io.c +++ b/packages/runtime/src/scr_bytes_io.c @@ -6,8 +6,8 @@ * exact source lists). */ #include "scr_runtime.h" -static const ScrArrOps scr_bytes_io_str_ops = { scr_str_retain_v, scr_str_release_v, NULL }; -static const ScrArrOps scr_bytes_io_bytes_ops = { scr_bytes_retain_v, scr_bytes_release_v, NULL }; +static ScrArrOps scr_bytes_io_str_ops = { scr_str_retain_v, scr_str_release_v, NULL }; +static ScrArrOps scr_bytes_io_bytes_ops = { scr_bytes_retain_v, scr_bytes_release_v, NULL }; #include #include diff --git a/packages/runtime/src/scr_copying.c b/packages/runtime/src/scr_copying.c index 6864a6262..df4da1738 100644 --- a/packages/runtime/src/scr_copying.c +++ b/packages/runtime/src/scr_copying.c @@ -23,7 +23,7 @@ static bool copying_elem_is_ref(ScrElemKind kind) { static ScrArr *copying_arr_new_like(const ScrArr *a, size_t cap) { ScrArr *out = a->elem == SCR_ELEM_REF - ? scr_arr_new_ops(a->ops, cap ? cap : 1) + ? scr_arr_new_ops(scr_arr_ops(a), cap ? cap : 1) : scr_arr_new(a->elem, cap ? cap : 1); out->len = cap; return out; @@ -35,7 +35,7 @@ static uint64_t copying_arr_retain_slot(const ScrArr *a, uint64_t slot) { if (a->elem == SCR_ELEM_STR) p = scr_str_retain((ScrStr *)p); else if (a->elem == SCR_ELEM_ARR) p = scr_arr_retain((ScrArr *)p); else if (a->elem == SCR_ELEM_BYTES) p = scr_bytes_retain((ScrBytes *)p); - else p = a->ops->retain(p); + else p = scr_arr_ops(a)->retain(p); return copying_slot_from_ptr(p); } diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index ce4dfa5fa..591f544b8 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -991,7 +991,8 @@ static inline ScrCycKind *scr_cyc_kind(uint32_t kind) { /* Whether a candidate of this type at level `gen` (nursery or mature) * skips that level's passes (see above). The last candidate a bypass sends * past the level opens a probe window, with fresh counts. */ -static inline bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { +static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycType *type, + unsigned gen) { uint32_t kind = type->kind; if (kind >= scr_cyc_kinds_cap) return false; ScrCycLevelStat *k = &scr_cyc_kinds[kind].level[gen]; @@ -1006,8 +1007,10 @@ static inline bool scr_cyc_bypassed(const ScrCycType *type, unsigned gen) { } /* The generation a release buffers an object into: its own, or past the - * levels its type bypasses. */ -static inline unsigned scr_cyc_buffer_gen(const ScrCycType *type, unsigned gen) { + * levels its type bypasses. Both run once per candidate that the routing + * below moves, so they are inlined there too. */ +static inline __attribute__((always_inline)) unsigned scr_cyc_buffer_gen(const ScrCycType *type, + unsigned gen) { if (gen == SCR_CYC_NURSERY && scr_cyc_bypassed(type, SCR_CYC_NURSERY)) gen = SCR_CYC_MATURE; if (gen == SCR_CYC_MATURE && scr_cyc_bypassed(type, SCR_CYC_MATURE)) gen = SCR_CYC_OLD; return gen; @@ -1236,13 +1239,17 @@ void scr_cyc_on_release(void *obj) { } /* Visit every child of `obj` with VISIT, through ARRAY_WALK for a dense - * cycle-capable array and through the type's trace otherwise. */ + * cycle-capable array and through the type's trace otherwise. An empty + * dense array has no children to visit, and ARRAY_WALK is not called. */ #define SCR_CYC_CHILDREN(obj, type, ARRAY_WALK, VISIT, depth) \ - do { \ - if ((type)->kind == SCR_CYC_KIND_ARRAY && ((const ScrArr *)(obj))->ext == NULL) \ - ARRAY_WALK((const ScrArr *)(obj), (void *)(depth)); \ - else \ - (type)->trace((obj), (VISIT), (void *)(depth)); \ + do { \ + if ((type)->kind == SCR_CYC_KIND_ARRAY && \ + !(((const ScrArr *)(obj))->spare & SCR_ARR_HAS_EXT)) { \ + if (((const ScrArr *)(obj))->len != 0) \ + ARRAY_WALK((const ScrArr *)(obj), (void *)(depth)); \ + } else { \ + (type)->trace((obj), (VISIT), (void *)(depth)); \ + } \ } while (0) static void scr_mg_visit(void *child, void *ctx); @@ -1253,12 +1260,23 @@ SCR_CYC_ARRAY_WALK(scr_sb_array, scr_sb_visit) SCR_CYC_ARRAY_WALK(scr_scan_array, scr_scan_visit) static inline __attribute__((always_inline)) void scr_mark_gray(void *obj, uintptr_t depth); +/* An edge into a generation this pass does not walk, for settle. */ +static __attribute__((noinline)) void scr_mg_skip_edge(void *child) { + scr_cyc_push_work(&scr_xgen_edges, scr_gray_holder); + scr_cyc_push_work(&scr_xgen_edges, child); +} +/* The first visit of an object: gray it and walk its children. */ +static __attribute__((noinline)) void scr_mg_first(void *child, void *ctx) { + scr_mark_gray(child, (uintptr_t)ctx); +} +/* Most edges end at an object that is skipped or gray already. Those + * return without a stack frame; the other two cases tail-call out of + * line. */ static void scr_mg_visit(void *child, void *ctx) { if (child == NULL || SCR_RC(child) == SCR_RC_IMMORTAL) return; ScrCycHdr *h = scr_cyc_hdr(child); if (SCR_CYC_WALK_GEN(h) > scr_gen_limit) { - scr_cyc_push_work(&scr_xgen_edges, scr_gray_holder); - scr_cyc_push_work(&scr_xgen_edges, child); + scr_mg_skip_edge(child); return; } size_t rc = --SCR_RC(child); @@ -1269,7 +1287,7 @@ static void scr_mg_visit(void *child, void *ctx) { } return; } - scr_mark_gray(child, (uintptr_t)ctx); + scr_mg_first(child, ctx); } /* markGray reaches `obj` (its count already trial-decremented): gray it, * number it with the walk's component, record it, count it while it is @@ -1436,6 +1454,25 @@ static SCR_CYC_COMPACT void scr_xg_release(void *obj) { scr_rc_destroy(obj, scr_xg_destroy); } +/* A work vector that holds more than this many entries is freed at the end + * of a pass that used less than a quarter of it, so a spike in one pass + * does not keep its buffers for the rest of the program, while passes of a + * steady size keep reusing theirs. */ +#define SCR_CYC_KEEP_VECTOR 65536 + +static SCR_CYC_COMPACT void scr_cyc_trim_free(ScrVec *vec) { + free(vec->v); + vec->v = NULL; + vec->cap = 0; +} +/* Every pass ends by trimming its work vectors, and most of them stay + * small: only the capacity test runs inline. */ +static inline void scr_cyc_trim(ScrVec *vec) { + if (vec->cap > SCR_CYC_KEEP_VECTOR && vec->n == 0 && vec->used < vec->cap / 4) + scr_cyc_trim_free(vec); + vec->used = 0; +} + #ifdef SCR_YIN /* ── young-in nursery ─────────────────────────────────────────────────── */ @@ -1488,7 +1525,6 @@ static SCR_CYC_COMPACT void scr_xg_release(void *obj) { * deletion reaches the survivor from the root's candidate; garbage formed * after the promotion has a candidate from the release that formed it, * since survivors are no longer young. */ -static void scr_cyc_trim(ScrVec *vec); #define SCR_YIN_DEFAULT_SIZE 4096 #define SCR_CYC_YIN_PROMOTED SCR_CYC_NURSERY @@ -1928,21 +1964,6 @@ static SCR_CYC_COMPACT size_t scr_cyc_settle(unsigned gen_limit, bool doomed) { return freed; } -/* A work vector that holds more than this many entries is freed at the end - * of a pass that used less than a quarter of it, so a spike in one pass - * does not keep its buffers for the rest of the program, while passes of a - * steady size keep reusing theirs. */ -#define SCR_CYC_KEEP_VECTOR 65536 - -static SCR_CYC_COMPACT void scr_cyc_trim(ScrVec *vec) { - if (vec->n == 0 && vec->cap > SCR_CYC_KEEP_VECTOR && vec->used < vec->cap / 4) { - free(vec->v); - vec->v = NULL; - vec->cap = 0; - } - vec->used = 0; -} - /* The end of every pass. The candidate list keeps its count, which the * scheduler reads; its buffer goes when this pass's candidates filled * less than a quarter of it. */ diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 0783ff705..02c4d828f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -892,6 +892,11 @@ void scr_dyn_typed_ref_bind_record(ScrDyn *d, const ScrDynTypedRecordOps *ops); void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *names, const size_t *lens, size_t count); bool scr_record_source_has(const void *rec); +/* The commit of a live record view converts the view into a new record + * `from`, whose conversion keeps the view's undeclared properties, then + * moves its fields into `to`: this moves the kept properties to `to` too, + * replacing any `to` had. */ +void scr_record_source_move(void *from, void *to); ScrDyn *scr_record_source_view(const void *rec); ScrDyn *scr_record_source_apply(ScrDyn *snapshot, const void *rec); /* Attach the prototype member names of a class instance capsule @@ -997,6 +1002,14 @@ ScrDyn *scr_dyn_get_own_property_descriptor(ScrDyn *target, ScrDyn *key); ScrDyn *scr_dyn_array_proto_call(ScrDyn *target, ScrStr *method, ScrDyn *args); ScrDyn *scr_dyn_array_prototype(void); /* +1, installs native method adapters */ ScrDyn *scr_dyn_array_prototype_base(void); /* +1, shared native array identity */ +/* Names the prototype object of a compiled class `name` for util.inspect + * (Node prints the constructor's name), unless it already has a name. */ +void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name); +/* Native arrays read their holes as undefined without consulting + * Array.prototype, so storing an element on Array.prototype raises SC2020. + * True (with the error pending) when `recv` is Array.prototype. */ +bool scr_dyn_array_prototype_element_fence(const ScrDyn *recv); +bool scr_dyn_is_array_prototype(const ScrDyn *d); /* The checked-dynamic keyed WRITE (`h.k = v` on a dyn receiver): OBJ sets * the member (JS: later writes win, insertion order); undefined/null and * non-object kinds throw Node's catchable TypeErrors (strict-mode diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index ffe1d0330..2a010f213 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -811,6 +811,10 @@ static ScrDyn *scr_dyn_invoke_impl( if (recv->kind == SCR_DYN_HANDLE) { return scr_dynh_dispatch(recv, method, args, argc, what); } + if (scr_dyn_is_array_prototype(recv) && dyn_arr_proto_mutates(method)) { + scr_dyn_array_prototype_element_fence(recv); + return NULL; + } if (recv->kind == SCR_DYN_PROXY) { diff --git a/packages/runtime/src/scr_error.c b/packages/runtime/src/scr_error.c index 4f514681b..b974474bf 100644 --- a/packages/runtime/src/scr_error.c +++ b/packages/runtime/src/scr_error.c @@ -409,19 +409,26 @@ SCR_TL ScrError *scr_event_error_payload = NULL; /* The code Node attaches to the runtime's event-error messages whose text * carries no errno name: connResetException's "socket hang up" and - * "aborted", the HTTP parser's header overflow, and the TLS verification - * failures (the openssl reason Node reports as the code). NULL for any + * "aborted", the TLS client's disconnect before the handshake completes, + * the HTTP parser's header overflow, and the TLS verification failures (the openssl reason Node reports as the code). NULL for any * other message. */ const char *scr_error_message_code(const ScrStr *msg) { static const struct { const char *prefix; bool whole; const char *code; } known[] = { { "socket hang up", true, "ECONNRESET" }, { "aborted", true, "ECONNRESET" }, + { "Client network socket disconnected before secure TLS connection was established", true, + "ECONNRESET" }, { "Parse Error: Header overflow", true, "HPE_HEADER_OVERFLOW" }, { "certificate has expired", true, "CERT_HAS_EXPIRED" }, { "self-signed certificate in certificate chain", true, "SELF_SIGNED_CERT_IN_CHAIN" }, { "self-signed certificate", false, "DEPTH_ZERO_SELF_SIGNED_CERT" }, { "unable to verify the first certificate", false, "UNABLE_TO_VERIFY_LEAF_SIGNATURE" }, { "Hostname/IP does not match certificate's altnames", false, "ERR_TLS_CERT_ALTNAME_INVALID" }, + { "error:0A000460:SSL routines:ssl3_read_bytes:tlsv1 alert no application protocol", true, + "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL" }, + { "error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version", true, + "ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION" }, + { "error:0A0000BF:SSL routines::no protocols available", true, "ERR_SSL_NO_PROTOCOLS_AVAILABLE" }, }; for (size_t i = 0; i < sizeof known / sizeof known[0]; i++) { size_t n = strlen(known[i].prefix); diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c index c11b7897d..901e2d114 100644 --- a/packages/runtime/src/scr_events_emitter.c +++ b/packages/runtime/src/scr_events_emitter.c @@ -1352,7 +1352,7 @@ ScrArr *scr_emitter_event_names(ScrEmitter *em) { * The once wrapper is runtime-internal here, so BOTH forms answer the * original listeners (SEMANTICS.md documents the rawListeners * divergence). */ -static const ScrArrOps scr_ee_closure_arr_ops = { &scr_closure_retain_v, &scr_closure_release_v, +static ScrArrOps scr_ee_closure_arr_ops = { &scr_closure_retain_v, &scr_closure_release_v, &scr_closure_trace_v }; ScrArr *scr_emitter_listeners(ScrEmitter *em, ScrStr *name) { diff --git a/packages/runtime/src/scr_itable.c b/packages/runtime/src/scr_itable.c index 7b5202b81..a333aab95 100644 --- a/packages/runtime/src/scr_itable.c +++ b/packages/runtime/src/scr_itable.c @@ -474,11 +474,11 @@ static ScrArrState scr_itable_element(ScrITableElements *w, size_t i, uint64_t * *slot = a->data[i]; return (ScrArrState)*scr_arr_state_ptr(a, i); } - if (a->ext) { - while (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index < i) w->sparse++; - if (w->sparse < a->ext->sparse_len && a->ext->sparse[w->sparse].index == i) { - *slot = a->ext->sparse[w->sparse].slot; - return (ScrArrState)a->ext->sparse[w->sparse].state; + if (scr_arr_ext_of(a)) { + while (w->sparse < scr_arr_ext_of(a)->sparse_len && scr_arr_ext_of(a)->sparse[w->sparse].index < i) w->sparse++; + if (w->sparse < scr_arr_ext_of(a)->sparse_len && scr_arr_ext_of(a)->sparse[w->sparse].index == i) { + *slot = scr_arr_ext_of(a)->sparse[w->sparse].slot; + return (ScrArrState)scr_arr_ext_of(a)->sparse[w->sparse].state; } } return SCR_ARR_HOLE; diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d18ce85e4..54411bd54 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -311,9 +311,10 @@ void scr_jb_put_f64(ScrJsonBuf *b, double v) { /* Find the next quote, backslash or control byte. memcpy keeps the word * loads unaligned-safe, and the length guard never reads past the input. - * The masks only decide whether to inspect a word byte by byte: borrow - * propagation may mark extra lanes, so it must not determine a byte index. - * High UTF-8 bytes are ordinary data, independent of host byte order. */ + * Borrow propagation can mark extra lanes, but only above a lane that + * holds a special byte, so on little-endian hosts the lowest marked lane + * is the answer; other hosts inspect the marked word byte by byte. High + * UTF-8 bytes are ordinary data. */ static size_t scr_json_plain_bytes(const char *data, size_t len) { const uint64_t ones = UINT64_C(0x0101010101010101); const uint64_t high = UINT64_C(0x8080808080808080); @@ -326,9 +327,41 @@ static size_t scr_json_plain_bytes(const char *data, size_t len) { uint64_t special = ((quotes - ones) & ~quotes) | ((slashes - ones) & ~slashes) | ((word - ones * 0x20) & ~word); - if (special & high) break; + special &= high; + if (special) { +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + /* A borrow only marks lanes above a lane that really matched, so + * the lowest marked lane is the first special byte. */ + return i + (size_t)__builtin_ctzll(special) / 8; +#else + break; +#endif + } i += sizeof word; } + /* The last 1..7 bytes as one word: two overlapping 4-byte loads, or the + * first, middle and last byte of a shorter rest with the remaining lanes + * filled with a plain byte. A plain rest (the common case: most strings + * are short) ends the scan; otherwise the bytes are inspected one by one. */ + size_t rest = len - i; + if (rest) { + uint64_t word; + if (rest >= 4) { + uint32_t lo, hi; + memcpy(&lo, data + i, sizeof lo); + memcpy(&hi, data + len - 4, sizeof hi); + word = (uint64_t)lo | (uint64_t)hi << 32; + } else { + word = (uint64_t)(unsigned char)data[i] | (uint64_t)(unsigned char)data[i + rest / 2] << 8 | + (uint64_t)(unsigned char)data[len - 1] << 16 | UINT64_C(0x4141414141000000); + } + uint64_t quotes = word ^ (ones * '"'); + uint64_t slashes = word ^ (ones * '\\'); + uint64_t special = ((quotes - ones) & ~quotes) | + ((slashes - ones) & ~slashes) | + ((word - ones * 0x20) & ~word); + if (!(special & high)) return len; + } while (i < len) { unsigned char c = (unsigned char)data[i]; if (c < 0x20 || c == '"' || c == '\\') break; @@ -674,6 +707,17 @@ void scr_dyn_from_leave(void) { */ static SCR_TL ScrDyn *scr_builtin_array_prototype; +bool scr_dyn_is_array_prototype(const ScrDyn *d) { + return d && d == scr_builtin_array_prototype; +} + +bool scr_dyn_array_prototype_element_fence(const ScrDyn *recv) { + if (!scr_dyn_is_array_prototype(recv)) return false; + static const char message[] = "Elements on Array.prototype are not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); + return true; +} + /* Property names are immutable and often repeated across simultaneously live * objects. Entries own references to their bytes; this fixed weak table owns * none. Collisions only reduce sharing, and the final release removes a weak @@ -2680,20 +2724,24 @@ bool scr_record_source_has(const void *rec) { return scr_record_source_count && scr_record_source_find(rec) != NULL; } -static void scr_record_source_disposed(void *rec) { - size_t bit = scr_record_source_filter_bit(rec); - if (!(scr_record_source_filter[bit >> 6] & (UINT64_C(1) << (bit & 63)))) return; - ScrRecordSource *found = scr_record_source_find(rec); - if (!found) return; +/* Drops the entry at `found` from the table, releasing its view. The table + * no longer refers to the view first, so a nested disposal that re-enters + * the dispose hook sees a consistent table. */ +static void scr_record_source_drop(ScrRecordSource *found) { ScrDyn *view = found->view; *found = (ScrRecordSource){ .rec = SCR_RECORD_SOURCE_DELETED }; scr_record_source_count--; scr_record_source_deleted++; - /* The table no longer refers to the view, so a nested disposal that - * re-enters this hook sees a consistent table. */ scr_dyn_release(view); } +static void scr_record_source_disposed(void *rec) { + size_t bit = scr_record_source_filter_bit(rec); + if (!(scr_record_source_filter[bit >> 6] & (UINT64_C(1) << (bit & 63)))) return; + ScrRecordSource *found = scr_record_source_find(rec); + if (found) scr_record_source_drop(found); +} + /* The single native dispose hook serves weak collections and the table * above; each observer runs only while it has entries to forget. */ static void scr_dispose_observers(void *object) { @@ -2834,6 +2882,20 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam scr_record_source_put(&probe); } +void scr_record_source_move(void *from, void *to) { + if (!scr_record_source_count) return; + ScrRecordSource *old = scr_record_source_find(to); + if (old) scr_record_source_drop(old); + ScrRecordSource *found = scr_record_source_find(from); + if (!found) return; + ScrRecordSource moved = *found; + *found = (ScrRecordSource){ .rec = SCR_RECORD_SOURCE_DELETED }; + scr_record_source_count--; + scr_record_source_deleted++; + moved.rec = to; + scr_record_source_put(&moved); +} + /* Refreshes a retained record view: the view's own-key order, declared * values from the native snapshot (a declared key the record no longer has * drops), every other property as the view holds it, then declared fields @@ -2917,6 +2979,44 @@ static bool scr_jb_put_source_field(ScrJsonBuf *b, void *rec, const ScrRecordSou /* The same in one lookup: writes the record and answers true when it kept * a source, otherwise writes nothing and answers false. */ +static bool scr_dyn_key_is_index(const char *key, size_t len, double *out); + +typedef struct { + double index; + size_t entry; +} ScrRecordSourceIntegerKey; + +static int scr_record_source_integer_cmp(const void *a, const void *b) { + double x = ((const ScrRecordSourceIntegerKey *)a)->index; + double y = ((const ScrRecordSourceIntegerKey *)b)->index; + return (x > y) - (x < y); +} + +/* The entry indexes of `view` in JavaScript's own-key order: integer keys + * ascending, then the other keys in insertion order. `out` holds len. */ +static void scr_record_source_key_order(const ScrDyn *view, size_t *out) { + size_t len = view->v.obj.len; + ScrRecordSourceIntegerKey small[64]; + ScrRecordSourceIntegerKey *keys = len <= 64 ? small : malloc(len * sizeof *keys); + if (!keys) scr_json_oom(); + size_t integers = 0, next = 0; + for (size_t i = 0; i < len; i++) { + const ScrDynEntry *entry = &view->v.obj.entries[i]; + double index; + if (scr_dyn_key_is_index(entry->key, entry->key_len, &index)) + keys[integers++] = (ScrRecordSourceIntegerKey){ index, i }; + } + /* Integer keys are distinct, so the order is total. */ + qsort(keys, integers, sizeof *keys, scr_record_source_integer_cmp); + for (size_t i = 0; i < integers; i++) out[next++] = keys[i].entry; + for (size_t i = 0; i < len; i++) { + const ScrDynEntry *entry = &view->v.obj.entries[i]; + double index; + if (!scr_dyn_key_is_index(entry->key, entry->key_len, &index)) out[next++] = i; + } + if (keys != small) free(keys); +} + bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, bool (*field)(ScrJsonBuf *, void *, size_t, bool)) { ScrRecordSource *found = scr_record_source_find(rec); @@ -2937,13 +3037,24 @@ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, bool small[64] = { false }; bool *seen = count <= 64 ? small : calloc(count, sizeof *seen); if (!seen) scr_json_oom(); + /* Integer keys come first in JavaScript's own-key order; a view without + * any walks in insertion order. */ + size_t order_small[64]; + size_t *order = NULL; + for (size_t k = 0; k < view->v.obj.len && !order; k++) { + double index; + if (!scr_dyn_key_is_index(view->v.obj.entries[k].key, view->v.obj.entries[k].key_len, &index)) continue; + order = view->v.obj.len <= 64 ? order_small : malloc(view->v.obj.len * sizeof *order); + if (!order) scr_json_oom(); + scr_record_source_key_order(view, order); + } /* 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]; + const ScrDynEntry *entry = &view->v.obj.entries[order ? order[i] : 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)) @@ -2963,7 +3074,7 @@ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, 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]; + const ScrDynEntry *entry = &view->v.obj.entries[order ? order[j] : 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)); @@ -2988,6 +3099,7 @@ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, first = false; } if (seen != small) free(seen); + if (order && order != order_small) free(order); scr_dyn_release(view); scr_jb_putc(b, '}'); if (entered) scr_jb_leave(b); @@ -4508,7 +4620,6 @@ bool scr_dyn_same_value(const ScrDyn *left, const ScrDyn *right) { static void scr_dyn_descriptor_fields_drop(ScrDyn **fields) { for (size_t i = 0; i < 6; i++) scr_dyn_release(fields[i]); } -static bool scr_dyn_key_is_index(const char *key, size_t len, double *out); static void scr_error_sync_cause(ScrDyn *view, const ScrStr *key); static void scr_error_sync_slots(const ScrDyn *view, ScrDyn *obj, const ScrStr *key); @@ -4625,6 +4736,24 @@ static void scr_dyn_named_prototypes_cleanup(void) { scr_dyn_named_count = scr_dyn_named_cap = 0; } +/* Stamps prototype `p` with a copy of `name` (len bytes) and keeps it alive. */ +static void scr_dyn_stamp_prototype_name(ScrDyn *p, const char *name, size_t len) { + if (scr_dyn_named_count == scr_dyn_named_cap) { + if (scr_dyn_named_cap == 0) scr_atexit(scr_dyn_named_prototypes_cleanup); + size_t cap = scr_dyn_named_cap ? scr_dyn_named_cap * 2 : 8; + ScrDynNamedPrototype *grown = realloc(scr_dyn_named_prototypes, cap * sizeof *grown); + if (!grown) scr_json_oom(); + scr_dyn_named_prototypes = grown; + scr_dyn_named_cap = cap; + } + char *copy = malloc(len + 1); + if (!copy) scr_json_oom(); + memcpy(copy, name, len); + copy[len] = '\0'; + p->v.obj.class_name = copy; + scr_dyn_named_prototypes[scr_dyn_named_count++] = (ScrDynNamedPrototype){ scr_dyn_retain(p), copy }; +} + static void scr_dyn_name_prototype_chain(const ScrDyn *object) { for (ScrDyn *p = object->prototype; p; p = p->prototype) { if (p->kind != SCR_DYN_OBJ || p->v.obj.class_name) return; @@ -4632,24 +4761,16 @@ static void scr_dyn_name_prototype_chain(const ScrDyn *object) { if (!entry || entry->accessor || !entry->value || entry->value->kind != SCR_DYN_FUNC) continue; const char *name = entry->value->v.fn.name; if (!name || !name[0]) continue; - if (scr_dyn_named_count == scr_dyn_named_cap) { - if (scr_dyn_named_cap == 0) scr_atexit(scr_dyn_named_prototypes_cleanup); - size_t cap = scr_dyn_named_cap ? scr_dyn_named_cap * 2 : 8; - ScrDynNamedPrototype *grown = realloc(scr_dyn_named_prototypes, cap * sizeof *grown); - if (!grown) scr_json_oom(); - scr_dyn_named_prototypes = grown; - scr_dyn_named_cap = cap; - } - size_t len = strlen(name); - char *copy = malloc(len + 1); - if (!copy) scr_json_oom(); - memcpy(copy, name, len + 1); - p->v.obj.class_name = copy; - scr_dyn_named_prototypes[scr_dyn_named_count++] = (ScrDynNamedPrototype){ scr_dyn_retain(p), copy }; + scr_dyn_stamp_prototype_name(p, name, strlen(name)); return; } } +void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) { + if (prototype->kind != SCR_DYN_OBJ || prototype->v.obj.class_name || name->len == 0) return; + scr_dyn_stamp_prototype_name(prototype, name->data, name->len); +} + /* Ordinary native function constructors retain their live prototype table. * A constructor may replace the fresh receiver with another object. */ ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) { @@ -8527,6 +8648,7 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) { else idx = idx * 10 + (size_t)(key->data[i] - '0'); } if (is_index && idx < 4294967295ULL) { + if (scr_dyn_array_prototype_element_fence(recv)) return; if (recv->v.arr.frozen && idx < recv->v.arr.len) { static const char message[] = "Cannot assign to read only property"; scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); @@ -8996,6 +9118,11 @@ typedef struct { ScrDynRegion *region; /* tags completed containers (ScrDynRegion); may be NULL */ struct ScrJsonShape *shapes; /* key-sequence transitions (scr_json_object) */ uint32_t nshapes, shape_cap; + /* Schema records whose plain field names are known (scr_jschema_plain): + * bit i of the mask is set when field i's name holds no byte JSON text + * would escape. */ + const struct ScrJsonSchema *plain_schema[4]; + uint64_t plain_mask[4]; } ScrJsonP; static void scr_json_throw(const ScrJsonP *p, const char *msg) { @@ -9003,7 +9130,10 @@ static void scr_json_throw(const ScrJsonP *p, const char *msg) { scr_throw_error_msg(SCR_ERR_SYNTAX, msg, strlen(msg)); } -static void scr_json_throw_pos(const ScrJsonP *p, const char *what, size_t pos) { +/* Errors stay out of line so the scanners that report them keep their + * registers for the common path. */ +static __attribute__((noinline, cold)) void scr_json_throw_pos(const ScrJsonP *p, const char *what, + size_t pos) { if (p->quiet) return; char buf[128]; int n = snprintf(buf, sizeof buf, "%s in JSON at position %zu", what, pos); @@ -9024,11 +9154,15 @@ static void scr_json_throw_token(const ScrJsonP *p) { } static void scr_json_ws(ScrJsonP *p) { - while (p->pos < p->len) { - char c = p->s[p->pos]; - if (c == ' ' || c == '\t' || c == '\n' || c == '\r') p->pos++; + /* A local cursor, as in scr_json_number_value. */ + const char *s = p->s; + size_t pos = p->pos; + while (pos < p->len) { + char c = s[pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++; else break; } + p->pos = pos; } /* Append the WTF-8 encoding of a code point or lone surrogate unit; a low @@ -9179,7 +9313,14 @@ static const double scr_json_pow10[23] = { * (thrown unless quiet). Shared by the dyn tree and the schema parser so * both produce bit-identical values. */ static bool scr_json_number_value(ScrJsonP *p, double *out) { - size_t start = p->pos; + /* The cursor and text stay in locals: the runtime builds without strict + * aliasing, so a byte read through p->s could alias p->pos, and a loop + * on p->pos would store it on every digit. p->pos is written once, when + * the token ends. */ + const char *s = p->s; + const size_t len = p->len; + const size_t start = p->pos; + size_t pos = start; /* Grammar validation and value accumulation in one pass. Clinger's fast * path: with at most 15 significant digits the mantissa is exact in a * double, and scaling by an exact power of ten (|exp| <= 22) rounds @@ -9188,35 +9329,37 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { int ndig = 0; /* significant digits folded into mant */ int exp10 = 0; /* decimal exponent (fraction shift + explicit exponent) */ bool neg = false, precise = true; - if (p->s[p->pos] == '-') { + if (s[pos] == '-') { neg = true; - p->pos++; - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "No number after minus sign", p->pos); + pos++; + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "No number after minus sign", pos); return false; } } - if (p->s[p->pos] == '0') { - p->pos++; + if (s[pos] == '0') { + pos++; } else { - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { if (ndig < 15) { - mant = mant * 10 + (uint64_t)(p->s[p->pos] - '0'); + mant = mant * 10 + (uint64_t)(s[pos] - '0'); ndig++; } else { precise = false; } - p->pos++; + pos++; } } - if (p->pos < p->len && p->s[p->pos] == '.') { - p->pos++; - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "Unterminated fractional number", p->pos); + if (pos < len && s[pos] == '.') { + pos++; + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "Unterminated fractional number", pos); return false; } - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { - unsigned digit = (unsigned)(p->s[p->pos] - '0'); + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { + unsigned digit = (unsigned)(s[pos] - '0'); if (mant == 0 && digit == 0) { exp10--; /* leading fractional zeros scale without a digit */ } else if (ndig < 15) { @@ -9226,29 +9369,33 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { } else { precise = false; } - p->pos++; + pos++; } } - if (p->pos < p->len && (p->s[p->pos] == 'e' || p->s[p->pos] == 'E')) { - p->pos++; + if (pos < len && (s[pos] == 'e' || s[pos] == 'E')) { + pos++; bool eneg = false; - if (p->pos < p->len && (p->s[p->pos] == '+' || p->s[p->pos] == '-')) { - eneg = p->s[p->pos] == '-'; - p->pos++; + if (pos < len && (s[pos] == '+' || s[pos] == '-')) { + eneg = s[pos] == '-'; + pos++; } - if (p->pos >= p->len || p->s[p->pos] < '0' || p->s[p->pos] > '9') { - scr_json_throw_pos(p, "Exponent part is missing a number", p->pos); + if (pos >= len || s[pos] < '0' || s[pos] > '9') { + p->pos = pos; + scr_json_throw_pos(p, "Exponent part is missing a number", pos); return false; } int ev = 0; - while (p->pos < p->len && p->s[p->pos] >= '0' && p->s[p->pos] <= '9') { - if (ev < 100000) ev = ev * 10 + (p->s[p->pos] - '0'); - p->pos++; + while (pos < len && s[pos] >= '0' && s[pos] <= '9') { + if (ev < 100000) ev = ev * 10 + (s[pos] - '0'); + pos++; } exp10 += eneg ? -ev : ev; } + p->pos = pos; if (precise && exp10 >= -22 && exp10 <= 22) { - double v = (double)mant; /* exact: mant < 10^15 < 2^53 */ + /* exact: mant < 10^15 < 2^53; the signed conversion is one + * instruction on x86-64, the unsigned one a sequence */ + double v = (double)(int64_t)mant; if (exp10 > 0) v *= scr_json_pow10[exp10]; else if (exp10 < 0) v /= scr_json_pow10[-exp10]; *out = neg ? -v : v; @@ -9258,7 +9405,7 @@ static bool scr_json_number_value(ScrJsonP *p, double *out) { * grammar above is a strict subset of what strtod accepts). The ScrStr * data is NUL-terminated, and strtod stops at the first non-number char, * so parsing from `start` reads exactly the validated token. */ - *out = strtod(p->s + start, NULL); + *out = strtod(s + start, NULL); return true; } @@ -9855,26 +10002,38 @@ static __attribute__((noinline)) bool scr_jschema_absent(const ScrJsonSchema *s, 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, - * take the general key scan. */ +/* Bit i set when field i's name (at most eight bytes) holds no quote, + * backslash or control byte, so text that spells it unescaped is exactly + * that key. Names are schema constants: each record schema computes its + * mask once per parse, in a four-entry table. */ +static uint64_t scr_jschema_plain(ScrJsonP *p, const ScrJsonSchema *s) { + size_t slot = ((uintptr_t)s >> 4) & 3; + if (p->plain_schema[slot] == s) return p->plain_mask[slot]; + uint64_t mask = 0; + for (size_t i = 0; i < s->nfields && i < 64; i++) { + const ScrJsonSchemaField *f = &s->fields[i]; + bool plain = f->name_len > 0 && f->name_len <= 8; + for (size_t k = 0; plain && k < f->name_len; k++) { + unsigned char c = (unsigned char)f->name[k]; + plain = c >= 0x20 && c != '"' && c != '\\'; + } + if (plain) mask |= (uint64_t)1 << i; + } + p->plain_schema[slot] = s; + p->plain_mask[slot] = mask; + return mask; +} + +/* The quoted key at p->pos when it is exactly `f`'s name, a plain name of + * at most eight bytes (scr_jschema_plain): the bytes consumed (name + * length + 2), else 0. Other names take the general key scan. */ static inline size_t scr_jschema_key_direct(const ScrJsonP *p, const ScrJsonSchemaField *f) { size_t len = f->name_len; size_t at = p->pos + 1; - if (len == 0 || len > 8 || len >= p->len - at) return 0; + if (len >= p->len - at) return 0; const char *text = p->s + at; - uint64_t word = scr_key_short_word(f->name, len); - if (text[len] != '"' || scr_key_short_word(text, len) != word) return 0; - /* The packed word holds every name byte (lengths one to three use its - * low three bytes). A quote, backslash or control byte sends the key to - * the general scan, as in scr_json_plain_bytes. */ - const uint64_t ones = UINT64_C(0x0101010101010101), high = UINT64_C(0x8080808080808080); - uint64_t quotes = word ^ (ones * '"'), slashes = word ^ (ones * '\\'); - uint64_t special = ((quotes - ones) & ~quotes) | ((slashes - ones) & ~slashes) | - ((word - ones * 0x20) & ~word); - uint64_t bytes = len >= 4 ? ~UINT64_C(0) : UINT64_C(0xffffff); - return (special & high & bytes) ? 0 : len + 2; + if (text[len] != '"' || scr_key_short_word(text, len) != scr_key_short_word(f->name, len)) return 0; + return len + 2; } /* An undeclared member of a record with an index signature, after its @@ -10002,6 +10161,7 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot const uint64_t all = n == 64 ? ~(uint64_t)0 : (((uint64_t)1 << n) - 1); uint64_t seen = 0; size_t guess = 0; + const uint64_t plain = scr_jschema_plain(p, s); scr_json_ws(p); if (p->pos < p->len && p->s[p->pos] == '}') { p->pos++; @@ -10013,7 +10173,8 @@ static bool scr_jschema_record(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot /* Members usually arrive in declaration order: try the successor of * the previous match, directly against the text, before scanning. */ size_t idx = n; - size_t direct = guess < n ? scr_jschema_key_direct(p, &s->fields[guess]) : 0; + size_t direct = + guess < n && (plain >> guess & 1) ? scr_jschema_key_direct(p, &s->fields[guess]) : 0; if (direct) { idx = guess; p->pos += direct; @@ -10092,13 +10253,21 @@ static bool scr_jschema_array(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot * out->ptr = arr; return true; } + /* Elements gather in a local run and append in bulk (scr_arr_push_many, + * which moves references in as scr_arr_push_ref does). */ + uint64_t run[32]; + size_t n = 0; for (;;) { ScrJsonSlot v; if (!scr_jschema_member(p, elem, &v)) goto fail; switch (elem->kind) { - case SCR_JSCHEMA_F64: scr_arr_push_f64(arr, v.f); break; - case SCR_JSCHEMA_BOOL: scr_arr_push_bool(arr, v.b); break; - default: scr_arr_push_ref(arr, v.ptr); break; /* moves in */ + case SCR_JSCHEMA_F64: memcpy(&run[n++], &v.f, sizeof v.f); break; + case SCR_JSCHEMA_BOOL: run[n++] = v.b ? 1 : 0; break; + default: run[n++] = (uint64_t)(uintptr_t)v.ptr; break; + } + if (n == sizeof run / sizeof run[0]) { + scr_arr_push_many(arr, n, run); + n = 0; } scr_json_ws(p); if (p->pos >= p->len) goto fail; @@ -10108,10 +10277,12 @@ static bool scr_jschema_array(ScrJsonP *p, const ScrJsonSchema *s, ScrJsonSlot * } if (p->s[p->pos] != ']') goto fail; p->pos++; + scr_arr_push_many(arr, n, run); out->ptr = arr; return true; } fail: + scr_arr_push_many(arr, n, run); /* the array releases what it holds */ s->release(arr); return false; } @@ -10269,6 +10440,40 @@ void scr_dyn_key_delete(ScrDyn *recv, const ScrStr *key, bool strict) { } return; } + /* A live native array keeps the noncanonical numeric properties written + * natively ("-1", "1.5") in its property table, and named properties, + * including numeric-looking keys written through a view, in its metadata, + * which the view and its commit hold. Deleting such a key removes it from + * both. Elements and `length` keep the boundary below. */ + size_t element; + if (recv->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(recv) && + !scr_typed_ref_is_length(key) && !scr_typed_ref_canonical_index(key->data, key->len, &element)) { + double number; + bool numeric = scr_arr_numeric_key(key->data, key->len, &number); + if (recv->v.typed_ref.commit) { + ScrDyn *view = scr_dyn_typed_ref_materialize(recv); + if (!scr_exc_pending() && view->kind == SCR_DYN_ARR) { + ScrDynEntry *own = view->v.arr.properties ? scr_dyn_entry(view->v.arr.properties, key) : NULL; + /* A non-configurable property refuses the delete before either + * store changes. */ + if (own && !own->configurable) { + scr_dyn_key_delete(view, key, strict); + } else { + if (numeric) scr_arr_delete(recv->v.typed_ref.ptr, number); + if (own) scr_dyn_key_delete(view, key, strict); + if (own && !scr_exc_pending()) scr_dyn_typed_ref_commit(recv); + } + scr_dyn_release(view); + return; + } + scr_dyn_release(view); + if (scr_exc_pending()) return; + } + if (numeric) { + scr_arr_delete(recv->v.typed_ref.ptr, number); + return; + } + } if (recv->kind == SCR_DYN_FUNC) { if (scr_dyn_fn_property_fence(key)) return; ScrDyn *table = scr_dyn_fn_properties(recv); @@ -12445,7 +12650,7 @@ ScrDynEntryPairs *scr_dyn_obj_entry_pairs(const ScrDyn *v) { void scr_dyn_entry_pairs_free(ScrDynEntryPairs *pairs) { free(pairs); } -static const ScrArrOps scr_dyn_value_list_ops = {scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v}; +static ScrArrOps scr_dyn_value_list_ops = {scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v}; /* A native array of `count` owned dyn values, moved in with one dense push. * Frees `values` unless it is `stack`. */ diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index 3946331be..736eca540 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -520,7 +520,7 @@ static void test_item_release(void *ptr) { static ScrArr *test_tags_new(size_t cap) { return scr_arr_new(SCR_ELEM_STR, cap); } static ScrArr *test_list_new(size_t cap) { - static const ScrArrOps item_ops = { test_item_retain, test_item_release, NULL }; + static ScrArrOps item_ops = { test_item_retain, test_item_release, NULL }; return scr_arr_new_ops(&item_ops, cap); } static void test_arr_release(void *ptr) { scr_arr_release(ptr); } diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index db0926690..3441e6142 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -5788,7 +5788,7 @@ void scr_fs_symlink(ScrStr *target, ScrStr *destination, int kind) { if (!scr_fs_path_ok(target, "target")) return; if (!scr_fs_path_ok(destination, "path")) return; #ifdef _WIN32 - static const ScrArrOps str_ops = { scr_str_retain_v, scr_str_release_v, NULL }; + static ScrArrOps str_ops = { scr_str_retain_v, scr_str_release_v, NULL }; ScrArr *parts = scr_arr_new_ops(&str_ops, 3); scr_arr_push_ref(parts, scr_str_retain(destination)); scr_arr_push_ref(parts, scr_str_new("..", 2)); diff --git a/packages/runtime/src/scr_mailbox.c b/packages/runtime/src/scr_mailbox.c index 361a43791..a8dc7e5cc 100644 --- a/packages/runtime/src/scr_mailbox.c +++ b/packages/runtime/src/scr_mailbox.c @@ -25,11 +25,15 @@ #endif #endif +/* Event kinds below this have their queued events counted (scr_mailbox_count_kind). */ +#define SCR_MAILBOX_COUNTED_KINDS 16u + struct ScrMailbox { atomic_size_t references; ScrMailEvent *head; ScrMailEvent **tail; bool closed; + size_t queued[SCR_MAILBOX_COUNTED_KINDS]; /* under the lock */ /* head != NULL || closed, written under the lock, read without it by * scr_mailbox_wait's spin. */ atomic_bool ready; @@ -167,6 +171,7 @@ bool scr_mailbox_post(ScrMailbox *mailbox, ScrMailEvent *event) { event->next = NULL; *mailbox->tail = event; mailbox->tail = &event->next; + if (event->kind < SCR_MAILBOX_COUNTED_KINDS) mailbox->queued[event->kind]++; atomic_store_explicit(&mailbox->ready, true, memory_order_release); scr_mailbox_unlock(mailbox); /* Signaled after the unlock: a woken or spinning taker then finds the @@ -182,6 +187,7 @@ ScrMailEvent *scr_mailbox_take(ScrMailbox *mailbox) { if (event) { mailbox->head = event->next; event->next = NULL; + if (event->kind < SCR_MAILBOX_COUNTED_KINDS) mailbox->queued[event->kind]--; } if (!mailbox->head) { mailbox->tail = &mailbox->head; @@ -201,6 +207,7 @@ ScrMailEvent *scr_mailbox_take_kind(ScrMailbox *mailbox, unsigned kind, uint64_t *slot = event->next; if (mailbox->tail == &event->next) mailbox->tail = slot; event->next = NULL; + if (event->kind < SCR_MAILBOX_COUNTED_KINDS) mailbox->queued[event->kind]--; } if (!mailbox->head) { mailbox->tail = &mailbox->head; @@ -226,6 +233,20 @@ bool scr_mailbox_pending(ScrMailbox *mailbox) { return pending; } +unsigned scr_mailbox_head_kind(ScrMailbox *mailbox) { + scr_mailbox_lock(mailbox); + unsigned kind = mailbox->head ? mailbox->head->kind : 0; + scr_mailbox_unlock(mailbox); + return kind; +} + +size_t scr_mailbox_count_kind(ScrMailbox *mailbox, unsigned kind) { + scr_mailbox_lock(mailbox); + size_t count = kind < SCR_MAILBOX_COUNTED_KINDS ? mailbox->queued[kind] : 0; + scr_mailbox_unlock(mailbox); + return count; +} + /* A taker that finds the mailbox empty spins this long before it blocks. * Replies between threads usually arrive within a few microseconds, and a * blocked taker pays a sleep and a wakeup for each one. */ diff --git a/packages/runtime/src/scr_mailbox.h b/packages/runtime/src/scr_mailbox.h index 678d17723..4e5f253b5 100644 --- a/packages/runtime/src/scr_mailbox.h +++ b/packages/runtime/src/scr_mailbox.h @@ -26,6 +26,11 @@ ScrMailEvent *scr_mailbox_take_kind(ScrMailbox *mailbox, unsigned kind, uint64_t void scr_mail_event_free(ScrMailEvent *event); void scr_mailbox_close(ScrMailbox *mailbox); bool scr_mailbox_pending(ScrMailbox *mailbox); +/* The kind of the first queued event, or 0 when none is queued. */ +unsigned scr_mailbox_head_kind(ScrMailbox *mailbox); +/* The number of queued events of this kind, kept as events are posted and + * taken; 0 for kinds of 16 and above. */ +size_t scr_mailbox_count_kind(ScrMailbox *mailbox, unsigned kind); void scr_mailbox_wait(ScrMailbox *mailbox, double milliseconds); int scr_mailbox_pollfd(const ScrMailbox *mailbox); diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 5d4935ed0..99caa31a8 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -1558,7 +1558,7 @@ void scr_map_values_into(ScrMap *set, const ScrMap *source, bool checked) { } ScrArr *scr_set_to_arr_dyn(const ScrMap *map) { - static const ScrArrOps dyn_ops = { scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v }; + static ScrArrOps dyn_ops = { scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v }; ScrArr *out = scr_arr_new_ops(&dyn_ops, map->nlive); for (size_t i = 0; i < map->nentries; i++) { if (scr_map_iter_live(map, (double)i)) scr_arr_push_ref(out, scr_map_dyn_key(map, (double)i)); diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 025e13a51..675a13ca9 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -201,7 +201,7 @@ static void scr_pub_slot(ScrPub *ctx, uint64_t slot, ScrPubFn fn) { void scr_pub_arr(void *v, ScrPub *ctx, ScrPubFn elem) { ScrArr *a = v; - if (a->ext && (a->ext->props || a->ext->metadata)) { + if (scr_arr_ext_of(a) && (scr_arr_ext_of(a)->props || scr_arr_ext_of(a)->metadata)) { scr_pub_refuse(ctx, "Cannot publish an array with non-index properties"); return; } @@ -212,9 +212,9 @@ void scr_pub_arr(void *v, ScrPub *ctx, ScrPubFn elem) { const uint8_t *states = scr_arr_states_end(a); for (size_t i = 0; i < dense; i++) if (!states || *scr_arr_state_in(states, i)) scr_pub_slot(ctx, a->data[i], fn); - if (a->ext) - for (size_t i = 0; i < a->ext->sparse_len; i++) - if (a->ext->sparse[i].state) scr_pub_slot(ctx, a->ext->sparse[i].slot, fn); + if (scr_arr_ext_of(a)) + for (size_t i = 0; i < scr_arr_ext_of(a)->sparse_len; i++) + if (scr_arr_ext_of(a)->sparse[i].state) scr_pub_slot(ctx, scr_arr_ext_of(a)->sparse[i].slot, fn); } /* publish() of a scalar Node.js refuses (a Date): nothing to walk. */ diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index 73440c110..b62cba896 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -667,7 +667,7 @@ static void scr_regex_defer_metadata(ScrArr *row, ScrDyn *source, int index) { /* The pending match source of a result, or NULL once its metadata exists. */ static ScrRegexMatchSource *scr_regex_pending(const ScrArr *a) { - const ScrDyn *metadata = a->ext ? a->ext->metadata : NULL; + const ScrDyn *metadata = scr_arr_ext_of(a) ? scr_arr_ext_of(a)->metadata : NULL; if (!metadata || metadata->kind != SCR_DYN_HANDLE || metadata->v.handle.tag != SCR_DYNH_MATCH_SOURCE) return NULL; return metadata->v.handle.ptr; @@ -707,7 +707,7 @@ static ScrDyn *scr_regex_groups_of(const ScrArr *a, uint8_t *bc) { static void scr_regex_materialize_metadata(ScrArr *a) { ScrRegexMatchSource *src = scr_regex_pending(a); if (!src) return; - ScrArrExt *x = a->ext; + ScrArrExt *x = scr_arr_ext_of(a); ScrDyn *source = x->metadata; ScrDyn *props = scr_dyn_new_obj(); scr_dyn_obj_set(props, "index", 5, scr_dyn_new_num(x->match_index)); @@ -724,7 +724,7 @@ static bool scr_regex_copy_pending_metadata(ScrArr *a, ScrDyn *target) { scr_regex_materialize_metadata(a); return false; } - scr_dyn_arr_named_put(target, "index", 5, scr_dyn_new_num(a->ext->match_index)); + scr_dyn_arr_named_put(target, "index", 5, scr_dyn_new_num(scr_arr_ext_of(a)->match_index)); scr_dyn_arr_named_put(target, "input", 5, scr_regex_source_input(src)); scr_dyn_arr_named_put(target, "groups", 6, scr_regex_groups_of(a, scr_regex_bc(src->re))); return true; @@ -734,11 +734,11 @@ ScrDyn *scr_regex_result_prop(ScrArr *a, ScrStr *key) { ScrRegexMatchSource *src = scr_regex_pending(a); if (src) { if (key->len == 5 && memcmp(key->data, "index", 5) == 0) - return scr_dyn_new_num(a->ext->match_index); + return scr_dyn_new_num(scr_arr_ext_of(a)->match_index); if (key->len == 5 && memcmp(key->data, "input", 5) == 0) return scr_regex_source_input(src); scr_regex_materialize_metadata(a); } - ScrDyn *metadata = a->ext ? a->ext->metadata : NULL; + ScrDyn *metadata = scr_arr_ext_of(a) ? scr_arr_ext_of(a)->metadata : NULL; ScrDyn *value = metadata ? scr_dyn_obj_get(metadata, key->data, key->len) : NULL; return scr_dyn_retain(value ? value : scr_dyn_undefined()); } diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index 3a7dad541..dbd74f3ac 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -803,17 +803,17 @@ static void *scr_rgn_slot_ptr(uint64_t slot) { static void scr_rgn_walk_arr(ScrRgnWalk *w, ScrArr *a) { if (a->elem != SCR_ELEM_REF && a->elem != SCR_ELEM_ARR) return; ScrRgnSlotKind k = a->elem == SCR_ELEM_ARR ? (ScrRgnSlotKind){SCR_RGN_W_ARR, NULL} - : scr_rgn_slot_kind(a->ops ? a->ops->release : NULL); + : scr_rgn_slot_kind(scr_arr_ops(a) ? scr_arr_ops(a)->release : NULL); size_t dense = a->len < a->cap ? a->len : a->cap; const uint8_t *states = scr_arr_states_end(a); for (size_t i = 0; i < dense; i++) if (!states || *scr_arr_state_in(states, i) == SCR_ARR_VALUE) scr_rgn_slot_as(w, scr_rgn_slot_ptr(a->data[i]), k); - if (!a->ext) return; - for (size_t i = 0; i < a->ext->sparse_len; i++) - if (a->ext->sparse[i].state == SCR_ARR_VALUE) scr_rgn_slot_as(w, scr_rgn_slot_ptr(a->ext->sparse[i].slot), k); - for (size_t i = 0; i < a->ext->prop_len; i++) - if (a->ext->props[i].state == SCR_ARR_VALUE) scr_rgn_slot_as(w, scr_rgn_slot_ptr(a->ext->props[i].slot), k); - scr_rgn_enter(w, a->ext->metadata, SCR_RGN_W_DYN, NULL); + if (!scr_arr_ext_of(a)) return; + for (size_t i = 0; i < scr_arr_ext_of(a)->sparse_len; i++) + if (scr_arr_ext_of(a)->sparse[i].state == SCR_ARR_VALUE) scr_rgn_slot_as(w, scr_rgn_slot_ptr(scr_arr_ext_of(a)->sparse[i].slot), k); + for (size_t i = 0; i < scr_arr_ext_of(a)->prop_len; i++) + if (scr_arr_ext_of(a)->props[i].state == SCR_ARR_VALUE) scr_rgn_slot_as(w, scr_rgn_slot_ptr(scr_arr_ext_of(a)->props[i].slot), k); + scr_rgn_enter(w, scr_arr_ext_of(a)->metadata, SCR_RGN_W_DYN, NULL); } static void scr_rgn_walk_map(ScrRgnWalk *w, ScrMap *m) { diff --git a/packages/runtime/src/scr_region.test.c b/packages/runtime/src/scr_region.test.c index 31e98942a..ad1f92518 100644 --- a/packages/runtime/src/scr_region.test.c +++ b/packages/runtime/src/scr_region.test.c @@ -61,7 +61,7 @@ static void node_release(void *o) { if (c) c->count--; } -static const ScrArrOps node_ops = {node_retain, node_release, NULL}; +static ScrArrOps node_ops = {node_retain, node_release, NULL}; /* ── the "emitted" ordinary class (acyclic, plain heap block) ────────── */ diff --git a/packages/runtime/src/scr_stream.c b/packages/runtime/src/scr_stream.c index 7d7ec04ed..f7330b615 100644 --- a/packages/runtime/src/scr_stream.c +++ b/packages/runtime/src/scr_stream.c @@ -4420,7 +4420,7 @@ static ScrPromise *scr_sc_consume(ScrStream *s, int kind) { scr_promise_reject_pending(p); return p; } - static const ScrArrOps bytes_ops = { scr_bytes_retain_v, scr_bytes_release_v, NULL }; + static ScrArrOps bytes_ops = { scr_bytes_retain_v, scr_bytes_release_v, NULL }; ScrArr *chunks = scr_arr_new_ops(&bytes_ops, 4); ScrStreamErrInv fin_inv = kind == SCR_SC_TEXT ? &scr_sc_fin_text : kind == SCR_SC_JSON ? &scr_sc_fin_json diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 41588f25a..58272cf67 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -1769,14 +1769,9 @@ static inline double scr_to_integer_or_infinity(double x) { return isnan(x) ? 0.0 : x; } -/* Byte substring search (needle and hay are both canonical WTF-8, - * so any byte-level match starts on a char boundary — UTF-8 is - * self-synchronizing). Empty needle matches at hay. */ -static const char *scr_byte_find(const char *hay, size_t hay_len, - const char *nee, size_t nee_len) { - if (nee_len == 0) return hay; - if (nee_len > hay_len) return NULL; - if (nee_len == 1) return memchr(hay, (unsigned char)nee[0], hay_len); +/* scr_byte_find for a needle of at least two bytes, no longer than hay. */ +static __attribute__((noinline)) const char *scr_byte_find_long(const char *hay, size_t hay_len, + const char *nee, size_t nee_len) { const unsigned char first = (unsigned char)nee[0]; const unsigned char second = (unsigned char)nee[1]; /* The last possible match start. */ @@ -1826,6 +1821,18 @@ static const char *scr_byte_find(const char *hay, size_t hay_len, return NULL; } +/* Byte substring search (needle and hay are both canonical WTF-8, + * so any byte-level match starts on a char boundary — UTF-8 is + * self-synchronizing). Empty needle matches at hay. The one-byte case, + * every split on a separator character, goes to memchr without a frame. */ +static inline const char *scr_byte_find(const char *hay, size_t hay_len, + const char *nee, size_t nee_len) { + if (nee_len == 0) return hay; + if (nee_len > hay_len) return NULL; + if (nee_len == 1) return memchr(hay, (unsigned char)nee[0], hay_len); + return scr_byte_find_long(hay, hay_len, nee, nee_len); +} + /* ── searches for a needle that can match half of a surrogate pair ───── * Canonical storage makes byte matches exact for every needle except one * that starts with a lone low surrogate or ends with a lone high one: JS @@ -2020,6 +2027,13 @@ double scr_str_char_code_at(ScrStr *s, double i) { } double scr_str_index_of(ScrStr *s, ScrStr *needle, double fromIndex) { + /* A search from the start of a proven-ASCII receiver: byte offsets are + * UTF-16 indexes, and a needle with other bytes is never found. A start + * index that is not positive (NaN, -0 or negative) clamps to 0. */ + if (s->ascii && !(fromIndex > 0)) { + const char *found = scr_byte_find(s->data, s->len, needle->data, needle->len); + return found ? (double)(found - s->data) : -1.0; + } double pos = scr_to_integer_or_infinity(fromIndex); if (needle->len > s->len) return -1.0; ScrSidx *e = scr_sidx_for(s); @@ -2142,6 +2156,12 @@ static size_t scr_slice_boundary(double v, size_t len16) { } ScrStr *scr_str_slice(ScrStr *s, double start, double end) { + /* A proven-ASCII receiver needs no index map (scr_str_slice_units). */ + if (s->ascii) { + size_t from = scr_slice_boundary(scr_to_integer_or_infinity(start), s->len); + size_t to = scr_slice_boundary(scr_to_integer_or_infinity(end), s->len); + return scr_str_slice_units(s, NULL, from, to); + } ScrSidx *e = scr_sidx_for(s); size_t len16 = scr_sidx_len(s, e); size_t from = scr_slice_boundary(scr_to_integer_or_infinity(start), len16); @@ -2407,8 +2427,10 @@ void scr_str_split_cursor_init(ScrSplitCursor *cursor, uint32_t limit) { cursor->pending_unit = 0; } -static ScrStr *scr_str_split_piece(const char *bytes, size_t len, ScrStr **scratch) { - if (!scratch || len <= 4) return scr_str_from_span(bytes, len); +/* `ascii`: the bytes are a span of a proven-ASCII string. */ +static ScrStr *scr_str_split_piece(const char *bytes, size_t len, ScrStr **scratch, bool ascii) { + if (!scratch || len <= 4) return ascii ? scr_str_from_span_ascii(bytes, len) + : scr_str_from_span(bytes, len); ScrStr *piece = *scratch; if (piece && piece->rc == 1 && piece->cap >= len) { /* The scratch owner is the only remaining reference. Invalidate all @@ -2419,10 +2441,12 @@ static ScrStr *scr_str_split_piece(const char *bytes, size_t len, ScrStr **scrat memcpy(piece->data, bytes, len); piece->len = len; piece->data[len] = '\0'; + piece->ascii = ascii; } else { ScrStr *fresh = scr_str_alloc_raw(len, len < 64 ? 64 : len); memcpy(fresh->data, bytes, len); fresh->data[len] = '\0'; + fresh->ascii = ascii; *scratch = fresh; scr_str_release(piece); piece = fresh; @@ -2480,7 +2504,7 @@ const char *scr_str_split_cursor_span(ScrStr *s, ScrStr *sep, ScrSplitCursor *cu } ScrStr *scr_str_split_materialize(const char *bytes, size_t length, ScrStr **scratch) { - return scr_str_split_piece(bytes, length, scratch); + return scr_str_split_piece(bytes, length, scratch, false); } ScrStr *scr_str_split_cursor_next(ScrStr *s, ScrStr *sep, ScrSplitCursor *cursor, @@ -2488,7 +2512,7 @@ ScrStr *scr_str_split_cursor_next(ScrStr *s, ScrStr *sep, ScrSplitCursor *cursor size_t len; const char *bytes = scr_str_split_span(s, sep, cursor, sep->len == 0, &len); if (!bytes) return NULL; - return scr_str_split_piece(bytes, len, scratch); + return scr_str_split_piece(bytes, len, scratch, s->ascii); } /* Move a bounded batch of pieces into array storage in one operation. @@ -2513,7 +2537,8 @@ static void scr_str_split_fill(ScrArr *out, ScrStr *s, ScrStr *sep, size_t count = 0, len; const char *bytes; while (count < 64 && (bytes = scr_str_split_span(s, sep, cursor, units, &len))) { - ScrStr *piece = scr_str_from_span(bytes, len); + ScrStr *piece = + s->ascii ? scr_str_from_span_ascii(bytes, len) : scr_str_from_span(bytes, len); slots[count++] = (uint64_t)(uintptr_t)piece; } if (count) scr_arr_push_many(out, count, slots); @@ -2522,18 +2547,43 @@ static void scr_str_split_fill(ScrArr *out, ScrStr *s, ScrStr *sep, } ScrArr *scr_str_split_limit(ScrStr *s, ScrStr *sep, double limit_num) { - uint32_t limit = scr_to_uint32(limit_num); + /* split(sep) passes the default limit, 2^32 - 1. */ + uint32_t limit = limit_num == 4294967295.0 ? UINT32_MAX : scr_to_uint32(limit_num); /* An empty separator yields one piece per UTF-16 unit: size it once. */ size_t pieces = 0; if (sep->len == 0) { pieces = s->ascii ? s->len : scr_sidx_len(s, scr_sidx(s)); if (pieces > limit) pieces = limit; } - ScrArr *out = scr_arr_new(SCR_ELEM_STR, pieces); ScrSplitCursor cursor; scr_str_split_cursor_init(&cursor, limit); - if (sep->len == 0) scr_str_split_fill(out, s, sep, &cursor, true); - else scr_str_split_fill(out, s, sep, &cursor, false); + if (sep->len == 0) { + ScrArr *out = scr_arr_new(SCR_ELEM_STR, pieces); + scr_str_split_fill(out, s, sep, &cursor, true); + return out; + } + /* Most splits fit one batch: its count sizes the result, whose slots then + * share the header's block (scr_arr_new_inline). A longer split appends + * the rest batch by batch. */ + uint64_t slots[64]; + size_t count = 0, len; + const char *bytes; + while (count < 64 && (bytes = scr_str_split_span(s, sep, &cursor, false, &len))) + slots[count++] = (uint64_t)(uintptr_t)(s->ascii ? scr_str_from_span_ascii(bytes, len) + : scr_str_from_span(bytes, len)); + ScrArr *out = scr_arr_new_inline(SCR_ELEM_STR, count); + if (count) { + /* A fresh inline array of exactly `count` slots: store the pieces and + * their states directly. */ + uint8_t *states = scr_arr_states_end(out); + for (size_t k = 0; k < count; k++) { + out->data[k] = slots[k]; + *scr_arr_state_in(states, k) = SCR_ARR_VALUE; + } + out->len = (uint32_t)count; + out->filled = (uint32_t)count; + } + if (count == 64) scr_str_split_fill(out, s, sep, &cursor, false); return out; } @@ -2641,90 +2691,198 @@ ScrStr *scr_str_replace_bytes(ScrStr *s, const char *pat, size_t plen, ScrStr *r return out; } -/* The first byte at or after `i` that is one of the replaced characters - * (`which` maps them to k + 1), or len. Up to eight characters are tested - * eight bytes at a time. */ -static size_t scr_replace_chars_next(const char *p, size_t i, size_t len, const uint8_t *which, - const char *chars, size_t n) { - if (n <= 8) { - const uint64_t ones = UINT64_C(0x0101010101010101); - const uint64_t highs = UINT64_C(0x8080808080808080); - for (; len - i >= 8; i += 8) { - uint64_t w; - memcpy(&w, p + i, 8); - uint64_t hit = 0; - for (size_t k = 0; k < n; k++) { - uint64_t x = w ^ (ones * (unsigned char)chars[k]); - hit |= (x - ones) & ~x & highs; - } - if (hit) break; - } +/* replaceAll of single characters (scr_str_replace_chars): a mask of the + * bytes in one block of SCR_RC_LANES that equal a replaced character, with + * SCR_RC_BITS bits per byte, lowest byte first. Every flagged byte is + * confirmed against the character table, so the SWAR form may flag extra + * bytes above a true match (its zero-byte test has no false negatives). + * Without a vector unit or a little-endian word, the byte loop alone. */ +#if defined(__SSE2__) +#define SCR_RC_LANES 16 +#define SCR_RC_BITS 1 +typedef __m128i ScrRcWant; +static inline ScrRcWant scr_rc_want(unsigned char c) { return _mm_set1_epi8((char)c); } +static inline uint64_t scr_rc_mask(const char *p, const ScrRcWant *want, size_t n) { + __m128i v = _mm_loadu_si128((const __m128i *)p); + __m128i eq = _mm_cmpeq_epi8(v, want[0]); + for (size_t k = 1; k < n; k++) eq = _mm_or_si128(eq, _mm_cmpeq_epi8(v, want[k])); + return (uint64_t)(uint32_t)_mm_movemask_epi8(eq); +} +#elif defined(__ARM_NEON) && defined(__aarch64__) +#define SCR_RC_LANES 16 +#define SCR_RC_BITS 4 +typedef uint8x16_t ScrRcWant; +static inline ScrRcWant scr_rc_want(unsigned char c) { return vdupq_n_u8(c); } +static inline uint64_t scr_rc_mask(const char *p, const ScrRcWant *want, size_t n) { + uint8x16_t v = vld1q_u8((const uint8_t *)p); + uint8x16_t eq = vceqq_u8(v, want[0]); + for (size_t k = 1; k < n; k++) eq = vorrq_u8(eq, vceqq_u8(v, want[k])); + /* Narrowing each 16-bit pair by 4 leaves one nibble per byte. */ + return vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16(vreinterpretq_u16_u8(eq), 4)), 0); +} +#elif defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ +#define SCR_RC_LANES 8 +#define SCR_RC_BITS 8 +typedef uint64_t ScrRcWant; +static inline ScrRcWant scr_rc_want(unsigned char c) { return UINT64_C(0x0101010101010101) * c; } +static inline uint64_t scr_rc_mask(const char *p, const ScrRcWant *want, size_t n) { + const uint64_t ones = UINT64_C(0x0101010101010101); + const uint64_t highs = UINT64_C(0x8080808080808080); + uint64_t w, hit = 0; + memcpy(&w, p, 8); + for (size_t k = 0; k < n; k++) { + uint64_t x = w ^ want[k]; + hit |= (x - ones) & ~x & highs; } - for (; i < len; i++) { - unsigned char c = (unsigned char)p[i]; - if (c < 0x80 && which[c]) return i; + return hit; +} +#else +#define SCR_RC_LANES 0 +#endif + +/* The result being built: `from` is where the copied subject resumes. */ +typedef struct { + ScrStr *out; + size_t cap, used, from; + bool joins; +} ScrRcOut; + +/* Copy the subject up to `i`, then `text` in place of the character at + * `i` (or nothing at the end, when i == len). */ +static inline __attribute__((always_inline)) void scr_rc_put(ScrRcOut *o, const char *p, + size_t len, size_t i, + const char *text, size_t text_len) { + size_t gap = i - o->from; + size_t need = gap + text_len; + if (need > o->cap - o->used) { + if (need > SCR_STR_MAX_CAP - o->used) scr_oom(); + size_t rest = o->used + need + (len - o->from); + size_t want = o->cap * 2 < rest ? rest : o->cap * 2; + if (want > SCR_STR_MAX_CAP) want = SCR_STR_MAX_CAP; + o->out->len = o->used; + o->out = scr_str_regrow(o->out, want); + o->cap = want; } - return len; + scr_copy_bytes(o->out->data + o->used, p + o->from, gap); + o->used += gap; + if (i == len) return; + /* As in scr_str_replace_bytes: a seam can complete a surrogate pair. */ + if (text_len) o->joins |= scr_wtf8_joins(o->out->data, o->used, text, text_len); + scr_copy_bytes(o->out->data + o->used, text, text_len); + o->used += text_len; + o->from = i + 1; + o->joins |= (unsigned char)p[o->from] == 0xED; +} + +/* The characters (at most SCR_RC_MAX), their replacements and the tables + * that find them. */ +#define SCR_RC_MAX 16 +typedef struct { + const ScrStr *chars, *texts; /* the immortal operands it was built for, or NULL */ + size_t n; + size_t grow; /* the longest replacement's growth, less one */ + bool ascii; /* every replacement is ASCII */ + uint8_t which[128]; + const char *text[SCR_RC_MAX]; + size_t text_len[SCR_RC_MAX]; +#if SCR_RC_LANES + ScrRcWant want[SCR_RC_MAX]; +#endif +} ScrRcTable; + +/* The compiler passes a chain's characters and replacements as immortal + * literals, so the last chain's table is kept for the next call with the + * same operands. */ +static SCR_TL ScrRcTable scr_rc_last; + +static const ScrRcTable *scr_rc_table(const ScrStr *chars, const ScrStr *texts, + ScrRcTable *local) { + bool keep = chars->rc == SIZE_MAX && texts->rc == SIZE_MAX; + if (keep && scr_rc_last.chars == chars && scr_rc_last.texts == texts) return &scr_rc_last; + ScrRcTable *table = keep ? &scr_rc_last : local; + table->chars = table->texts = NULL; + size_t n = chars->len < SCR_RC_MAX ? chars->len : SCR_RC_MAX; + size_t grow = 0; + bool ascii = true; + memset(table->which, 0, sizeof table->which); + const char *t = texts->data, *t_end = texts->data + texts->len; + for (size_t k = 0; k < n; k++) { + /* The replacements are short: find each terminator, and whether the + * replacement is ASCII, in one pass. */ + const char *stop = t; + unsigned high = 0; + while (stop < t_end && *stop) high |= (unsigned char)*stop++; + table->text[k] = t; + table->text_len[k] = (size_t)(stop - t); + if (table->text_len[k] > grow) grow = table->text_len[k]; + if (high & 0x80) ascii = false; + table->which[(unsigned char)chars->data[k] & 0x7F] = (uint8_t)(k + 1); +#if SCR_RC_LANES + table->want[k] = scr_rc_want((unsigned char)chars->data[k]); +#endif + t = stop < t_end ? stop + 1 : t_end; + } + table->n = n; + table->grow = grow > 1 ? grow - 1 : 0; + table->ascii = ascii; + if (keep) { + table->chars = chars; + table->texts = texts; + } + return table; } ScrStr *scr_str_replace_chars(ScrStr *s, ScrStr *chars, ScrStr *texts) { - enum { MAX = 16 }; - size_t n = chars->len < MAX ? chars->len : MAX; - uint8_t which[128] = { 0 }; - const char *text[MAX]; - size_t text_len[MAX]; - size_t grow = 0; - bool ascii = s->ascii; - const char *t = texts->data, *t_end = texts->data + texts->len; - for (size_t k = 0; k < n; k++) { - const char *stop = memchr(t, '\0', (size_t)(t_end - t)); - if (!stop) stop = t_end; - text[k] = t; - text_len[k] = (size_t)(stop - t); - if (text_len[k] > grow) grow = text_len[k]; - if (ascii && !scr_bytes_all_ascii(t, text_len[k])) ascii = false; - which[(unsigned char)chars->data[k] & 0x7F] = (uint8_t)(k + 1); - t = stop < t_end ? stop + 1 : t_end; - } + ScrRcTable local; + const ScrRcTable *table = scr_rc_table(chars, texts, &local); + size_t n = table->n, grow = table->grow; + const uint8_t *which = table->which; + const char *const *text = table->text; + const size_t *text_len = table->text_len; +#if SCR_RC_LANES + const ScrRcWant *want = table->want; +#endif + bool ascii = s->ascii && table->ascii; const char *p = s->data; size_t len = s->len; - size_t i = scr_replace_chars_next(p, 0, len, which, chars->data, n); - if (i == len) return scr_str_retain(s); - grow = grow > 1 ? grow - 1 : 0; - size_t cap = len + (grow < 64 ? 4 * grow : grow) + 16; - if (cap > SCR_STR_MAX_CAP) cap = SCR_STR_MAX_CAP; - ScrStr *out = scr_str_alloc_raw(0, cap); - cap = out->cap; - size_t used = 0, from = 0; - /* As in scr_str_replace_bytes: a seam can complete a surrogate pair. */ - bool joins = false; - for (;;) { - size_t gap = i - from; - size_t k = i < len ? (size_t)which[(unsigned char)p[i]] - 1 : 0; - size_t need = gap + (i < len ? text_len[k] : 0); - if (need > cap - used) { - if (need > SCR_STR_MAX_CAP - used) scr_oom(); - size_t want = cap * 2 < used + need + (len - from) ? used + need + (len - from) : cap * 2; - if (want > SCR_STR_MAX_CAP) want = SCR_STR_MAX_CAP; - out->len = used; - out = scr_str_regrow(out, want); - cap = want; + ScrRcOut o = { NULL, 0, 0, 0, false }; + size_t i = 0; +#define SCR_RC_HIT(at) \ + do { \ + unsigned char c_ = (unsigned char)p[at]; \ + if (c_ < 0x80 && which[c_]) { \ + if (!o.out) { \ + size_t cap_ = len + (grow < 64 ? 4 * grow : grow) + 16; \ + if (cap_ > SCR_STR_MAX_CAP) cap_ = SCR_STR_MAX_CAP; \ + o.out = scr_str_alloc_raw(0, cap_); \ + o.cap = o.out->cap; \ + } \ + size_t k_ = (size_t)which[c_] - 1; \ + scr_rc_put(&o, p, len, (at), text[k_], text_len[k_]); \ + } \ + } while (0) +#if SCR_RC_LANES + /* Every match of a block is replaced before the next block is read. */ + for (; len - i >= SCR_RC_LANES; i += SCR_RC_LANES) { + uint64_t mask = scr_rc_mask(p + i, want, n); + while (mask) { + size_t lane = (size_t)__builtin_ctzll(mask) / SCR_RC_BITS; + mask &= ~((((uint64_t)1 << SCR_RC_BITS) - 1) << (lane * SCR_RC_BITS)); + SCR_RC_HIT(i + lane); } - scr_copy_bytes(out->data + used, p + from, gap); - used += gap; - if (i == len) break; - if (text_len[k]) joins |= scr_wtf8_joins(out->data, used, text[k], text_len[k]); - scr_copy_bytes(out->data + used, text[k], text_len[k]); - used += text_len[k]; - from = i + 1; - joins |= (unsigned char)p[from] == 0xED; - i = scr_replace_chars_next(p, from, len, which, chars->data, n); } +#endif + for (; i < len; i++) SCR_RC_HIT(i); +#undef SCR_RC_HIT + if (!o.out) return scr_str_retain(s); + scr_rc_put(&o, p, len, len, NULL, 0); + ScrStr *out = o.out; + size_t used = o.used, cap = o.cap; out->len = used; if (cap - used > used / 4 + 64) out = scr_str_regrow(out, used); out->data[used] = '\0'; scr_str_hash_forget(out); - if (joins && scr_wtf8_any()) scr_str_fuse_surrogates(out); + if (o.joins && scr_wtf8_any()) scr_str_fuse_surrogates(out); else if (ascii) scr_str_note_ascii(out); return out; } @@ -3104,7 +3262,7 @@ double scr_string_to_number(ScrStr *s) { return scr_bytes_to_number(s->data, s->len); } -double scr_bytes_to_number(const char *p, size_t len) { +static __attribute__((noinline)) double scr_bytes_to_number_any(const char *p, size_t len) { size_t b = 0, e = len; while (b < e) { unsigned char c = (unsigned char)p[b]; @@ -3164,6 +3322,27 @@ double scr_bytes_to_number(const char *p, size_t len) { return r; } +/* An optional sign and 1 to 15 decimal digits, the common shape of a + * parsed field, is an exact integer below 2^53 with no whitespace to trim + * and no other grammar to apply ("-0" is -0, leading zeros are decimal). + * Everything else takes the full grammar. */ +double scr_bytes_to_number(const char *p, size_t len) { + if (len - 1 < 16) { + size_t i = p[0] == '-' || p[0] == '+'; + if (i < len && len - i <= 15) { + int64_t m = 0; + size_t k = i; + for (; k < len; k++) { + unsigned d = (unsigned)(unsigned char)p[k] - '0'; + if (d > 9) break; + m = m * 10 + d; + } + if (k == len) return p[0] == '-' ? -(double)m : (double)m; + } + } + return scr_bytes_to_number_any(p, len); +} + ScrStr *scr_f64_to_scrstr(double x) { if (x >= 0 && x < 100) { /* -0 prints "0" as well */ unsigned v = (unsigned)x; diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 804e51d69..b4d125cb2 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -255,6 +255,93 @@ void scr_secure_ctx_release_v(void *p) { scr_secure_ctx_release((ScrSecureCtx *) /* ── the server config (one per created server, rc'd) ────────────────── */ +/* An owned ALPN name list (NULL-terminated; NULL frees nothing). */ +static void scr_tls_alpn_free(char **names) { + if (names == NULL) return; + for (char **n = names; *n != NULL; n++) free(*n); + free(names); +} + +static bool scr_tls_alpn_dyn(const ScrDyn *v, char ***out); +static void scr_tls_opt_fence(const char *api, const char *key, const char *hint); +static bool scr_tls_dyn_truthy(const ScrDyn *v); + +/* Node's toV over a minVersion/maxVersion value: 'TLSv1' through 'TLSv1.3' + * as 10 through 13, null or undefined as `def`. Anything else throws + * ERR_TLS_INVALID_PROTOCOL_VERSION ("%j is not a valid TLS protocol + * version"). False with the exception pending. */ +static bool scr_tls_version_dyn(const ScrDyn *v, const char *which, int def, int *out) { + if (v == NULL || v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL) { + *out = def; + return true; + } + if (v->kind == SCR_DYN_STR) { + static const struct { const char *name; int version; } names[] = { + { "TLSv1", 10 }, { "TLSv1.1", 11 }, { "TLSv1.2", 12 }, { "TLSv1.3", 13 }, + }; + for (size_t i = 0; i < sizeof names / sizeof names[0]; i++) { + if (v->v.str->len == strlen(names[i].name) && + memcmp(v->v.str->data, names[i].name, v->v.str->len) == 0) { + *out = names[i].version; + return true; + } + } + } + ScrStr *shown = scr_util_format_j(v); + if (shown == NULL) return false; + char msg[300]; + int m = snprintf(msg, sizeof msg, "%.*s is not a valid %s TLS protocol version", + (int)(shown->len < 200 ? shown->len : 200), shown->data, which); + scr_str_release(shown); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)m, "ERR_TLS_INVALID_PROTOCOL_VERSION"); + return false; +} + +/* The version range on an mbedTLS config. mbedTLS implements TLS 1.2 and + * 1.3, so a minimum of TLSv1 or TLSv1.1 offers from 1.2, the lowest version + * Node's default security level allows too. Answers false when no version + * in [min, max] is available (a maximum below 1.2, or min above max), which + * Node reports as ERR_SSL_NO_PROTOCOLS_AVAILABLE when the handshake starts. */ +static bool scr_tls_conf_versions(mbedtls_ssl_config *conf, int min, int max) { + if (min < 12) min = 12; + if (max < min) return false; + mbedtls_ssl_conf_min_tls_version(conf, min == 13 ? MBEDTLS_SSL_VERSION_TLS1_3 : MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_max_tls_version(conf, max == 13 ? MBEDTLS_SSL_VERSION_TLS1_3 : MBEDTLS_SSL_VERSION_TLS1_2); + return true; +} + +/* Node's SecureContext checks, in its order: secureProtocol conflicts with + * an explicit minVersion or maxVersion, then each version. The range lands + * in *min / *max (Node's defaults TLSv1.2 and TLSv1.3). False with the + * exception pending. */ +static bool scr_tls_versions_dyn(const ScrDyn *opts, int *min, int *max) { + const ScrDyn *vmin = opts != NULL && opts->kind == SCR_DYN_OBJ ? scr_dyn_obj_get(opts, "minVersion", 10) : NULL; + const ScrDyn *vmax = opts != NULL && opts->kind == SCR_DYN_OBJ ? scr_dyn_obj_get(opts, "maxVersion", 10) : NULL; + const ScrDyn *proto = opts != NULL && opts->kind == SCR_DYN_OBJ ? scr_dyn_obj_get(opts, "secureProtocol", 14) : NULL; + if (proto != NULL && scr_tls_dyn_truthy(proto)) { + const ScrDyn *version = vmin != NULL && vmin->kind != SCR_DYN_UNDEF && vmin->kind != SCR_DYN_NULL ? vmin + : vmax != NULL && vmax->kind != SCR_DYN_UNDEF && vmax->kind != SCR_DYN_NULL ? vmax + : NULL; + if (version != NULL) { + ScrStr *a = scr_util_format_j(version); + if (a == NULL) return false; + ScrStr *b = scr_util_format_j(proto); + if (b == NULL) { + scr_str_release(a); + return false; + } + char msg[400]; + int m = snprintf(msg, sizeof msg, "TLS protocol version %.*s conflicts with secureProtocol %.*s", + (int)(a->len < 150 ? a->len : 150), a->data, (int)(b->len < 150 ? b->len : 150), b->data); + scr_str_release(a); + scr_str_release(b); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)m, "ERR_TLS_PROTOCOL_VERSION_CONFLICT"); + return false; + } + } + return scr_tls_version_dyn(vmin, "minimum", 12, min) && scr_tls_version_dyn(vmax, "maximum", 13, max); +} + typedef struct ScrTlsSrv { size_t rc; mbedtls_ssl_config conf; @@ -275,6 +362,12 @@ typedef struct ScrTlsSrv { * thunk that becomes each minted answer closure's fn */ ScrClosure *sni_cb; void *sni_answer_fn; + /* tls.createServer's ALPNProtocols (owned; NULL when none), in the + * server's preference order. */ + char **alpn; + /* maxVersion TLSv1.2: the ClientHello is pre-parsed so a hello that + * offers only later versions gets OpenSSL's protocol_version alert. */ + bool max_tls12; } ScrTlsSrv; /* The ALPN answers (static storage — mbedTLS keeps the pointers). Plain @@ -325,6 +418,7 @@ static void scr_tls_srv_release(ScrTlsSrv *s) { mbedtls_ssl_config_free(&s->conf); mbedtls_x509_crt_free(&s->cert); mbedtls_pk_free(&s->pk); + scr_tls_alpn_free(s->alpn); free(s); } @@ -342,6 +436,13 @@ typedef struct ScrTlsCli { * VERIFY_NONE socket (the https client's rejectUnauthorized:false) * never verified, so authorized answers false like Node's. */ bool verify_recorded; + /* The ALPNProtocols option's names, NULL-terminated, in the caller's + * order (mbedtls_ssl_conf_alpn_protocols keeps the pointer); NULL when + * none are offered. */ + char **alpn; + /* No TLS version is in the configured range: the handshake fails as + * soon as it starts (Node's ERR_SSL_NO_PROTOCOLS_AVAILABLE). */ + bool no_protocols; } ScrTlsCli; /* The default trust anchors when no `ca` option is given: the host trust @@ -490,6 +591,7 @@ static ScrTlsCli *scr_tls_cli_new(ScrStr *host, bool reject_unauthorized, const static void scr_tls_cli_free(ScrTlsCli *c) { mbedtls_ssl_config_free(&c->conf); + scr_tls_alpn_free(c->alpn); mbedtls_x509_crt_free(&c->ca); free(c->hostname); free(c->verify_name); @@ -725,8 +827,13 @@ static int scr_tls_f_sni(void *p, mbedtls_ssl_context *ssl, const unsigned char * random, session id, cipher suites, compression, then the extension list * — server_name (type 0) holds one host_name (type 0) entry. Answers 1 * with `out` set, 2 for a well-formed hello without SNI or anything - * malformed (the real handshake will judge it). */ -static int scr_tls_parse_ch_body(const unsigned char *b, size_t n, char *out, size_t out_cap) { + * malformed (the real handshake will judge it). *offers12 answers whether + * the hello offers TLS 1.2: true without a supported_versions extension + * (type 43), else whether its list holds 0x0303. */ +static int scr_tls_parse_ch_body(const unsigned char *b, size_t n, char *out, size_t out_cap, + bool *offers12) { + *offers12 = true; + int found = 2; size_t i = 34; /* version(2) + random(32) */ if (i + 1 > n) return 2; i += 1 + b[i]; /* session id: length byte + body */ @@ -744,24 +851,35 @@ static int scr_tls_parse_ch_body(const unsigned char *b, size_t n, char *out, si size_t el = ((size_t)b[i + 2] << 8) | b[i + 3]; i += 4; if (i + el > end) return 2; - if (et == 0) { + if (et == 0 && found == 2) { /* server_name: list length(2), then entry type(1) + length(2) + name */ - if (el < 5 || b[i + 2] != 0) return 2; - size_t nl = ((size_t)b[i + 3] << 8) | b[i + 4]; - if (nl == 0 || nl + 5 > el || nl + 1 > out_cap) return 2; - memcpy(out, b + i + 5, nl); - out[nl] = 0; - return 1; + if (el >= 5 && b[i + 2] == 0) { + size_t nl = ((size_t)b[i + 3] << 8) | b[i + 4]; + if (nl > 0 && nl + 5 <= el && nl + 1 <= out_cap) { + memcpy(out, b + i + 5, nl); + out[nl] = 0; + found = 1; + } + } + } else if (et == 43 && el >= 1) { + /* supported_versions: list length(1), then 2-byte versions */ + size_t vl = b[i] < el - 1 ? b[i] : el - 1; + *offers12 = false; + for (size_t v = 0; v + 1 < vl; v += 2) { + if (b[i + 1 + v] == 0x03 && b[i + 2 + v] == 0x03) *offers12 = true; + } } i += el; } - return 2; + return found; } /* Reassemble the handshake stream from the raw record flight and look for * the server_name extension. 0 = need more bytes; 1 = found (out set); * 2 = no SNI / not parseable (serve the default cert). */ -static int scr_tls_parse_sni(const unsigned char *buf, size_t len, char *out, size_t out_cap) { +static int scr_tls_parse_sni(const unsigned char *buf, size_t len, char *out, size_t out_cap, + bool *offers12) { + *offers12 = true; unsigned char *hs = NULL; size_t hs_len = 0, hs_cap = 0; size_t off = 0; @@ -785,7 +903,7 @@ static int scr_tls_parse_sni(const unsigned char *buf, size_t len, char *out, si size_t chlen = ((size_t)hs[1] << 16) | ((size_t)hs[2] << 8) | hs[3]; if (chlen > 131072) { free(hs); return 2; } if (hs_len >= 4 + chlen) { - int r = scr_tls_parse_ch_body(hs + 4, chlen, out, out_cap); + int r = scr_tls_parse_ch_body(hs + 4, chlen, out, out_cap, offers12); free(hs); return r; } @@ -811,8 +929,19 @@ static int scr_tls_sni_collect(ScrTlsSock *t) { char servername[256]; for (;;) { if (t->hello_len > 0) { - int r = scr_tls_parse_sni(t->hello, t->hello_len, servername, sizeof servername); - if (r == 2 || t->hello_len > 65536) { + bool offers12; + int r = scr_tls_parse_sni(t->hello, t->hello_len, servername, sizeof servername, &offers12); + if (r != 0 && t->srv->max_tls12 && !offers12) { + /* A TLS 1.2 server and a hello offering only later versions: + * OpenSSL answers with a fatal protocol_version alert, which the + * client reports as ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION. */ + static const unsigned char alert[] = { 0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x46 }; + int fd = scr_net_sock_fd(t->sock); + if (fd >= 0) (void)!write(fd, alert, sizeof alert); + scr_net_sock_transport_error(t->sock, NULL); + return -1; + } + if (r == 2 || t->hello_len > 65536 || (r == 1 && t->srv->sni_cb == NULL)) { /* no SNI (or unparseable): skip the callback, serve the default * pair — Node's SNICallback never fires without a server_name */ t->sni_state = SCR_SNI_DONE; @@ -899,6 +1028,12 @@ void scr_tls_sni_answer(ScrClosure *self, bool has_err, ScrSecureCtx *ctx /*move static int scr_tls_handshake(void *tctx) { ScrTlsSock *t = (ScrTlsSock *)tctx; + if (t->cli != NULL && t->cli->no_protocols) { + /* OpenSSL's reason without its per-process prefix (SEMANTICS.md + * divergence 55); scr_error_message_code gives Node's code. */ + scr_net_sock_transport_error(t->sock, "error:0A0000BF:SSL routines::no protocols available"); + return -1; + } if (t->sni_state == SCR_SNI_COLLECT) { t->in_handshake = true; int c = scr_tls_sni_collect(t); @@ -922,11 +1057,25 @@ static int scr_tls_handshake(void *tctx) { if (r == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) { scr_tls_verify_msg(t, msg, sizeof msg); } else if (r == MBEDTLS_ERR_SSL_CONN_EOF || r == MBEDTLS_ERR_NET_CONN_RESET) { - /* the peer died mid-handshake: the http layer's own premature-close - * shape ('socket hang up') is Node's message — tear down without an error - * at this level and let the closed hook classify it */ - scr_net_sock_transport_error(t->sock, NULL); + /* The peer closed or reset the connection mid-handshake: Node's + * ECONNRESET for tls.connect and https.request alike. */ + scr_net_sock_transport_error( + t->sock, "Client network socket disconnected before secure TLS connection was established"); return -1; + } else if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && + t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && + t->ssl.MBEDTLS_PRIVATE(in_msg)[1] == MBEDTLS_SSL_ALERT_MSG_NO_APPLICATION_PROTOCOL) { + /* The server shares none of the ALPNProtocols: OpenSSL's reason, which + * Node reports as ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL + * (scr_error_message_code), without its per-process prefix and source + * location (SEMANTICS.md divergence 55). */ + snprintf(msg, sizeof msg, + "error:0A000460:SSL routines:ssl3_read_bytes:tlsv1 alert no application protocol"); + } else if (r == MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE && + t->ssl.MBEDTLS_PRIVATE(in_msg) != NULL && + t->ssl.MBEDTLS_PRIVATE(in_msg)[1] == MBEDTLS_SSL_ALERT_MSG_PROTOCOL_VERSION) { + /* The server accepts none of the offered versions. */ + snprintf(msg, sizeof msg, "error:0A00042E:SSL routines:ssl3_read_bytes:tlsv1 alert protocol version"); } else { /* non-verify handshake failures: honest but not Node's exact openssl * string (SEMANTICS.md divergence 55) */ @@ -1012,19 +1161,65 @@ static void scr_tls_srv_on_conn(void *ctx, ScrNetSocket *sock) { if (mbedtls_ssl_setup(&t->ssl, &srv->conf) != 0) scr_tls_oom(); t->sock = sock; t->srv = scr_tls_srv_retain(srv); - if (srv->sni_cb != NULL) t->sni_state = SCR_SNI_COLLECT; /* pre-parse the hello */ + if (srv->sni_cb != NULL || srv->max_tls12) t->sni_state = SCR_SNI_COLLECT; /* pre-parse the hello */ mbedtls_ssl_set_bio(&t->ssl, t, scr_tls_bio_send, scr_tls_bio_recv, NULL); scr_net_sock_set_transport(sock, &SCR_TLS_OPS, t); } +static ScrNetServer *scr_tls_create_server_alpn(const char *cert, size_t cert_len, + const char *key, size_t key_len, + ScrClosure *handler /*moves, nullable*/, + ScrNetConnFn fn, char **alpn /*moves, nullable*/, + int min_version, int max_version, + ScrClosure *sni_cb /*moves, nullable*/, + void *sni_answer_fn) { + ScrTlsSrv *srv = scr_tls_srv_new(cert, cert_len, key, key_len, false); + if (sni_cb != NULL) { + srv->sni_cb = sni_cb; /* moves; released with srv */ + srv->sni_answer_fn = sni_answer_fn; + mbedtls_ssl_conf_sni(&srv->conf, &scr_tls_f_sni, NULL); + } + srv->max_tls12 = max_version == 12; + if (!scr_tls_conf_versions(&srv->conf, min_version, max_version)) { + /* Node's server then fails every handshake; nothing here models + * that, so it stays a refusal. */ + scr_tls_srv_release(srv); + scr_closure_release(handler); + scr_tls_alpn_free(alpn); + scr_tls_opt_fence("tls.createServer", "maxVersion", + "a version range with neither TLSv1.2 nor TLSv1.3 has no lowering"); + return NULL; + } + if (alpn != NULL) { + /* mbedTLS picks the first of these the client offers (Node's server + * preference) and sends no_application_protocol when it offers none. */ + srv->alpn = alpn; + mbedtls_ssl_conf_alpn_protocols(&srv->conf, (const char **)srv->alpn); + } + ScrNetServer *s = scr_net_create_server(handler, fn); + scr_net_server_set_native_conn(s, &scr_tls_srv_on_conn, srv, &scr_tls_srv_release_v); + scr_net_server_defer_connections(s); /* the handler is 'secureConnection' */ + return s; +} + ScrNetServer *scr_tls_create_server(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn) { - ScrTlsSrv *srv = scr_tls_srv_new(cert, cert_len, key, key_len, false); - ScrNetServer *s = scr_net_create_server(handler, fn); - scr_net_server_set_native_conn(s, &scr_tls_srv_on_conn, srv, &scr_tls_srv_release_v); - scr_net_server_defer_connections(s); /* the handler is 'secureConnection' */ - return s; + return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, NULL, + NULL); +} + +/* tls.createServer({ cert, key, SNICallback }): the pre-parse machinery + * (design note atop the SNI section) hands each ClientHello's servername + * to the callback and serves the context it answers, or the default pair + * for none. A NULL sni_cb (the callback union's undefined arm) is + * scr_tls_create_server. */ +ScrNetServer *scr_tls_create_server_sni(const char *cert, size_t cert_len, const char *key, + size_t key_len, ScrClosure *handler /*moves, nullable*/, + ScrNetConnFn fn, ScrClosure *sni_cb /*moves, nullable*/, + void *sni_answer_fn) { + return scr_tls_create_server_alpn(cert, cert_len, key, key_len, handler, fn, NULL, 12, 13, sni_cb, + sni_answer_fn); } ScrNetServer *scr_https_create_server(const char *cert, size_t cert_len, const char *key, @@ -1639,38 +1834,11 @@ static bool scr_tls_opts_validate(const ScrDyn *opts) { return false; } } - static const char *const VERSION_OPTS[] = { "minVersion", "maxVersion", NULL }; - for (size_t i = 0; VERSION_OPTS[i] != NULL; i++) { - const ScrDyn *v = scr_dyn_obj_get(opts, VERSION_OPTS[i], strlen(VERSION_OPTS[i])); - if (v == NULL || v->kind == SCR_DYN_UNDEF) continue; - bool valid = false; - if (v->kind == SCR_DYN_STR) { - static const char *const KNOWN[] = { "TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3", NULL }; - for (size_t k = 0; KNOWN[k] != NULL; k++) { - if (v->v.str->len == strlen(KNOWN[k]) && memcmp(v->v.str->data, KNOWN[k], v->v.str->len) == 0) { - valid = true; - break; - } - } - } - if (!valid) { - /* %j: strings render JSON-quoted, everything else inspect-lite. */ - char rendered[96]; - if (v->kind == SCR_DYN_STR) { - snprintf(rendered, sizeof rendered, "\"%.*s\"", - (int)(v->v.str->len < 80 ? v->v.str->len : 80), v->v.str->data); - } else { - /* %j over non-strings: JSON.stringify's scalar renderings. */ - char lite[64]; - snprintf(rendered, sizeof rendered, "%s", scr_dyn_inspect_lite(v, lite, sizeof lite)); - } - char msg[192]; - int len = snprintf(msg, sizeof msg, "%s is not a valid %s TLS protocol version", - rendered, VERSION_OPTS[i][2] == 'n' ? "minimum" : "maximum"); - scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)len, "ERR_TLS_INVALID_PROTOCOL_VERSION"); - return false; - } - } + /* Node's toV: null and undefined select the default bound. */ + int version; + if (!scr_tls_version_dyn(scr_dyn_obj_get(opts, "minVersion", 10), "minimum", 12, &version) || + !scr_tls_version_dyn(scr_dyn_obj_get(opts, "maxVersion", 10), "maximum", 13, &version)) + return false; static const char *const NUM_OPTS[] = { "handshakeTimeout", "keepAliveInitialDelay", NULL }; for (size_t i = 0; NUM_OPTS[i] != NULL; i++) { const ScrDyn *v = scr_dyn_obj_get(opts, NUM_OPTS[i], strlen(NUM_OPTS[i])); @@ -1730,8 +1898,10 @@ static bool scr_tls_opts_validate(const ScrDyn *opts) { * dyn options record; false = exception pending (partial results released). * Exported runtime-internally: scr_http2.c's createSecureServerDyn rides * the same walk (scr_http2.c always links alongside this unit). */ -bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_out, - ScrBytes **key_out) { +/* `server_ok`: tls.createServer reads ALPNProtocols, minVersion and + * maxVersion itself; the walk's other callers keep their fences. */ +static bool scr_tls_srv_opts_walk_ex(const ScrDyn *opts, const char *api, ScrBytes **cert_out, + ScrBytes **key_out, bool server_ok) { *cert_out = NULL; *key_out = NULL; if (opts == NULL || opts->kind != SCR_DYN_OBJ) { @@ -1770,6 +1940,9 @@ bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_ if (strcmp(e->key, "rejectUnauthorized") == 0) { continue; /* meaningful only with requestCert, which fences */ } + if (server_ok && (strcmp(e->key, "ALPNProtocols") == 0 || strcmp(e->key, "minVersion") == 0 || + strcmp(e->key, "maxVersion") == 0)) + continue; if (strcmp(e->key, "SNICallback") == 0) { scr_tls_opt_fence(api, "SNICallback", "SNICallback lowers only on http2.createSecureServer({ allowHTTP1: true, ... }) with a literal options object"); @@ -1803,6 +1976,11 @@ bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_ return ok; } +bool scr_tls_srv_opts_walk(const ScrDyn *opts, const char *api, ScrBytes **cert_out, + ScrBytes **key_out) { + return scr_tls_srv_opts_walk_ex(opts, api, cert_out, key_out, false); +} + /* createSecureContext over a RUNTIME options record: Node's typed * validations first (scr_tls_opts_validate), then the pem walk — a bag * that validates AND carries both cert and key builds the real context; @@ -1845,13 +2023,29 @@ void scr_tls_ca_certs_chk(const ScrDyn *type, const ScrStr *fence) { ScrNetServer *scr_tls_create_server_dyn(const ScrDyn *opts /*borrowed*/, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn) { - ScrBytes *cert, *key; - if (!scr_tls_srv_opts_walk(opts, "tls.createServer", &cert, &key)) { + int min_version = 12, max_version = 13; + if (!scr_tls_versions_dyn(opts, &min_version, &max_version)) { scr_closure_release(handler); return NULL; } - ScrNetServer *s = scr_tls_create_server((const char *)cert->data, cert->len, - (const char *)key->data, key->len, handler, fn); + ScrBytes *cert, *key; + if (!scr_tls_srv_opts_walk_ex(opts, "tls.createServer", &cert, &key, true)) { + scr_closure_release(handler); + return NULL; + } + /* The Server constructor converts ALPNProtocols after the secure + * context validates (Node's order). */ + char **alpn = NULL; + const ScrDyn *protocols = scr_dyn_obj_get(opts, "ALPNProtocols", 13); + if (protocols != NULL && !scr_tls_alpn_dyn(protocols, &alpn)) { + scr_bytes_release(cert); + scr_bytes_release(key); + scr_closure_release(handler); + return NULL; + } + ScrNetServer *s = scr_tls_create_server_alpn((const char *)cert->data, cert->len, + (const char *)key->data, key->len, handler, fn, + alpn, min_version, max_version, NULL, NULL); scr_bytes_release(cert); scr_bytes_release(key); return s; @@ -1896,20 +2090,157 @@ ScrNetServer *scr_https_create_server_dyn(const ScrDyn *opts /*borrowed*/, /* ── tls.connect (the TLS client socket) ──────────────────────────────── */ +/* Node's inspect of a Buffer inside ERR_INVALID_ARG_VALUE's message: + * "" with at most 50 bytes, the whole rendering cut at + * 128 characters with "...". */ +static void scr_tls_alpn_value_fail(const char *reason, const uint8_t *data, size_t len) { + char inspected[400]; + size_t n = (size_t)snprintf(inspected, sizeof inspected, " shown) n += (size_t)snprintf(inspected + n, sizeof inspected - n, " ... %zu more byte%s", len - shown, len - shown == 1 ? "" : "s"); + snprintf(inspected + n, sizeof inspected - n, ">"); + if (strlen(inspected) > 128) strcpy(inspected + 128, "..."); + char msg[600]; + int m = snprintf(msg, sizeof msg, "The argument 'ALPNProtocols' %s. Received %s", reason, inspected); + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, (size_t)m, "ERR_INVALID_ARG_VALUE"); +} + +/* The wire list (...) as mbedTLS's NULL-terminated names, after + * Node's validateALPNBuffer. An empty list offers nothing (*out stays + * NULL). False with the exception pending. */ +static bool scr_tls_alpn_from_wire(const uint8_t *data, size_t len, char ***out) { + size_t count = 0; + for (size_t at = 0; at < len;) { + size_t n = data[at]; + if (n == 0) { + scr_tls_alpn_value_fail("must not contain zero-length protocol", data, len); + return false; + } + if (at + 1 + n > len) { + scr_tls_alpn_value_fail("contains truncated protocol", data, len); + return false; + } + at += 1 + n; + count++; + } + if (count == 0) return true; + char **names = calloc(count + 1, sizeof *names); + if (!names) scr_tls_oom(); + size_t k = 0; + for (size_t at = 0; at < len; at += 1 + data[at]) { + names[k] = malloc((size_t)data[at] + 1); + if (!names[k]) scr_tls_oom(); + memcpy(names[k], data + at + 1, data[at]); + names[k][data[at]] = '\0'; + k++; + } + *out = names; + return true; +} + +/* tls.connect's ALPNProtocols (Node's convertALPNProtocols, then + * setALPNProtocols): an array of non-empty strings of at most 255 bytes, a + * Uint8Array or other ArrayBufferView in the wire format, or a falsy value + * (no ALPN). Any other truthy value is the binding's TypeError. False with + * the exception pending. */ +static bool scr_tls_alpn_dyn(const ScrDyn *v, char ***out) { + *out = NULL; + ScrDyn *own = NULL; + if (v->kind == SCR_DYN_TYPED_REF) { + own = scr_dyn_typed_ref_materialize(v); + if (own == NULL) return false; + v = own; + } + bool ok = true; + if (v->kind == SCR_DYN_ARR) { + /* convertProtocols: Buffer.byteLength of every entry first (strings, + * or the ArrayBuffer and view entries byteLength also measures), then + * each length's checks, then buff.write, which takes strings only. */ + size_t count = v->v.arr.len; + size_t total = 0; + for (size_t i = 0; ok && i < count; i++) { + const ScrDyn *e = v->v.arr.items[i]; + size_t n; + if (e->kind == SCR_DYN_STR) { + n = e->v.str->len; + } else if (e->kind == SCR_DYN_BYTES || scr_buffer_storage_is(e)) { + ScrBytes *view = scr_bytes_buffer_source(e); + if (view == NULL) { + ok = false; + break; + } + n = view->len; + scr_bytes_release(view); + } else { + scr_dyn_arg_type_fail("string", "of type string or an instance of Buffer or ArrayBuffer", e); + ok = false; + break; + } + if (n == 0) { + char name[32]; + snprintf(name, sizeof name, "protocols[%zu]", i); + scr_dyn_arg_value_fail(name, "must be a non-empty string", e); + ok = false; + break; + } + if (n > 255) { + char msg[160]; + int m = snprintf(msg, sizeof msg, + "The byte length of the protocol at index %zu exceeds the maximum length. It must be <= 255. Received %zu", + i, n); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)m, "ERR_OUT_OF_RANGE"); + ok = false; + break; + } + total += 1 + n; + } + uint8_t *wire = ok ? malloc(total + 1) : NULL; + if (ok && !wire) scr_tls_oom(); + size_t at = 0; + for (size_t i = 0; ok && i < count; i++) { + const ScrDyn *e = v->v.arr.items[i]; + if (e->kind != SCR_DYN_STR) { + static const char msg[] = "argument must be a string"; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_ARG_TYPE"); + ok = false; + break; + } + wire[at++] = (uint8_t)e->v.str->len; + memcpy(wire + at, e->v.str->data, e->v.str->len); + at += e->v.str->len; + } + if (ok) ok = scr_tls_alpn_from_wire(wire, at, out); + free(wire); + } else if (v->kind == SCR_DYN_BYTES) { + ScrBytes *view = scr_bytes_buffer_source(v); + if (view == NULL) ok = false; + else { + ok = scr_tls_alpn_from_wire(view->data, view->len, out); + scr_bytes_release(view); + } + } else if (scr_tls_dyn_truthy(v)) { + static const char msg[] = "Must give a Buffer as first argument"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + ok = false; + } + scr_dyn_release(own); + return ok; +} + /* tls.connect's documented keys outside the implemented set: present * throws (the server walk's stance). */ static const char *const SCR_TLS_CONNECT_FENCED_OPTIONS[] = { - "ALPNProtocols", "allowHalfOpen", "autoSelectFamily", + "allowHalfOpen", "autoSelectFamily", "autoSelectFamilyAttemptTimeout", "cert", "certificateCompression", "checkServerIdentity", "ciphers", "clientCertEngine", "crl", "dhparam", "ecdhCurve", "enableTrace", "family", "fd", "hints", "highWaterMark", "honorCipherOrder", "keepAlive", "keepAliveInitialDelay", "key", - "localAddress", "localPort", "lookup", "maxVersion", "minDHSize", - "minVersion", "noDelay", "onread", "passphrase", "path", "pfx", + "localAddress", "localPort", "lookup", "minDHSize", "noDelay", "onread", "passphrase", "path", "pfx", "privateKeyEngine", "privateKeyIdentifier", "pskCallback", "readable", "secureContext", "secureOptions", "secureProtocol", "session", "sessionIdContext", "sigalgs", "signal", "socket", "ticketKeys", - "timeout", "writable", NULL, + "writable", NULL, }; ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable*/, @@ -1920,12 +2251,19 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable ScrBytes *ca = NULL; const ScrStr *servername = NULL; const ScrStr *opt_host = NULL; + char **alpn = NULL; + const ScrDyn *alpn_value = NULL; + const ScrDyn *timeout_value = NULL; + int min_version = 12, max_version = 13; bool ok = true; if (opts != NULL && opts->kind != SCR_DYN_UNDEF && opts->kind != SCR_DYN_NULL) { if (opts->kind != SCR_DYN_OBJ) { scr_dyn_arg_type_fail("options", "of type object", opts); ok = false; } + /* createSecureContext's version checks come before its other options + * (Node's SecureContext constructor). */ + if (ok && !scr_tls_versions_dyn(opts, &min_version, &max_version)) ok = false; for (size_t i = 0; ok && i < opts->v.obj.len; i++) { const ScrDynEntry *e = &opts->v.obj.entries[i]; const ScrDyn *v = e->value; @@ -1974,6 +2312,16 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable if (ca == NULL) ok = false; continue; } + if (strcmp(e->key, "ALPNProtocols") == 0) { + alpn_value = v; /* converted after the context options, as TLSSocket does */ + continue; + } + if (strcmp(e->key, "minVersion") == 0 || strcmp(e->key, "maxVersion") == 0) continue; + if (strcmp(e->key, "timeout") == 0) { + /* `if (options.timeout) tlssock.setTimeout(options.timeout)` */ + timeout_value = scr_tls_dyn_truthy(v) ? v : NULL; + continue; + } if (strcmp(e->key, "servername") == 0) { if (v->kind == SCR_DYN_STR) servername = v->v.str; else { @@ -1985,7 +2333,7 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable for (size_t k = 0; SCR_TLS_CONNECT_FENCED_OPTIONS[k] != NULL; k++) { if (strcmp(e->key, SCR_TLS_CONNECT_FENCED_OPTIONS[k]) == 0) { scr_tls_opt_fence("tls.connect", e->key, - "port/host/rejectUnauthorized/ca/servername are the honestly-implemented members of a runtime options record"); + "port/host/rejectUnauthorized/ca/servername/ALPNProtocols/minVersion/maxVersion/timeout are the implemented members of a runtime options record"); ok = false; break; } @@ -1993,6 +2341,26 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable /* anything else: an undocumented key, dropped like Node drops it */ } } + if (ok && alpn_value != NULL && !scr_tls_alpn_dyn(alpn_value, &alpn)) ok = false; + /* socket.setTimeout(msecs) runs after the TLSSocket exists and before it + * connects: Node's msecs checks, then the inactivity timer below. */ + double timeout_ms = 0; + if (ok && timeout_value != NULL) { + if (timeout_value->kind != SCR_DYN_NUM) { + scr_dyn_arg_type_fail("msecs", "of type number", timeout_value); + ok = false; + } else if (!isfinite(timeout_value->v.num) || timeout_value->v.num < 0) { + char recv[48], msg[160]; + scr_num_received(timeout_value->v.num, recv); + int m = snprintf(msg, sizeof msg, + "The value of \"msecs\" is out of range. It must be a non-negative finite number. Received %s", + recv); + scr_throw_error_msg_code(SCR_ERR_RANGE, msg, (size_t)m, "ERR_OUT_OF_RANGE"); + ok = false; + } else { + timeout_ms = timeout_value->v.num; + } + } if (ok && port < 0) { scr_dyn_arg_type_fail("options.port", "of type number", scr_dyn_undefined()); ok = false; @@ -2000,6 +2368,7 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable ScrStr *dial_host = (ScrStr *)(host != NULL && host->len > 0 ? host : opt_host); if (ok && !scr_net_host_chk(dial_host)) ok = false; if (!ok) { + scr_tls_alpn_free(alpn); scr_bytes_release(ca); scr_closure_release(cb); return NULL; @@ -2022,7 +2391,13 @@ ScrNetSocket *scr_tls_connect_dyn(double port, ScrStr *host /*borrowed, nullable ca != NULL ? (const char *)ca->data : NULL, ca != NULL ? ca->len : 0, !reject); if (servername != NULL) scr_tls_cli_set_servername(cli, servername); + if (alpn != NULL) { + cli->alpn = alpn; /* moves */ + mbedtls_ssl_conf_alpn_protocols(&cli->conf, (const char **)cli->alpn); + } + cli->no_protocols = !scr_tls_conf_versions(&cli->conf, min_version, max_version); scr_tls_cli_wrap(s, cli); + if (timeout_ms > 0) scr_net_sock_set_timeout(s, timeout_ms); scr_str_release(localhost_fallback); scr_bytes_release(ca); return s; diff --git a/packages/runtime/src/scr_tls.h b/packages/runtime/src/scr_tls.h index 0b1714eb3..fcb221b42 100644 --- a/packages/runtime/src/scr_tls.h +++ b/packages/runtime/src/scr_tls.h @@ -15,6 +15,9 @@ * (port 443 default, `ca` / rejectUnauthorized for local-CA flows — * cert/key/ca all arrive as borrowed PEM bytes). */ ScrNetServer *scr_tls_create_server(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn); /* +1 */ +/* The SNICallback form: sni_cb (moves, nullable) receives (servername, answer), and + * sni_answer_fn is the emitted thunk that hands the answer to scr_tls_sni_answer. */ +ScrNetServer *scr_tls_create_server_sni(const char *cert, size_t cert_len, const char *key, size_t key_len, ScrClosure *handler /*moves, nullable*/, ScrNetConnFn fn, ScrClosure *sni_cb /*moves, nullable*/, void *sni_answer_fn); /* +1 */ /* ── RUNTIME tls/https options records (the divergence-66 stance: a * non-literal options value reads its members at runtime; members whose diff --git a/packages/runtime/src/scr_worker_events.c b/packages/runtime/src/scr_worker_events.c index b4efa3e0d..252230372 100644 --- a/packages/runtime/src/scr_worker_events.c +++ b/packages/runtime/src/scr_worker_events.c @@ -57,6 +57,28 @@ static SCR_TL uint64_t scr_channel_poll_gen; /* the loop pass continues a phase (scr_channel_more answered true) */ static SCR_TL bool scr_channel_continuing; static SCR_TL bool scr_channel_closing_phase; +/* Messages from the thread's workers and its parent are delivered in + * batches, as Node's MessagePort delivers them: one callback takes the + * messages queued when it starts, and those that arrive meanwhile until it + * has taken SCR_WORKER_BATCH_MIN, with a microtask checkpoint after each, + * before the loop goes on to timers and immediates. A delivered message + * starts a batch; the loop pass continues it (scr_workers_more) while the + * next event is a message and the batch has room. Port messages that a + * listener posts meanwhile wait for the batch to end, and the pass then + * continues once more so they are delivered before immediates, as in Node. */ +#define SCR_WORKER_BATCH_MIN 1000 +static SCR_TL size_t scr_worker_batch_left; +static SCR_TL bool scr_worker_batch_ended; +/* scr_workers_more found the batch's next message at the head of the + * mailbox. Only this thread takes events and others append them, so it is + * still there when the pass dispatches. */ +static SCR_TL bool scr_worker_batch_queued; + +/* Whether a batch is open and its next message is queued. */ +static bool scr_worker_batch_next(void) { + return scr_worker_batch_left > 0 && scr_worker_events && + scr_mailbox_head_kind(scr_worker_events) == SCR_WORKER_MESSAGE; +} static const char *const scr_worker_method_names[] = { "ref", "unref", "hasRef", "terminate", "postMessage", "on", "once", "addListener", "prependListener", "prependOnceListener", "off", "removeListener", "removeAllListeners", @@ -700,8 +722,30 @@ static bool scr_channel_more(void) { return more; } +static bool scr_workers_more(void) { + if (scr_worker_batch_left > 0) { + if (scr_worker_batch_next()) { + scr_worker_batch_queued = true; + return true; + } + scr_worker_batch_left = 0; + scr_worker_batch_ended = true; + } + if (scr_channel_more()) return true; + /* One more pass after a batch when a port has something to deliver: the + * ports' poll phase for what its listeners posted. */ + bool ended = scr_worker_batch_ended; + scr_worker_batch_ended = false; + if (ended) + for (ScrWorkerView *view = scr_channel_ports; view; view = view->next) + if (scr_channel_port_ready(view)) return true; + return false; +} + static void scr_worker_dispatch(void) { - if (scr_channel_dispatch()) return; + bool batch = scr_worker_batch_queued; + scr_worker_batch_queued = false; + if (!batch && scr_channel_dispatch()) return; ScrWorkerView *port = scr_worker_port; if (port && port->closing) { port->closing = false; @@ -718,7 +762,16 @@ static void scr_worker_dispatch(void) { port->queued_head = event->next; if (!port->queued_head) port->queued_tail = NULL; event->next = NULL; - } else event = scr_mailbox_take(scr_worker_events); + } else { + event = scr_mailbox_take(scr_worker_events); + if (event && event->kind == SCR_WORKER_MESSAGE) { + if (scr_worker_batch_left == 0) { + size_t queued = 1 + scr_mailbox_count_kind(scr_worker_events, SCR_WORKER_MESSAGE); + scr_worker_batch_left = queued > SCR_WORKER_BATCH_MIN ? queued : SCR_WORKER_BATCH_MIN; + } + scr_worker_batch_left--; + } + } if (!event) return; if (event->kind == SCR_WORKER_WAKE) { scr_mail_event_free(event); return; } if (event->kind == SCR_WORKER_STDIN) { @@ -869,7 +922,7 @@ static bool scr_worker_events_install(void) { if (scr_context_atexit(scr_worker_events_cleanup) != 0) scr_trap("scriptc: out of memory\n"); scr_loop_set_workers(scr_workers_pending, scr_worker_events_ready, scr_worker_dispatch, scr_worker_events_pollfd, scr_worker_events_wait); - scr_loop_set_workers_more(scr_channel_more); + scr_loop_set_workers_more(scr_workers_more); return true; } diff --git a/packages/runtime/test/test_array.c b/packages/runtime/test/test_array.c index 3eea84dcf..02da2fac2 100644 --- a/packages/runtime/test/test_array.c +++ b/packages/runtime/test/test_array.c @@ -19,7 +19,7 @@ #include /* Sparse entries live in the array's out-of-line storage. */ -static size_t arr_sparse_len(const ScrArr *a) { return a->ext ? a->ext->sparse_len : 0; } +static size_t arr_sparse_len(const ScrArr *a) { return scr_arr_ext_of(a) ? scr_arr_ext_of(a)->sparse_len : 0; } #ifdef SCR_RC_AUDIT long scr_str_live_count(void); /* provided by scr_string.c */ @@ -396,8 +396,8 @@ static MockRec *mock_cyc_new(int value) { return r; } -static const ScrArrOps mock_ops = { &mock_retain, &mock_release, NULL }; -static const ScrArrOps mock_cyc_ops = { &mock_cyc_retain, &mock_cyc_release, &mock_trace }; +static ScrArrOps mock_ops = { &mock_retain, &mock_release, NULL }; +static ScrArrOps mock_cyc_ops = { &mock_cyc_retain, &mock_cyc_release, &mock_trace }; static void test_ref_elements(void) { #ifdef SCR_RC_AUDIT @@ -444,6 +444,58 @@ static void test_ref_elements(void) { #endif } +/* Element ops past the registry's capacity: every descriptor still finds its + * own retain and release, and the first ones keep their registry ids. */ +static void test_ops_overflow(void) { + enum { N = SCR_ARR_OPS_OVERFLOW + 8 }; + static ScrArrOps many[N]; + static ScrArr *arrays[N]; + for (int i = 0; i < N; i++) many[i] = (ScrArrOps){ &mock_retain, &mock_release, NULL, 0 }; + for (int i = 0; i < N; i++) { + arrays[i] = scr_arr_new_ops(&many[i], 0); + scr_arr_push_ref(arrays[i], mock_new(i)); + } + check(scr_arr_ops(arrays[0]) == &many[0], "a registered descriptor answers by id"); + check(scr_arr_ops(arrays[N - 1]) == &many[N - 1], "an overflow descriptor answers through the array"); + check(many[N - 1].id == SCR_ARR_OPS_OVERFLOW, "descriptors past the table get the overflow id"); + MockRec *r = (MockRec *)scr_arr_get_ref(arrays[N - 1], 0); + check(r->rc == 2 && r->value == N - 1, "an overflow array retains through its own ops"); + mock_release(r); + for (int i = 0; i < N; i++) scr_arr_release(arrays[i]); + check(mock_live == 0, "overflow arrays release their elements"); +} + +/* An array with out-of-line storage keeps it through every change of its + * dense allocation: growth, head room, unshift into new room, sorting + * (which rebuilds the storage) and a sparse tail. */ +static void test_ext_through_storage_changes(void) { + ScrArr *a = scr_arr_new(SCR_ELEM_F64, 0); + scr_arr_set_f64(a, -1, 7); /* a noncanonical property: out-of-line storage */ + check(scr_arr_ext_of(a) != NULL, "a numeric property creates out-of-line storage"); + for (int i = 0; i < 100; i++) scr_arr_push_f64(a, 100 - i); + check_f64(scr_arr_get_f64(a, -1), 7, "the property survives growth"); + for (int i = 0; i < 40; i++) scr_arr_shift_f64(a); + check_f64(scr_arr_get_f64(a, -1), 7, "the property survives head room"); + for (int i = 0; i < 60; i++) scr_arr_unshift_f64(a, 1000 + i); + check_f64(scr_arr_len(a), 120, "unshift after shifts keeps the length"); + check_f64(scr_arr_get_f64(a, -1), 7, "the property survives new head room"); + ScrArr *same = scr_arr_sort_primitive(a, false); + check(same == a, "an in-place sort returns the array"); + check_f64(scr_arr_get_f64(a, 0), 1, "sorting reorders the values"); + check_f64(scr_arr_get_f64(a, -1), 7, "the property survives a sort"); + scr_arr_set_f64(a, 5000000, 9); /* a sparse tail entry */ + check(arr_sparse_len(a) == 1 && scr_arr_get_f64(a, 5000000) == 9, "a sparse entry joins the out-of-line storage"); + scr_arr_release(same); /* the sort's result reference */ + scr_arr_release(a); + + ScrArr *empty = scr_arr_new(SCR_ELEM_F64, 0); + scr_arr_set_f64(empty, 0.5, 3); + check_f64(scr_arr_get_f64(empty, 0.5), 3, "an array without elements keeps a property"); + scr_arr_push_f64(empty, 1); + check_f64(scr_arr_get_f64(empty, 0.5), 3, "the property survives the first element"); + scr_arr_release(empty); +} + static void test_ref_cycle(void) { #ifdef SCR_RC_AUDIT long arrays0 = scr_arr_live_count(); @@ -1522,7 +1574,11 @@ static void test_inline_slots(void) { scr_arr_push_ref(words, scr_str_new("a", 1)); scr_arr_push_ref(words, scr_str_new("b", 1)); scr_arr_set_ref(words, 3000000, scr_str_new("far", 3)); - check(inline_slots(words) && arr_sparse_len(words) == 1, "a far index leaves inline slots in place"); + /* A sparse entry needs out-of-line storage, whose pointer lives before the + * dense allocation: the inline slots move out of line with their values. */ + check(!inline_slots(words) && arr_sparse_len(words) == 1 && + strcmp(((ScrStr *)scr_arr_peek_ref(words, 1))->data, "b") == 0, + "a far index moves the inline slots out of line"); scr_arr_unshift_ref(words, scr_str_new("z", 1)); check(!inline_slots(words) && scr_arr_len(words) == 3000002 && strcmp(((ScrStr *)scr_arr_peek_ref(words, 0))->data, "z") == 0 && @@ -2149,6 +2205,8 @@ int main(int argc, char **argv) { test_nested_rc(); test_index_of_includes(); test_ref_elements(); + test_ops_overflow(); + test_ext_through_storage_changes(); test_ref_cycle(); test_ref_truncate_cycle(); test_ref_trace_storage_boundaries(); diff --git a/packages/runtime/test/test_lib.c b/packages/runtime/test/test_lib.c index db8d29693..6bdbb085d 100644 --- a/packages/runtime/test/test_lib.c +++ b/packages/runtime/test/test_lib.c @@ -22,7 +22,7 @@ #include /* Sparse entries live in the array's out-of-line storage. */ -static size_t arr_sparse_len(const ScrArr *a) { return a->ext ? a->ext->sparse_len : 0; } +static size_t arr_sparse_len(const ScrArr *a) { return scr_arr_ext_of(a) ? scr_arr_ext_of(a)->sparse_len : 0; } #endif static int checks = 0; diff --git a/packages/runtime/test/test_map.c b/packages/runtime/test/test_map.c index 9957a994c..32bdb7be1 100644 --- a/packages/runtime/test/test_map.c +++ b/packages/runtime/test/test_map.c @@ -636,7 +636,7 @@ static void test_value_keys_and_copies(void) { } #ifdef SCR_RC_AUDIT -static const ScrArrOps map_arr_ops = { scr_map_retain_v, scr_map_release_v, scr_map_trace_v }; +static ScrArrOps map_arr_ops = { scr_map_retain_v, scr_map_release_v, scr_map_trace_v }; static void test_identity_cycles(void) { scr_collect_cycles(); diff --git a/packages/runtime/test/test_regex.c b/packages/runtime/test/test_regex.c index 6084c5102..6afa8391e 100644 --- a/packages/runtime/test/test_regex.c +++ b/packages/runtime/test/test_regex.c @@ -333,7 +333,7 @@ static void test_exec_groups(void) { check(full && lean && full->len == lean->len && lean->len == 2, "exec_groups keeps the capture count"); check(*scr_arr_state_ptr(lean, 0) == SCR_ARR_UNDEFINED && *scr_arr_state_ptr(lean, 1) == SCR_ARR_UNDEFINED, "exec_groups: unselected and nonparticipating captures are undefined"); - check(lean->ext == NULL || lean->ext->metadata == NULL, "exec_groups: no index/input properties"); + check(scr_arr_ext_of(lean) == NULL || scr_arr_ext_of(lean)->metadata == NULL, "exec_groups: no index/input properties"); scr_arr_release(full); scr_arr_release(lean); ScrStr *ab = S("ab"); diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index c8a14adbd..e539cea1d 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -1,8 +1,9 @@ /** Build the production CLI seed and its relocatable compiler assets. */ import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; import { compile } from "../packages/compiler/src/index.js"; import { NATIVE_TARGETS, nativeCodegenTarget } from "../packages/compiler/src/backend/targets.js"; import { tsgoExecutable } from "../packages/compiler/src/frontend/ts7/rpc-api.js"; @@ -11,12 +12,29 @@ import type { NativeToolchainManifest } from "../packages/compiler/src/native/to const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const output = resolve(process.argv[2] ?? join(root, ".scriptc/native-cli")); +const optimization = process.env["SCRIPTC_NATIVE_OPTIMIZATION"] === "dev" ? "dev" : "release"; +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +// SCRIPTC_NATIVE_PROFILE_GENERATE= builds a compiler that writes a +// raw profile per run (scripts/refresh-cli-profile.mjs). Optimized builds +// otherwise use the committed profile, or the indexed profile that +// SCRIPTC_NATIVE_PROFILE_USE names; an empty SCRIPTC_NATIVE_PROFILE_USE +// builds without one. +const profileGenerate = process.env["SCRIPTC_NATIVE_PROFILE_GENERATE"] || undefined; +const committedProfile = join(root, "packages/compiler/native/cli.profdata.gz"); +const requestedProfile = process.env["SCRIPTC_NATIVE_PROFILE_USE"]; +const useProfile = + profileGenerate === undefined && + optimization !== "dev" && + !sanitize && + requestedProfile !== "" && + (requestedProfile !== undefined || existsSync(committedProfile)); let target = nativeCodegenTarget(); if (target === null || target.platform === "wasi") throw new Error("a supported native host is required to build the compiler"); // Release executables use the runtime pack's libc baseline, even when the -// build host has a newer libc. Sanitizer builds use the host's sanitizer SDK. -if (target.name.endsWith("-gnu") && process.env["SCRIPTC_SAN"] !== "1") { +// build host has a newer libc. Sanitizer builds use the host's sanitizer SDK, +// and instrumented builds the host compiler driver's profile runtime. +if (target.name.endsWith("-gnu") && !sanitize && profileGenerate === undefined) { const architecture = target.architecture === "x64" ? "x86_64" : "aarch64"; process.env["SCRIPTC_TARGET"] = `${architecture}-linux-gnu.2.34`; target = nativeCodegenTarget()!; @@ -25,6 +43,14 @@ const bin = join(output, "bin"); const lib = join(output, "lib"); const seed = join(dirname(output), ".scriptc", basename(output) + "-seed"); for (const dir of [bin, lib, seed]) mkdirSync(dir, { recursive: true }); +let profileUse: string | undefined; +if (useProfile) { + profileUse = requestedProfile; + if (profileUse === undefined) { + profileUse = join(seed, "cli.profdata"); + writeFileSync(profileUse, gunzipSync(readFileSync(committedProfile))); + } +} const compiler = process.env["SCRIPTC_CC"] ?? target.defaultLinker; const compilerArgs = process.env["SCRIPTC_CC"] === undefined ? [...target.defaultLinkerArgs] : []; // Zig's C debug mode otherwise inserts UBSan calls into these objects. They @@ -84,10 +110,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"), outDir: seed, outPath: executable, backend: "llvm", - optimization: process.env["SCRIPTC_NATIVE_OPTIMIZATION"] === "dev" ? "dev" : "release", + optimization, strip: process.env["SCRIPTC_NATIVE_KEEP_SYMBOLS"] !== "1", ffiProfilePath: ffi, - sanitize: process.env["SCRIPTC_SAN"] === "1", + sanitize, + ...(profileGenerate === undefined ? {} : { profileGenerate }), + ...(profileUse === undefined ? {} : { profileUse }), emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1", }); if (!result.ok) diff --git a/scripts/lowering-order.mjs b/scripts/lowering-order.mjs new file mode 100644 index 000000000..81611e6a4 --- /dev/null +++ b/scripts/lowering-order.mjs @@ -0,0 +1,601 @@ +#!/usr/bin/env node +/* Finds lowering state whose result depends on the order reachable bodies + * lower in. + * + * Each program is lowered to IR in discovery order and with every seed of + * SCRIPTC_LOWER_SHUFFLE_SEED (lowerer.ts), and the IR is compared function + * by function. Output assembly does not depend on the order, so every + * difference is state one body's lowering leaves for another. The + * discovery-order run also audits the lowerer's Map and Set fields: a key + * that two bodies touch, at least one of them writing it, makes the field + * order-sensitive for both bodies. A function whose IR differs between + * orders is attributed to the order-sensitive fields of the body that + * produced it. + * + * node scripts/lowering-order.mjs [--stride 7] [--offset 0] [--limit N] + * [--seeds 1,2,3] [--jobs 8] [--entry ]... [--out ] + * [--fail-on names|content] + * + * Needs a built compiler (pnpm build). Writes /results.json and prints + * a ranking of fields by the programs whose IR they change. With --fail-on, + * it exits with status 1 when some program's IR differs between orders: + * in any way (names), or beyond names and ids (content). */ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { cpus, tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const dist = join(root, "packages/compiler/dist"); + +if (process.argv[2] === "--child") { + const [, , , entry, irPath, seed, auditPath, snapshotPath] = process.argv; + if (seed !== "-") process.env["SCRIPTC_LOWER_SHUFFLE_SEED"] = seed; + const module = (path) => import(pathToFileURL(join(dist, path)).href); + const audit = + auditPath === "-" + ? null + : installAudit( + await module("frontend/lowering/lowerer.js"), + await module("frontend/lowering/class-dynamic-dispatch.js"), + await module("frontend/lowering/deferred-module-initializers.js"), + ); + const snapshots = installSnapshot(await module("frontend/lowering/lowerer.js")); + const { compile } = await import(pathToFileURL(join(dist, "index.js")).href); + const outDir = join(tmpdir(), `scriptc-lowering-order-${process.pid}`); + const result = await compile(entry, { + outDir, + outPath: irPath, + backend: "llvm", + outputKind: "ir", + }); + if (audit !== null) writeFileSync(auditPath, JSON.stringify(audit.report())); + writeFileSync(snapshotPath, JSON.stringify(snapshots.at(-1) ?? {})); + process.exit(result.ok ? 0 : 3); +} + +/** Records the contents of the lowerer's Map and Set fields once reachable + * emit finishes, under keys that do not depend on the process: syntax + * nodes by file and span, symbols by name and declaration, IR values by id + * or name. Values are digested to a bounded depth. */ +function installSnapshot({ Lowerer }) { + const snapshots = []; + const nodeKey = (node) => { + try { + return `node:${node.getSourceFile().fileName}:${node.kind}:${node.pos}:${node.end}`; + } catch { + return `node:?:${node.kind}:${node.pos}:${node.end}`; + } + }; + const isNode = (value) => + typeof value.kind === "number" && + typeof value.pos === "number" && + typeof value.end === "number" && + typeof value.getSourceFile === "function"; + const isSymbol = (value) => + typeof value.escapedName === "string" && Array.isArray(value.declarationHandles); + const symbolKey = (symbol) => { + let declaration = ""; + try { + const first = symbol.declarations[0]; + if (first !== undefined) declaration = nodeKey(first); + } catch {} + return `symbol:${symbol.escapedName}:${declaration}`; + }; + const key = (value) => { + if (value === null || (typeof value !== "object" && typeof value !== "function")) + return `${typeof value}:${String(value)}`; + if (isNode(value)) return nodeKey(value); + if (isSymbol(value)) return symbolKey(value); + for (const field of ["name", "id", "localId"]) + if (typeof value[field] === "string" || typeof value[field] === "number") + return `${value.constructor?.name}:${field}:${value[field]}`; + if (value.def && typeof value.def.name === "string") + return `${value.constructor?.name}:def:${value.def.name}`; + return null; + }; + const digest = (value, depth) => { + if (value === null || value === undefined || typeof value !== "object") + return typeof value === "function" ? "fn" : (JSON.stringify(value) ?? "undefined"); + const stable = key(value); + if (depth === 0) return stable ?? `<${value.constructor?.name}>`; + if (isNode(value) || isSymbol(value)) return stable; + if (Array.isArray(value)) return `[${value.map((item) => digest(item, depth - 1)).join(",")}]`; + if (value instanceof Map) + return `Map{${[...value] + .map(([k, v]) => `${key(k) ?? "?"}=>${digest(v, depth - 1)}`) + .sort() + .join(",")}}`; + if (value instanceof Set) + return `Set{${[...value] + .map((item) => key(item) ?? digest(item, depth - 1)) + .sort() + .join(",")}}`; + const fields = Object.keys(value).sort(); + return `${value.constructor?.name ?? "Object"}{${fields.map((field) => `${field}:${digest(value[field], depth - 1)}`).join(",")}}`; + }; + const emitReachable = Lowerer.prototype.emitReachable; + Lowerer.prototype.emitReachable = function (...args) { + const result = emitReachable.apply(this, args); + const snapshot = {}; + for (const [field, value] of Object.entries(this)) { + if (!(value instanceof Map) && !(value instanceof Set)) continue; + const entries = {}; + const unkeyed = []; + for (const item of value instanceof Map ? value : [...value].map((v) => [v, true])) { + const [k, v] = item; + const stable = key(k); + const text = digest(v, 3); + if (stable === null) unkeyed.push(text); + else entries[stable] = text; + } + snapshot[field] = { entries, unkeyed: unkeyed.sort() }; + } + snapshots.push(snapshot); + return result; + }; + return snapshots; +} + +/** Wraps the lowering methods that produce bodies and the lowerer's Map and + * Set fields; records which bodies touch which keys. */ +function installAudit({ Lowerer }, { ClassDynamicDispatch }, { DeferredModuleInitializers }) { + const proto = Lowerer.prototype; + const tokens = []; + const stack = []; + const fields = new Map(); + const objectIds = new Map(); + const keyId = (key) => { + if (key === null || (typeof key !== "object" && typeof key !== "function")) + return `${typeof key}:${String(key)}`; + let id = objectIds.get(key); + if (id === undefined) objectIds.set(key, (id = objectIds.size)); + return `object:${id}`; + }; + const current = () => (stack.length === 0 ? null : stack[stack.length - 1]); + const touch = (field, key, write) => { + const token = current(); + if (token === null) return; + let keys = fields.get(field); + if (keys === undefined) fields.set(field, (keys = new Map())); + const id = key === ITERATE ? "*" : keyId(key); + let entry = keys.get(id); + if (entry === undefined) keys.set(id, (entry = { readers: new Set(), writers: new Set() })); + (write ? entry.writers : entry.readers).add(token.id); + }; + const ITERATE = Symbol("iterate"); + const instrument = (field, value) => { + const read = (key) => touch(field, key, false); + const write = (key) => { + touch(field, key, true); + // A write changes what every iteration of the field observes. + touch(field, ITERATE, true); + }; + const iterate = () => touch(field, ITERATE, false); + const base = value instanceof Map ? Map.prototype : Set.prototype; + for (const name of ["get", "has"]) { + if (!(name in base)) continue; + const original = base[name]; + value[name] = function (key) { + read(key); + return original.call(this, key); + }; + } + for (const name of ["set", "add", "delete"]) { + if (!(name in base)) continue; + const original = base[name]; + value[name] = function (key, ...rest) { + write(key); + return original.call(this, key, ...rest); + }; + } + for (const name of ["forEach", "keys", "values", "entries", Symbol.iterator]) { + const original = base[name]; + value[name] = function (...args) { + iterate(); + return original.apply(this, args); + }; + } + const size = Object.getOwnPropertyDescriptor(base, "size").get; + Object.defineProperty(value, "size", { + get() { + iterate(); + return size.call(this); + }, + }); + }; + const emitReachable = proto.emitReachable; + proto.emitReachable = function (...args) { + for (const [field, value] of Object.entries(this)) + if (value instanceof Map || value instanceof Set) instrument(field, value); + return emitReachable.apply(this, args); + }; + const produced = (result, into) => { + if (result === null || result === undefined) return; + if (Array.isArray(result)) for (const fn of result) produced(fn, into); + else if (typeof result === "object" && typeof result.name === "string") into.add(result.name); + }; + // Passes over the lowered bodies after the worklist: the functions they + // create belong to the pass. + const passes = new Map(); + const wrapPass = (owner, label, method) => { + const original = owner.prototype[method]; + owner.prototype[method] = function (lowerer, ...rest) { + const implicit = lowerer.implicitFns?.length ?? 0; + const lifted = lowerer.liftedFns?.length ?? 0; + try { + return original.call(this, lowerer, ...rest); + } finally { + let names = passes.get(label); + if (names === undefined) passes.set(label, (names = new Set())); + for (const fn of (lowerer.implicitFns ?? []).slice(implicit)) names.add(fn.name); + for (const fn of (lowerer.liftedFns ?? []).slice(lifted)) names.add(fn.name); + } + }; + }; + wrapPass(ClassDynamicDispatch, "ClassDynamicDispatch.process", "process"); + wrapPass(ClassDynamicDispatch, "ClassDynamicDispatch.finalize", "finalize"); + wrapPass(DeferredModuleInitializers, "DeferredModuleInitializers.process", "process"); + for (const name of [ + "lowerFunction", + "lowerClassCtor", + "lowerClassMethodMember", + "lowerClassMembers", + "lowerFileInit", + "lowerGenericInstance", + "throwingSetterFn", + ]) { + const original = proto[name]; + if (typeof original !== "function") continue; + proto[name] = function (...args) { + const token = { id: tokens.length, names: new Set() }; + tokens.push(token); + const implicit = this.implicitFns?.length ?? 0; + const lifted = this.liftedFns?.length ?? 0; + stack.push(token); + try { + const result = original.apply(this, args); + produced(result, token.names); + return result; + } finally { + stack.pop(); + for (const fn of (this.implicitFns ?? []).slice(implicit)) token.names.add(fn.name); + for (const fn of (this.liftedFns ?? []).slice(lifted)) token.names.add(fn.name); + } + }; + } + return { + report() { + const out = {}; + for (const [field, keys] of fields) { + const involved = new Set(); + let sensitive = 0; + for (const { readers, writers } of keys.values()) { + const all = new Set([...readers, ...writers]); + if (writers.size === 0 || all.size < 2) continue; + sensitive++; + for (const id of all) for (const fn of tokens[id].names) involved.add(fn); + } + if (sensitive > 0) out[field] = { sensitiveKeys: sensitive, functions: [...involved] }; + } + for (const [label, names] of passes) + if (names.size > 0) out[`pass ${label}`] = { sensitiveKeys: 0, functions: [...names] }; + return out; + }, + }; +} + +const { values } = parseArgs({ + options: { + stride: { type: "string", default: "7" }, + offset: { type: "string", default: "0" }, + limit: { type: "string", default: "100000" }, + seeds: { type: "string", default: "1,2,3" }, + jobs: { type: "string", default: String(Math.max(1, Math.floor(cpus().length / 2))) }, + entry: { type: "string", multiple: true, default: [] }, + out: { type: "string", default: join(tmpdir(), "scriptc-lowering-order") }, + "no-audit": { type: "boolean", default: false }, + "fail-on": { type: "string" }, + }, +}); +if (values["fail-on"] !== undefined && !["names", "content"].includes(values["fail-on"])) + throw new Error(`--fail-on takes names or content, not ${values["fail-on"]}`); +const out = resolve(values.out); +mkdirSync(out, { recursive: true }); +const seeds = values.seeds.split(",").filter(Boolean); +const corpus = join(root, "tests/corpus"); +const programs = [ + ...readdirSync(corpus) + .filter((name) => /^\d+-.*\.ts$/.test(name)) + .filter((name) => Number.parseInt(name, 10) % Number(values.stride) === Number(values.offset)) + .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10)) + .slice(0, Number(values.limit)) + .map((name) => join(corpus, name)), + ...values.entry.map((entry) => resolve(entry)), +]; + +const child = (entry, irPath, seed, auditPath, snapshotPath) => + new Promise((done) => { + const proc = spawn( + process.execPath, + [ + "--max-old-space-size=8192", + fileURLToPath(import.meta.url), + "--child", + entry, + irPath, + seed, + auditPath, + snapshotPath, + ], + { stdio: ["ignore", "ignore", "pipe"], env: { ...process.env, SCRIPTC_NO_CACHE: "1" } }, + ); + let stderr = ""; + proc.stderr.on("data", (chunk) => (stderr += chunk)); + proc.on("close", (code) => done({ ok: code === 0, stderr: stderr.slice(-2000) })); + }); + +const loadIr = (path) => JSON.parse(readFileSync(path, "utf8")); +const firstDiff = (x, y, path = "") => { + if (JSON.stringify(x) === JSON.stringify(y)) return null; + if (typeof x !== "object" || typeof y !== "object" || x === null || y === null) return path; + for (const key of new Set([...Object.keys(x), ...Object.keys(y)])) { + const found = firstDiff(x[key], y[key], `${path}/${key}`); + if (found !== null) return found; + } + return path; +}; +const differingFunctions = (a, b) => { + const left = new Map(a.functions.map((fn) => [fn.name, fn])); + const right = new Map(b.functions.map((fn) => [fn.name, fn])); + const names = new Set(); + const paths = {}; + for (const [name, fn] of left) { + const other = right.get(name); + if (other === undefined) names.add(name); + else if (JSON.stringify(other) !== JSON.stringify(fn)) { + names.add(name); + paths[name] = firstDiff(fn, other).replace(/\/\d+/g, "/#"); + } + } + for (const name of right.keys()) if (!left.has(name)) names.add(name); + const topLevel = []; + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) + if (key !== "functions" && JSON.stringify(a[key]) !== JSON.stringify(b[key])) + topLevel.push(key); + if (a.functions.map((fn) => fn.name).join("\n") !== b.functions.map((fn) => fn.name).join("\n")) + topLevel.push("function order"); + return { functions: [...names], topLevel, paths }; +}; + +/** Keys whose strings name functions, locals, globals, shapes, unions, + * classes and labels. Lowering numbers these in creation order; every other + * string (literal values, field names, library functions) is compared as + * is. A `name` names a function, record or union when it is one of the + * module's numbered definitions or starts with `%`. */ +const ID_KEYS = new Set([ + "base", + "callee", + "captures", + "catchLocalId", + "className", + "fnName", + "fromId", + "globalId", + "id", + "int32Locals", + "int32Params", + "label", + "labels", + "localId", + "retag", + "shapeId", + "targets", + "unionId", +]); + +/** The module with every numbered function, record and union renamed by a + * hash of its own definition (ids with their digits stripped), other + * numbered ids by first use within their function or table entry, source + * locations dropped, and tables sorted. Orders whose canonical modules + * match differ only in names and ids numbered in creation order. */ +const canonicalModule = (mod) => { + const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 12); + const m = JSON.parse(JSON.stringify(mod, (k, v) => (k === "loc" ? undefined : v))); + const definitions = new Set([ + ...(m.functions ?? []).map((fn) => fn.name), + ...["records", "unions"].flatMap((key) => (m[key] ?? []).map((item) => item.id)), + ]); + const isId = (key, value) => + ID_KEYS.has(key) || (key === "name" && (definitions.has(value) || value.startsWith("%"))); + /** `v` with the strings under id keys passed through `id`. */ + const mapIds = (v, id, key) => { + if (typeof v === "string") return isId(key, v) ? id(v) : v; + if (Array.isArray(v)) return v.map((x) => mapIds(x, id, key)); + if (v && typeof v === "object") + return Object.fromEntries(Object.keys(v).map((k) => [k, mapIds(v[k], id, k)])); + return v; + }; + const strip = (v) => JSON.stringify(mapIds(v, (id) => id.replace(/\d+/g, "#"))); + const rename = new Map(); + for (const fn of m.functions ?? []) { + if (!/\d/.test(fn.name)) continue; + const { name, ...rest } = fn; + rename.set(name, `${name.replace(/\d+/g, "#")}~${hash(strip(rest))}`); + } + for (const key of ["records", "unions"]) + for (const item of m[key] ?? []) { + const { id, ...rest } = item; + if (typeof id === "string" && /\d/.test(id)) + rename.set(id, `${id.replace(/\d+/g, "#")}~${hash(strip(rest))}`); + } + const canonical = (item) => { + const scope = new Map(); + return mapIds(item, (value) => { + const renamed = rename.get(value); + if (renamed !== undefined) return renamed; + if (!/\d/.test(value)) return value; + let id = scope.get(value); + if (id === undefined) scope.set(value, (id = scope.size)); + return `${value.replace(/\d+/g, "#")}@${id}`; + }); + }; + const out = {}; + for (const key of Object.keys(m)) + out[key] = Array.isArray(m[key]) + ? m[key].map((item) => JSON.stringify(canonical(item))).sort() + : JSON.stringify(canonical(m[key])); + return out; +}; +/** Canonical entries present in one order only, by table. */ +const contentDifferences = (a, b) => { + const found = {}; + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { + if (JSON.stringify(a[key]) === JSON.stringify(b[key])) continue; + if (!Array.isArray(a[key]) || !Array.isArray(b[key])) { + found[key] = ["(differs)"]; + continue; + } + const other = new Set(b[key]); + found[key] = a[key] + .filter((item) => !other.has(item)) + .map((item) => JSON.parse(item).name ?? JSON.parse(item).id ?? "?"); + } + return found; +}; + +/** Fields whose contents after reachable emit differ between two orders. */ +const differingState = (a, b) => { + const fields = []; + for (const field of new Set([...Object.keys(a), ...Object.keys(b)])) { + const x = a[field] ?? { entries: {}, unkeyed: [] }; + const y = b[field] ?? { entries: {}, unkeyed: [] }; + const keys = new Set([...Object.keys(x.entries), ...Object.keys(y.entries)]); + let differs = [...keys].some((k) => x.entries[k] !== y.entries[k]); + if (!differs) differs = JSON.stringify(x.unkeyed) !== JSON.stringify(y.unkeyed); + if (differs) fields.push(field); + } + return fields; +}; + +const results = []; +let next = 0; +await Promise.all( + Array.from({ length: Number(values.jobs) }, async () => { + while (next < programs.length) { + const entry = programs[next++]; + const name = basename(entry).replace(/\.ts$/, ""); + const dir = join(out, name); + mkdirSync(dir, { recursive: true }); + const audit = values["no-audit"] ? "-" : join(dir, "audit.json"); + const base = await child( + entry, + join(dir, "base.ir.json"), + "-", + audit, + join(dir, "base.state.json"), + ); + if (!base.ok) { + results.push({ program: name, status: "does not compile" }); + continue; + } + const diffs = {}; + const stateDiffs = {}; + let baseCanonical = null; + for (const seed of seeds) { + const run = await child( + entry, + join(dir, `seed${seed}.ir.json`), + seed, + "-", + join(dir, `seed${seed}.state.json`), + ); + if (!run.ok) { + // A program that lowers in discovery order but not in this one + // differs in content. + diffs[seed] = { failed: run.stderr, content: { lowering: ["fails in this order"] } }; + continue; + } + const diff = differingFunctions( + loadIr(join(dir, "base.ir.json")), + loadIr(join(dir, `seed${seed}.ir.json`)), + ); + const state = differingState( + loadIr(join(dir, "base.state.json")), + loadIr(join(dir, `seed${seed}.state.json`)), + ); + if (state.length > 0) stateDiffs[seed] = state; + if (diff.functions.length > 0 || diff.topLevel.length > 0) { + baseCanonical ??= canonicalModule(loadIr(join(dir, "base.ir.json"))); + diff.content = contentDifferences( + baseCanonical, + canonicalModule(loadIr(join(dir, `seed${seed}.ir.json`))), + ); + diffs[seed] = diff; + } + } + const auditReport = audit === "-" ? {} : JSON.parse(readFileSync(audit, "utf8")); + const changed = new Set(Object.values(diffs).flatMap((diff) => diff.functions ?? [])); + const attribution = {}; + for (const fn of changed) { + const fields = Object.entries(auditReport) + .filter(([, entry]) => entry.functions.includes(fn)) + .map(([field]) => field); + attribution[fn] = fields; + } + results.push({ + program: name, + status: + Object.keys(diffs).length === 0 + ? "order-independent" + : Object.values(diffs).some((diff) => Object.keys(diff.content ?? {}).length > 0) + ? "order-dependent content" + : "order-dependent names", + diffs, + attribution, + sensitiveFields: Object.keys(auditReport), + stateFields: [...new Set(Object.values(stateDiffs).flat())], + }); + process.stderr.write( + `${results.length}/${programs.length} ${name}: ${results.at(-1).status}\n`, + ); + } + }), +); +writeFileSync(join(out, "results.json"), JSON.stringify(results, null, 2)); + +const dependent = results.filter((r) => r.status.startsWith("order-dependent")); +const content = results.filter((r) => r.status === "order-dependent content"); +const byField = new Map(); +let unattributed = 0; +for (const r of dependent) { + const fields = new Set(Object.values(r.attribution).flat()); + if (fields.size === 0) unattributed++; + for (const field of fields) byField.set(field, (byField.get(field) ?? 0) + 1); +} +console.log( + `programs ${results.length}: ${dependent.length} order-dependent (${content.length} beyond names and ids), ${results.filter((r) => r.status === "does not compile").length} do not compile`, +); +for (const r of content) + console.log( + ` ${r.program}: ${Object.values(r.diffs) + .flatMap((diff) => + Object.entries(diff.content ?? {}).map( + ([table, items]) => `${table}: ${items.slice(0, 4).join(", ")}`, + ), + ) + .slice(0, 3) + .join("; ")}`, + ); +for (const [field, count] of [...byField].sort((a, b) => b[1] - a[1])) + console.log(`${String(count).padStart(5)} ${field}`); +console.log(`${String(unattributed).padStart(5)} (no audited field)`); +const failing = + values["fail-on"] === "names" ? dependent : values["fail-on"] === "content" ? content : []; +if (failing.length > 0) { + console.log(`${failing.length} programs lower to different IR in another order`); + process.exitCode = 1; +} diff --git a/scripts/refresh-cli-profile.mjs b/scripts/refresh-cli-profile.mjs new file mode 100644 index 000000000..e9554e911 --- /dev/null +++ b/scripts/refresh-cli-profile.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/* Refreshes packages/compiler/native/cli.profdata.gz, the profile that release + * builds of the native compiler optimize with (scripts/build-native-cli.mts). + * + * Builds an instrumented native compiler for this Linux host, runs it on the + * training builds below, and merges the raw profiles it writes into one + * sparse indexed profile. The training builds are tsc-ts (a cold `speed` + * build, then a rebuild after an edit), every seventh corpus program at the + * default optimization and a further slice in `dev`, and the runtime + * benchmark workloads. Run it on a quiet Linux x64 host after changes that + * move much compiler code; a stale profile only loses part of its gain. A + * refresh takes about 11 minutes on 16 cores. + * + * node scripts/refresh-cli-profile.mjs --tsc-ts [--jobs 8] + */ +import { spawn, spawnSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { cpus, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { constants, gzipSync } from "node:zlib"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const { values } = parseArgs({ + options: { + "tsc-ts": { type: "string" }, + jobs: { type: "string", default: String(Math.max(1, Math.floor(cpus().length / 2))) }, + output: { type: "string", default: join(root, "packages/compiler/native/cli.profdata.gz") }, + work: { type: "string" }, + instrumented: { type: "string" }, + help: { type: "boolean", default: false }, + }, +}); +if (values.help || values["tsc-ts"] === undefined) { + console.log( + "Usage: node scripts/refresh-cli-profile.mjs --tsc-ts [--jobs N] [--output ] [--work ] [--instrumented ]", + ); + process.exit(values.help ? 0 : 2); +} +if (process.platform !== "linux" || process.arch !== "x64") + throw new Error("the compiler profile is collected on Linux x64"); +const tscTs = resolve(values["tsc-ts"]); +const tscTsPackage = join(tscTs, "packages/tsc-ts"); +if (!existsSync(join(tscTsPackage, "src/main.ts"))) + throw new Error(`${tscTs} is not a tsc-ts checkout with installed dependencies`); +const jobs = Number(values.jobs); +const work = resolve(values.work ?? mkdtempSync(join(tmpdir(), "scriptc-cli-profile-"))); +const raw = join(work, "raw"); +const builds = join(work, "builds"); +rmSync(raw, { recursive: true, force: true }); +for (const dir of [raw, builds]) mkdirSync(dir, { recursive: true }); +const log = join(work, "training.log"); + +// Every run merges its counters into one of four pooled raw profiles. +const profileFile = join(raw, "cli-%4m.profraw"); +// --instrumented reuses a compiler an earlier run built in /cli. +const cli = resolve(values.instrumented ?? join(work, "cli")); +if (values.instrumented === undefined) { + console.log(`building the instrumented compiler in ${cli}`); + const built = spawnSync( + process.execPath, + [ + "--max-old-space-size=8192", + "--import", + "tsx", + join(root, "scripts/build-native-cli.mts"), + cli, + ], + { + cwd: root, + stdio: "inherit", + env: { ...process.env, SCRIPTC_NATIVE_PROFILE_GENERATE: profileFile }, + }, + ); + if (built.status !== 0) throw new Error("the instrumented compiler did not build"); +} +const scriptc = join(cli, "bin/scriptc"); +// The compiler trains with the helper it bundles, so a helper override in +// the environment does not apply. +const trainingEnv = { ...process.env, LLVM_PROFILE_FILE: profileFile }; +delete trainingEnv["SCRIPTC_LLVM_HELPER"]; + +/** Runs the instrumented compiler; a failing build still profiles the + * paths it took. */ +function run(args, options) { + return new Promise((done) => { + const child = spawn(scriptc, args, { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { ...trainingEnv, SCRIPTC_CACHE_DIR: options.cache }, + }); + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + child.on("close", (code) => { + appendFileSync(log, `$ scriptc ${args.join(" ")}\n${output}exit ${code}\n`); + done(code === 0); + }); + }); +} + +async function pool(tasks) { + let next = 0; + let passed = 0; + await Promise.all( + Array.from({ length: jobs }, async () => { + while (next < tasks.length) if (await tasks[next++]()) passed++; + }), + ); + return passed; +} + +const programNumber = (name) => Number.parseInt(name, 10); +const corpus = readdirSync(join(root, "tests/corpus")) + .filter((name) => /^\d+-.*\.ts$/.test(name)) + .sort((a, b) => programNumber(a) - programNumber(b)); +const training = [ + ...corpus + .filter((name) => programNumber(name) % 7 === 0) + .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "release" })), + ...corpus + .filter((name) => programNumber(name) % 14 === 7) + .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "dev" })), + ...readdirSync(join(root, "benchmarks/runtime/workloads")) + .filter((name) => name.endsWith(".ts")) + .map((name) => ({ + file: join(root, "benchmarks/runtime/workloads", name), + optimization: "release", + })), +]; + +const started = Date.now(); +console.log("training: tsc-ts, cold and after an edit"); +const tscCache = join(work, "cache-tsc-ts"); +const tscArgs = [ + "build", + "src/main.ts", + "--optimization=speed", + "--detached-exit", + "-o", + join(builds, "tsc"), +]; +await run(tscArgs, { cwd: tscTsPackage, cache: tscCache }); +const edited = join(tscTsPackage, "src/main.ts"); +const source = readFileSync(edited, "utf8"); +try { + writeFileSync(edited, source + "\n// profile training edit\n"); + await run(tscArgs, { cwd: tscTsPackage, cache: tscCache }); +} finally { + writeFileSync(edited, source); +} + +console.log(`training: ${training.length} programs, ${jobs} at a time`); +const passed = await pool( + training.map((program, index) => () => { + const cache = join(work, `cache-${index}`); + return run( + [ + "build", + program.file, + `--optimization=${program.optimization}`, + "-o", + join(builds, `program-${index}`), + ], + { cwd: root, cache }, + ).then((ok) => { + rmSync(cache, { recursive: true, force: true }); + rmSync(join(builds, `program-${index}`), { force: true }); + return ok; + }); + }), +); +console.log( + `trained in ${Math.round((Date.now() - started) / 1000)} s; ${passed} of ${training.length} programs built (failures still profile; see ${log})`, +); + +const profiles = readdirSync(raw) + .filter((name) => name.endsWith(".profraw")) + .map((name) => join(raw, name)); +rmSync(builds, { recursive: true, force: true }); +const indexed = join(work, "cli.profdata"); +const merged = spawnSync(scriptc, ["merge-profiles", "--sparse", "-o", indexed, ...profiles], { + stdio: "inherit", + env: { ...trainingEnv, LLVM_PROFILE_FILE: join(work, "merge.profraw") }, +}); +if (merged.status !== 0) throw new Error("merge-profiles failed"); +// The indexed profile compresses to about an eighth of its size. +const output = resolve(values.output); +writeFileSync(output, gzipSync(readFileSync(indexed), { level: constants.Z_BEST_COMPRESSION })); +console.log( + `wrote ${output} (${statSync(output).size} bytes; ${statSync(indexed).size} uncompressed)`, +); +// A work directory the caller named stays for inspection. +if (values.work === undefined) rmSync(work, { recursive: true, force: true }); diff --git a/tests/corpus/1541-fs-readdir-dirent.ts b/tests/corpus/1541-fs-readdir-dirent.ts index ac30ab615..8db6243b2 100644 --- a/tests/corpus/1541-fs-readdir-dirent.ts +++ b/tests/corpus/1541-fs-readdir-dirent.ts @@ -15,7 +15,7 @@ function tail(path: string): string { } return path.slice(i + 1); } -const dir = `/tmp/scr-dirent-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const dir = `/tmp/scr-dirent-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir); fs.mkdirSync(`${dir}/sub-a`); diff --git a/tests/corpus/5565-fs-dirent-untyped.mjs b/tests/corpus/5565-fs-dirent-untyped.mjs index acb5be704..9751c3d1f 100644 --- a/tests/corpus/5565-fs-dirent-untyped.mjs +++ b/tests/corpus/5565-fs-dirent-untyped.mjs @@ -9,7 +9,7 @@ function tail(path) { while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; return path.slice(i + 1); } -const dir = `/tmp/scr-dirent-untyped-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const dir = `/tmp/scr-dirent-untyped-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(`${dir}/sub`, { recursive: true }); fs.writeFileSync(`${dir}/a.txt`, "x"); diff --git a/tests/corpus/5567-fs-cpsync.ts b/tests/corpus/5567-fs-cpsync.ts index 7e258ca85..ef736ec92 100644 --- a/tests/corpus/5567-fs-cpsync.ts +++ b/tests/corpus/5567-fs-cpsync.ts @@ -8,7 +8,7 @@ function tail(path: string): string { while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; return path.slice(i + 1); } -const root = `/tmp/scr-cpsync-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const root = `/tmp/scr-cpsync-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); fs.mkdirSync(root); const home = process.cwd(); diff --git a/tests/corpus/5568-fs-cp-errors.js b/tests/corpus/5568-fs-cp-errors.js index e9a87f14e..9089d612c 100644 --- a/tests/corpus/5568-fs-cp-errors.js +++ b/tests/corpus/5568-fs-cp-errors.js @@ -11,7 +11,7 @@ function tail(path) { while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; return path.slice(i + 1); } -const root = `/tmp/scr-cp-errors-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const root = `/tmp/scr-cp-errors-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); fs.mkdirSync(root); const home = process.cwd(); diff --git a/tests/corpus/5569-fs-cp-symlinks.ts b/tests/corpus/5569-fs-cp-symlinks.ts index ae4621ce6..2bc3c8ba4 100644 --- a/tests/corpus/5569-fs-cp-symlinks.ts +++ b/tests/corpus/5569-fs-cp-symlinks.ts @@ -9,7 +9,7 @@ function tail(path: string): string { while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; return path.slice(i + 1); } -const root = `/tmp/scr-cp-links-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const root = `/tmp/scr-cp-links-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); fs.mkdirSync(root); const home = process.cwd(); diff --git a/tests/corpus/5570-fs-cp-async.ts b/tests/corpus/5570-fs-cp-async.ts index 9f3ce6c87..d83750095 100644 --- a/tests/corpus/5570-fs-cp-async.ts +++ b/tests/corpus/5570-fs-cp-async.ts @@ -9,7 +9,7 @@ function tail(path: string): string { while (i >= 0 && path.charAt(i) !== "/" && path.charAt(i) !== "\\") i = i - 1; return path.slice(i + 1); } -const root = `/tmp/scr-cp-async-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}`; +const root = `/tmp/scr-cp-async-${tail(process.argv[1] === undefined ? "x" : process.argv[1])}-${process.pid}`; if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); fs.mkdirSync(root); const home = process.cwd(); diff --git a/tests/corpus/6211-worker-message-batches.ts b/tests/corpus/6211-worker-message-batches.ts new file mode 100644 index 000000000..75583c027 --- /dev/null +++ b/tests/corpus/6211-worker-message-batches.ts @@ -0,0 +1,87 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Messages queued for a thread while one of its 'message' listeners runs +// are delivered before the loop goes on to immediates, in both directions: +// worker to parent, and parent to worker. Microtasks a listener queues run +// before the next message. The first listener waits on a shared flag until +// the other thread has posted the rest, so they are queued before it +// returns. +type Setup = { role: "sender" | "receiver"; flag: Int32Array }; + +function waitFor(flag: Int32Array, index: number): void { + while (Atomics.load(flag, index) === 0) Atomics.wait(flag, index, 0, 50); +} + +function signal(flag: Int32Array, index: number): void { + Atomics.store(flag, index, 1); + Atomics.notify(flag, index); +} + +/** Records the order of the batch the first message's listener waits for. */ +function receive(on: (listener: (m: unknown) => void) => void, flag: Int32Array, done: (log: string[]) => void): void { + const log: string[] = []; + let count = 0; + on((m) => { + count++; + log.push(`message ${String(m)}`); + if (count === 1) { + signal(flag, 0); + waitFor(flag, 1); + process.nextTick(() => log.push("tick")); + queueMicrotask(() => log.push("microtask")); + setImmediate(() => { + log.push("immediate"); + done(log); + }); + } + if (count === 4) queueMicrotask(() => log.push(`microtask after ${String(m)}`)); + }); +} + +function send(post: (m: string) => void, flag: Int32Array, prefix: string): void { + post(`${prefix}1`); + waitFor(flag, 0); + for (let i = 2; i <= 6; i++) post(`${prefix}${i}`); + signal(flag, 1); +} + +if (isMainThread) { + // Worker to parent: the worker sends, this thread receives. + const first = new Int32Array(new SharedArrayBuffer(8)); + const sender = new Worker(new URL(import.meta.url), { workerData: { role: "sender", flag: first } satisfies Setup }); + receive( + (listener) => sender.on("message", listener), + first, + (log) => { + console.log("worker to parent"); + for (const line of log) console.log(line); + }, + ); + sender.on("exit", () => { + // Parent to worker: this thread sends, the worker receives and reports its log. + const second = new Int32Array(new SharedArrayBuffer(8)); + const receiver = new Worker(new URL(import.meta.url), { + workerData: { role: "receiver", flag: second } satisfies Setup, + }); + receiver.on("message", (log: unknown) => { + if (!Array.isArray(log)) return; + console.log("parent to worker"); + for (const line of log) console.log(String(line)); + }); + receiver.on("online", () => send((m) => receiver.postMessage(m), second, "p")); + receiver.on("exit", (code: number) => console.log("exit", code)); + }); +} else { + const setup = workerData as Setup; + const port = parentPort!; + if (setup.role === "sender") send((m) => port.postMessage(m), setup.flag, "w"); + else + receive( + (listener) => port.on("message", listener), + setup.flag, + (log) => { + port.postMessage(log); + port.close(); + }, + ); +} diff --git a/tests/corpus/6212-worker-message-batch-ports.ts b/tests/corpus/6212-worker-message-batch-ports.ts new file mode 100644 index 000000000..3f5a2a476 --- /dev/null +++ b/tests/corpus/6212-worker-message-batch-ports.ts @@ -0,0 +1,47 @@ +import { MessageChannel, Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// A MessageChannel message that a worker 'message' listener posts while +// more worker messages are queued arrives after those messages and before +// setImmediate callbacks, as in Node. The listener waits on a shared flag +// until the worker has posted the rest. +function waitFor(flag: Int32Array, index: number): void { + while (Atomics.load(flag, index) === 0) Atomics.wait(flag, index, 0, 50); +} +function signal(flag: Int32Array, index: number): void { + Atomics.store(flag, index, 1); + Atomics.notify(flag, index); +} + +if (isMainThread) { + const flag = new Int32Array(new SharedArrayBuffer(8)); + const worker = new Worker(new URL(import.meta.url), { workerData: flag }); + const channel = new MessageChannel(); + const log: string[] = []; + channel.port2.on("message", (m: unknown) => { + log.push(`channel ${String(m)}`); + }); + let count = 0; + worker.on("message", (m: unknown) => { + count++; + log.push(`worker ${String(m)}`); + if (count === 1) { + signal(flag, 0); + waitFor(flag, 1); + channel.port1.postMessage("c1"); + setImmediate(() => { + log.push("immediate"); + setImmediate(() => { + log.push("second immediate"); + for (const line of log) console.log(line); + channel.port1.close(); + }); + }); + } + }); +} else { + const flag = workerData as Int32Array; + parentPort!.postMessage("w1"); + waitFor(flag, 0); + for (let i = 2; i <= 6; i++) parentPort!.postMessage(`w${i}`); + signal(flag, 1); +} diff --git a/tests/corpus/6721-tls-alpn-protocols.ts b/tests/corpus/6721-tls-alpn-protocols.ts new file mode 100644 index 000000000..f68fb2840 --- /dev/null +++ b/tests/corpus/6721-tls-alpn-protocols.ts @@ -0,0 +1,75 @@ +// tls.connect and tls.createServer with ALPNProtocols: the server answers +// with the first of its own protocols the client offers, both sockets +// report it as alpnProtocol, and a client that offers none of the +// server's fails with ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL. An +// array of strings, a Buffer or a Uint8Array in the wire format, an empty +// list and a falsy value all work as in Node, and invalid values throw +// Node's errors from tls.connect. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const dir = "tests/fixtures/server/certs/"; +const ca = readFileSync(dir + "ca.pem"); +const server = tls.createServer( + { + cert: readFileSync(dir + "localhost.pem"), + key: readFileSync(dir + "localhost-key.pem"), + ALPNProtocols: ["h2", "x/1"], + }, + (socket) => { + socket.end(`server:${String(socket.alpnProtocol)}`); + }, +); + +function attempt(port: number, label: string, protocols: unknown): Promise { + return new Promise((resolve) => { + const socket = tls.connect({ host: "localhost", port, ca, ALPNProtocols: protocols as any }, () => { + console.log(label, "client:", JSON.stringify(socket.alpnProtocol)); + }); + let data = ""; + socket.on("data", (chunk: Buffer) => (data += chunk.toString())); + socket.on("end", () => { + console.log(label, data); + resolve(); + }); + socket.on("error", (err) => { + console.log(label, "error:", (err as NodeJS.ErrnoException).code); + resolve(); + }); + }); +} + +function invalid(label: string, protocols: unknown): void { + try { + const socket = tls.connect({ host: "127.0.0.1", port: 1, ALPNProtocols: protocols as any }); + socket.on("error", () => {}); + socket.destroy(); + console.log(label, "accepted"); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + } +} + +server.listen(0, "127.0.0.1", async () => { + const port = (server.address() as { port: number }).port; + await attempt(port, "strings", ["x/1", "h2"]); + await attempt(port, "only-second", ["zz", "x/1"]); + await attempt(port, "buffer", Buffer.from([2, 0x68, 0x32])); + await attempt(port, "uint8array", new Uint8Array([3, 0x78, 0x2f, 0x31])); + await attempt(port, "absent", undefined); + await attempt(port, "empty", []); + await attempt(port, "falsy", 0); + await attempt(port, "no-match", ["zz"]); + server.close(); + + invalid("string", "h2"); + invalid("object", {}); + invalid("buffer-element", [Buffer.from("h2")]); + invalid("number-element", [1]); + invalid("empty-element", ["h2", ""]); + invalid("too-long", ["a".repeat(256)]); + invalid("zero-length", Buffer.from([0])); + invalid("truncated", Buffer.from([5, 0x68])); + invalid("long-zero", Buffer.alloc(60)); +}); diff --git a/tests/corpus/6722-tls-min-max-version.ts b/tests/corpus/6722-tls-min-max-version.ts new file mode 100644 index 000000000..70cddae52 --- /dev/null +++ b/tests/corpus/6722-tls-min-max-version.ts @@ -0,0 +1,101 @@ +// tls.connect and tls.createServer with minVersion and maxVersion: the +// handshake picks the highest version both ranges allow and getProtocol() +// reports it. A range the server does not share fails with +// ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, a client range with neither TLSv1.2 +// nor TLSv1.3 fails with ERR_SSL_NO_PROTOCOLS_AVAILABLE once the handshake +// starts, and an invalid version or a secureProtocol conflict throws Node's +// TypeError from tls.connect and tls.createServer. null selects the default +// bound, as undefined does. +import * as tls from "node:tls"; +import { readFileSync } from "node:fs"; + +const dir = "tests/fixtures/server/certs/"; +const ca = readFileSync(dir + "ca.pem"); +const cert = readFileSync(dir + "localhost.pem"); +const key = readFileSync(dir + "localhost-key.pem"); + +const upTo12 = tls.createServer({ cert, key, maxVersion: "TLSv1.2" }, (s) => s.end(`server:${s.getProtocol()}`)); +const from13 = tls.createServer({ cert, key, minVersion: "TLSv1.3" }, (s) => s.end(`server:${s.getProtocol()}`)); +const any = tls.createServer({ cert, key }, (s) => s.end(`server:${s.getProtocol()}`)); +const nullBounds = tls.createServer({ cert, key, minVersion: null as any, maxVersion: null as any }, (s) => + s.end(`server:${s.getProtocol()}`), +); +for (const maxVersion of [3, "TLSv9"]) { + try { + tls.createServer({ cert, key, maxVersion: maxVersion as any }); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log("createServer", err.name, err.code, err.message); + } +} + +function listen(server: tls.Server): Promise { + return new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve((server.address() as { port: number }).port)), + ); +} + +interface Versions { + minVersion?: unknown; + maxVersion?: unknown; + secureProtocol?: string; +} + +function attempt(label: string, port: number, versions: Versions): Promise { + return new Promise((resolve) => { + let socket: tls.TLSSocket; + try { + const options = { + host: "localhost", + port, + ca, + minVersion: versions.minVersion as any, + maxVersion: versions.maxVersion as any, + secureProtocol: versions.secureProtocol, + }; + socket = tls.connect(options, () => console.log(label, "client:", socket.getProtocol())); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + resolve(); + return; + } + let data = ""; + socket.on("data", (chunk: Buffer) => (data += chunk.toString())); + socket.on("end", () => { + console.log(label, data); + resolve(); + }); + socket.on("error", (err) => { + console.log(label, "error:", (err as NodeJS.ErrnoException).code); + resolve(); + }); + }); +} + +async function main(): Promise { + const p12 = await listen(upTo12); + const p13 = await listen(from13); + const pAny = await listen(any); + await attempt("defaults", pAny, {}); + await attempt("max-1.2", pAny, { maxVersion: "TLSv1.2" }); + await attempt("min-1.3", pAny, { minVersion: "TLSv1.3" }); + await attempt("min-1-against-1.2", p12, { minVersion: "TLSv1" }); + await attempt("min-1.3-against-1.2", p12, { minVersion: "TLSv1.3" }); + await attempt("max-1.2-against-1.3", p13, { maxVersion: "TLSv1.2" }); + await attempt("max-1.1", pAny, { maxVersion: "TLSv1.1" }); + await attempt("min-above-max", pAny, { minVersion: "TLSv1.3", maxVersion: "TLSv1.2" }); + await attempt("null-min", pAny, { minVersion: null }); + const pNull = await listen(nullBounds); + await attempt("null-server", pNull, {}); + await attempt("null-both", pNull, { minVersion: null, maxVersion: null }); + await attempt("invalid-min", pAny, { minVersion: "TLSv9" }); + await attempt("invalid-max", pAny, { maxVersion: 3 }); + await attempt("conflict", pAny, { minVersion: "TLSv1.2", secureProtocol: "TLSv1_2_method" }); + upTo12.close(); + from13.close(); + any.close(); + nullBounds.close(); +} + +void main(); diff --git a/tests/corpus/6723-tls-connect-timeout.ts b/tests/corpus/6723-tls-connect-timeout.ts new file mode 100644 index 000000000..68f40ec37 --- /dev/null +++ b/tests/corpus/6723-tls-connect-timeout.ts @@ -0,0 +1,51 @@ +// tls.connect's timeout option arms the socket's inactivity timeout +// before it connects, so a handshake the peer never answers emits +// 'timeout'. Node applies it only when truthy, so 0 and NaN arm nothing, +// and setTimeout's msecs checks throw from tls.connect for a value that is +// not a number, negative, or not finite. +import * as net from "node:net"; +import * as tls from "node:tls"; + +// The server reads (and ignores) what arrives, so each connection closes +// when its client is destroyed. +const silent = net.createServer((socket) => { + socket.resume(); +}); + +function attempt(port: number, label: string, timeout: unknown): Promise { + return new Promise((resolve) => { + let socket: tls.TLSSocket; + try { + socket = tls.connect({ host: "127.0.0.1", port, timeout: timeout as any }); + } catch (e) { + const err = e as NodeJS.ErrnoException; + console.log(label, err.name, err.code, err.message); + resolve(); + return; + } + const fallback = setTimeout(() => { + console.log(label, "no timeout event"); + socket.destroy(); + resolve(); + }, 300); + socket.on("timeout", () => { + console.log(label, "timeout"); + clearTimeout(fallback); + socket.destroy(); + resolve(); + }); + socket.on("error", (err) => console.log(label, "error", (err as NodeJS.ErrnoException).code)); + }); +} + +silent.listen(0, "127.0.0.1", async () => { + const port = (silent.address() as net.AddressInfo).port; + await attempt(port, "50ms", 50); + await attempt(port, "fractional", 30.7); + await attempt(port, "zero", 0); + await attempt(port, "nan", NaN); + await attempt(port, "negative", -1); + await attempt(port, "infinite", Infinity); + await attempt(port, "string", "x"); + silent.close(); +}); diff --git a/tests/corpus/6820-stream-push-nullable-chunks.ts b/tests/corpus/6820-stream-push-nullable-chunks.ts new file mode 100644 index 000000000..5b79525b1 --- /dev/null +++ b/tests/corpus/6820-stream-push-nullable-chunks.ts @@ -0,0 +1,77 @@ +// Readable subclasses pushing `string | null` and `Buffer | null` chunks, a +// conditional `push(done ? null : chunk)`, and writes of `string | Buffer` +// chunks. +import { Readable, Writable } from "node:stream"; + +class Lines extends Readable { + private index = 0; + private readonly lines: string[]; + constructor(lines: string[]) { + super(); + this.lines = lines; + } + next(): string | null { + return this.index < this.lines.length ? this.lines[this.index++]! : null; + } + _read(): void { + const line: string | null = this.next(); + this.push(line); + } +} + +class Blocks extends Readable { + private sent = 0; + _read(): void { + const block: Buffer | null = this.sent < 3 ? Buffer.from([65 + this.sent, 10]) : null; + this.sent++; + this.push(block); + } +} + +class Countdown extends Readable { + private left: number; + constructor(left: number) { + super({ encoding: "utf8" }); + this.left = left; + } + _read(): void { + this.left--; + this.push(this.left < 0 ? null : `t-${this.left} `); + } +} + +function collect(label: string, stream: Readable): Promise { + return new Promise((resolve) => { + const parts: string[] = []; + stream.on("data", (chunk: Buffer | string) => + parts.push(typeof chunk === "string" ? chunk : chunk.toString()), + ); + stream.on("end", () => { + console.log(label, JSON.stringify(parts.join("")), parts.length); + resolve(); + }); + }); +} + +async function main(): Promise { + await collect("lines", new Lines(["a\n", "bb\n", "ccc\n"])); + await collect("empty lines", new Lines([])); + await collect("blocks", new Blocks()); + await collect("countdown", new Countdown(3)); + + const written: string[] = []; + const sink = new Writable({ + write(chunk: Buffer, _encoding, callback) { + written.push(chunk.toString()); + callback(); + }, + }); + const pieces: (string | Buffer)[] = ["x", Buffer.from("y"), "z"]; + for (const piece of pieces) { + const chunk: string | Buffer = piece; + console.log("write", Buffer.isBuffer(chunk) ? "buffer" : "string", sink.write(chunk)); + } + sink.end(() => console.log("written", JSON.stringify(written.join("")))); +} + +void main(); diff --git a/tests/corpus/6821-stream-write-nullable-chunks.cjs b/tests/corpus/6821-stream-write-nullable-chunks.cjs new file mode 100644 index 000000000..c7bf32bc0 --- /dev/null +++ b/tests/corpus/6821-stream-write-nullable-chunks.cjs @@ -0,0 +1,23 @@ +// Writes and pushes of a chunk that is a string or null: a null write throws +// ERR_STREAM_NULL_VALUES, and a null push ends the readable. +const { Writable, Readable } = require("node:stream"); +const written = []; +const sink = new Writable({ + write(chunk, _encoding, callback) { + written.push(chunk.toString()); + callback(); + }, +}); +for (let i = 0; i < 3; i++) { + const chunk = i === 1 ? null : `c${i}`; + try { + console.log("write", JSON.stringify(chunk), sink.write(chunk)); + } catch (error) { + console.log("write threw", error.name, error.code, error.message); + } +} +const r = new Readable({ read() {} }); +for (let i = 0; i < 3; i++) r.push(i === 2 ? null : `r${i}`); +r.on("data", (d) => console.log("data", String(d))); +r.on("end", () => console.log("end")); +sink.end(() => console.log("written", JSON.stringify(written.join("")))); diff --git a/tests/corpus/6823-recursive-alias-generic-signature.ts b/tests/corpus/6823-recursive-alias-generic-signature.ts new file mode 100644 index 000000000..1019371d7 --- /dev/null +++ b/tests/corpus/6823-recursive-alias-generic-signature.ts @@ -0,0 +1,29 @@ +// Generic functions whose parameters use a recursive type alias: the alias +// reaches itself through its own type arguments (`Tree[]` inside `Tree`). +type Tree = string | number | Tree[]; +type Json = null | boolean | number | string | Json[]; + +function flatten(label: T, ...trees: Tree[]): (T | string | number)[] { + const out: (T | string | number)[] = [label]; + const visit = (tree: Tree): void => { + if (Array.isArray(tree)) tree.forEach(visit); + else out.push(tree); + }; + trees.forEach(visit); + return out; +} + +function tagged(key: K, value: Json): { key: K; text: string } { + return { key, text: JSON.stringify(value) }; +} + +function depth(value: T, tree: Tree): [T, number] { + const measure = (node: Tree): number => + Array.isArray(node) ? 1 + Math.max(0, ...node.map(measure)) : 0; + return [value, measure(tree)]; +} + +console.log(flatten("root", "a", [1, ["b", [2]]], []).join(",")); +console.log(flatten(7, [[["deep"]]]).join(",")); +console.log(tagged("config", [1, "two", null, [true, [false]]])); +console.log(depth("x", [[1], [[2, [3]]]]), depth(0, "leaf")); diff --git a/tests/corpus/6860-unary-plus-primitive-unions.ts b/tests/corpus/6860-unary-plus-primitive-unions.ts new file mode 100644 index 000000000..e112013d9 --- /dev/null +++ b/tests/corpus/6860-unary-plus-primitive-unions.ts @@ -0,0 +1,48 @@ +// Unary + on unions of strings, numbers and booleans converts as Number(value) +// does: strings parse with StringToNumber (whitespace, hex, octal and binary +// prefixes, Infinity), booleans become 1 and 0, and numbers keep their value. +function show(label: string, value: number): void { + console.log(label, Object.is(value, -0) ? "-0" : String(value)); +} + +function plusTextOrNumber(value: string | number): number { + return +value; +} + +function plusTextOrFlag(value: string | boolean): number { + return +value; +} + +function plusNumberOrFlag(value: number | boolean): number { + return +value; +} + +type Scalar = string | number | boolean; + +const texts = ["42", " 0x1F ", "", " ", "1e3", "-0", "abc", "Infinity", "0b101", "0o17", "1_000", "\n7\t"]; +for (const text of texts) show(`text ${JSON.stringify(text)}`, plusTextOrNumber(text)); +for (const n of [9, -0, 0.5, Number.NaN, -Infinity]) show(`number ${String(n)}`, plusTextOrNumber(n)); +for (const value of [true, false, "3", "x"]) show(`text or flag ${String(value)}`, plusTextOrFlag(value)); +for (const value of [true, false, 2.5]) show(`number or flag ${String(value)}`, plusNumberOrFlag(value)); + +const scalars: Scalar[] = ["7", 3.5, true, false, "", -0, " 12 "]; +let total = 0; +for (const value of scalars) { + const n = +value; + total += n; + show(`scalar ${typeof value} ${JSON.stringify(value)}`, n); +} +show("total", total); + +// In expressions: sums, negation and comparisons. +const width: string | number = texts.length > 3 ? "640" : 0; +const ratio: string | number = texts.length > 3 ? 1.5 : "2"; +show("sum", +width + +ratio); +show("negated", -+width); +console.log("compare", +width > 600, +ratio === 1.5); + +type Level = "1" | "2" | 3; +function plusLevel(level: Level): number { + return +level; +} +show("level", plusLevel("2") + plusLevel(3)); diff --git a/tests/corpus/6861-unary-plus-optional-values.js b/tests/corpus/6861-unary-plus-optional-values.js new file mode 100644 index 000000000..6acf48e10 --- /dev/null +++ b/tests/corpus/6861-unary-plus-optional-values.js @@ -0,0 +1,49 @@ +// Unary + in JavaScript on values that may be undefined or null: command-line +// argument slots, Map lookups, optional parameters and nullable values convert +// as Number(value) does (undefined to NaN, null to 0). +function show(label, value) { + console.log(label, Object.is(value, -0) ? "-0" : String(value)); +} + +// No arguments: the first argument slot is undefined. +const n = +process.argv[2]; +show("argv[2]", n); +show("argv[2] doubled", +process.argv[2] * 2); +show("argv[2] or default", +process.argv[2] || 100); +show("argv[0] is a path", +process.argv[0]); + +const settings = new Map([ + ["width", "640"], + ["ratio", "1.5"], + ["blank", " "], +]); +show("map width", +settings.get("width")); +show("map ratio", +settings.get("ratio")); +show("map blank", +settings.get("blank")); +show("map missing", +settings.get("height")); +show("map sum", +settings.get("width") + +settings.get("ratio")); +show("negated", -+settings.get("width")); + +function plusOptional(value) { + return +value; +} +for (const text of ["42", " 0x1F ", "", "1e3", "-0", "abc", undefined]) { + show(`optional ${String(text)}`, plusOptional(text)); +} + +function plusParameter(value = undefined) { + return +value; +} +show("parameter missing", plusParameter()); +show("parameter given", plusParameter("12.5")); + +const words = ["3", "x"]; +show("past the end", +words[5]); +show("in range", +words[0]); + +const lookup = { a: "8" }; +show("missing key", +lookup.b); +show("present key", +lookup.a); + +const maybeNull = words.length > 1 ? null : "1"; +show("null", +maybeNull); diff --git a/tests/corpus/6905-isfinite-coercion.ts b/tests/corpus/6905-isfinite-coercion.ts new file mode 100644 index 000000000..57e158c72 --- /dev/null +++ b/tests/corpus/6905-isfinite-coercion.ts @@ -0,0 +1,38 @@ +// Global isFinite applies ToNumber to its argument before the test, like +// global isNaN: strings parse as numeric literals, booleans and null are 0 +// and 1, undefined is NaN, and objects convert through valueOf/toString. +// Number.isFinite stays strict and answers false for anything not a number. +const values: unknown[] = ["12", "", " 0x10 ", "abc", "1e400", true, false, null, undefined, {}, [], [7], [1, 2]]; +for (const value of values) { + console.log(JSON.stringify(value) ?? "undefined", isFinite(value as any), Number.isFinite(value)); +} + +const u: unknown = 12; +console.log(isFinite(u as any)); + +let calls = ""; +const boxed: unknown = { valueOf: () => ((calls += "v"), 5) }; +console.log(isFinite(boxed as any), calls); +const text: unknown = { toString: () => "Infinity" }; +console.log(isFinite(text as any)); + +class Cache { + #maxAge: unknown; + constructor(maxAge?: number) { + this.#maxAge = maxAge; + } + expires(): boolean { + return isFinite(this.#maxAge as any); + } +} +console.log(new Cache(1000).expires(), new Cache().expires()); + +const throwing: unknown[] = [Symbol("s"), 10n]; +for (const value of throwing) { + try { + isFinite(value as any); + } catch (e) { + console.log(e instanceof TypeError); + } +} +console.log(isFinite(1 / 0), isFinite(-1 / 0), isFinite(0 / 0), isFinite(-0)); diff --git a/tests/corpus/6906-tonumber-arguments.mjs b/tests/corpus/6906-tonumber-arguments.mjs new file mode 100644 index 000000000..35589d07f --- /dev/null +++ b/tests/corpus/6906-tonumber-arguments.mjs @@ -0,0 +1,97 @@ +// Arguments that the global number functions convert with ToNumber, in a +// JavaScript file where they arrive as strings, booleans, null, undefined +// or dynamic values: isNaN, isFinite, Math functions and +// String.fromCharCode match Node for each. +console.log(isNaN("12"), isNaN("abc"), isNaN(true), isNaN(null), isNaN(undefined)); +console.log(isFinite("12"), isFinite("abc"), isFinite(true), isFinite(null), isFinite(undefined)); +console.log(Math.floor("3.7"), Math.max("4", 2), Math.abs(false), Math.sqrt(null), String.fromCharCode("65", true)); + +const parsed = JSON.parse('["12", " 0x10 ", "", "1e400", true, null, {}, [], [7]]'); +for (const value of parsed) console.log(JSON.stringify(value), isNaN(value), isFinite(value), Math.trunc(value)); + +let calls = ""; +const boxed = { valueOf: () => ((calls += "v"), 4.5), toString: () => ((calls += "s"), "9") }; +console.log(isFinite(boxed), isNaN(boxed), Math.round(boxed), calls); + +class LruCache { + constructor(config) { + this.maxAge = config.maxAge; + } + hasMaxAge() { + return isFinite(this.maxAge); + } +} +console.log(new LruCache({ maxAge: 1000 }).hasMaxAge(), new LruCache({}).hasMaxAge()); + +for (const value of [Symbol("s"), 10n]) { + try { + isFinite(value); + } catch (e) { + console.log(e instanceof TypeError, e.message); + } +} + +// A conditional argument converts whichever branch it takes. +for (const flag of [true, false]) { + console.log(isFinite(flag ? "12" : "abc"), isNaN(flag ? null : "x"), Math.floor(flag ? "3.7" : true)); +} + +// Every argument is evaluated before any converts, as in Node: valueOf runs +// after the last argument, and an argument that throws stops the call +// before any conversion. +let order = ""; +const valued = (name, n) => ({ valueOf: () => ((order += "v" + name), n) }); +const arg = (name, n) => ((order += "e" + name), valued(name, n)); +console.log(Math.max(arg("a", 1), arg("b", 5), arg("c", 3)), order); +order = ""; +console.log(Math.pow(arg("a", 2), arg("b", 10)), order); +order = ""; +console.log(Math.hypot(arg("a", 3), ...[4], arg("c", 12)), order); +order = ""; +console.log(String.fromCharCode(arg("a", 72), arg("b", 105)), order); +order = ""; +try { + Math.max( + arg("a", 1), + (() => { + order += "throw"; + throw new Error("boom"); + })(), + ); +} catch (e) { + console.log(e.message, order); +} +order = ""; +try { + Math.min( + { + valueOf() { + order += "x"; + throw new TypeError("first"); + }, + }, + arg("b", 2), + ); +} catch (e) { + console.log(e.message, order); +} + +// A spread reads its elements where it is evaluated, before an earlier +// argument's valueOf runs. +const values = [1, 2]; +const mutator = { + valueOf() { + values[0] = 100; + values.push(50); + return 3; + }, +}; +console.log(Math.max(mutator, ...values), values.join(",")); +const more = [4]; +const resetter = { + valueOf() { + more[0] = -1; + return 0; + }, +}; +console.log(Math.hypot(resetter, ...more, 3), more.join(",")); diff --git a/tests/corpus/6908-generic-class-literal-conditional.ts b/tests/corpus/6908-generic-class-literal-conditional.ts new file mode 100644 index 000000000..8bf7a0533 --- /dev/null +++ b/tests/corpus/6908-generic-class-literal-conditional.ts @@ -0,0 +1,65 @@ +// A generic class whose member types are conditional types over a literal +// type parameter: each literal argument selects the conditional's branch, +// so Field.get() returns T and Field.get() +// returns T | undefined. +type Kind = "required" | "optional"; +type Value = K extends "required" ? T : T | undefined; + +class Field { + value: T; + kind: K; + present: boolean; + constructor(value: T, kind: K, present = true) { + this.value = value; + this.kind = kind; + this.present = present; + } + get(): Value { + return (this.present ? this.value : undefined) as Value; + } + describe(): string { + return `${this.kind}:${String(this.get())}`; + } +} + +console.log(new Field("hello", "required").get()); +const name = new Field("ada", "required"); +const nick = new Field("ad", "optional", false); +const age = new Field(36, "optional"); +console.log(name.get().toUpperCase(), nick.get() ?? "(none)", (age.get() ?? 0) + 1); +console.log(name.describe(), nick.describe(), age.describe()); + +function read(field: Field): string { + return field.get(); +} +function readOptional(field: Field): string { + return field.get() ?? "missing"; +} +console.log(read(name), readOptional(nick), readOptional(new Field("x", "optional"))); + +type Flag = B extends true ? "on" : "off"; +class Switch { + readonly state: B; + constructor(state: B) { + this.state = state; + } + label(): Flag { + return (this.state ? "on" : "off") as Flag; + } +} +console.log(new Switch(true).label(), new Switch(false).label()); + +// A conditional wrapped in a union alias selects its branch the same way. +type MaybeValue = (K extends "required" ? T : T | undefined) | null; +class Slot { + value: MaybeValue; + kind: K; + constructor(value: MaybeValue, kind: K) { + this.value = value; + this.kind = kind; + } +} +const filled = new Slot("a", "required"); +const empty = new Slot(undefined, "optional"); +const cleared = new Slot(null, "optional"); +console.log(filled.value, empty.value, cleared.value, filled.kind, empty.kind); diff --git a/tests/corpus/6909-empty-object-type-values.ts b/tests/corpus/6909-empty-object-type-values.ts new file mode 100644 index 000000000..f845e19a6 --- /dev/null +++ b/tests/corpus/6909-empty-object-type-values.ts @@ -0,0 +1,40 @@ +// The empty object type `{}` admits every non-nullish value, including when +// tsc infers it from an empty object literal: union subtype reduction folds +// primitives into it (`["12", {}]` is `{}[]`, `flag ? "x" : {}` is `{}`), and +// a `let` initialized with `{}` takes any later non-nullish value. +const flag = Math.random() < 2; + +const mixed = ["12", {}]; +console.log(mixed.length, typeof mixed[0], typeof mixed[1]); + +const marker = {}; +const list = [marker, "x", 3, true, [1]]; +console.log(list.length, list[0] === marker, list.map((x) => typeof x).join(",")); + +const holder = { value: flag ? "x" : {} }; +const picked = flag ? "y" : {}; +console.log(typeof holder.value, typeof picked, holder.value, picked); + +let slot = {}; +console.log(JSON.stringify(slot)); +slot = "now a string"; +console.log(typeof slot, slot); +slot = 42; +console.log(typeof slot, slot); + +function choose(n: number) { + if (n > 0) return "positive"; + return {}; +} +console.log(typeof choose(1), typeof choose(-1)); + +const empty = {}; +console.log(JSON.stringify(empty), Object.keys(empty).length, empty === empty, String(empty)); + +// An empty literal's type beside a typed arm of an unreduced union, after +// the type was already used on its own. +const viewed = {}; +console.log(JSON.stringify(viewed)); +const views: (typeof viewed | DataView)[] = [viewed, new DataView(new ArrayBuffer(2))]; +for (const view of views) + console.log(view instanceof DataView ? `view ${view.byteLength}` : JSON.stringify(view)); diff --git a/tests/corpus/6910-empty-object-type-values-js.mjs b/tests/corpus/6910-empty-object-type-values-js.mjs new file mode 100644 index 000000000..3c6803556 --- /dev/null +++ b/tests/corpus/6910-empty-object-type-values-js.mjs @@ -0,0 +1,14 @@ +// In a JavaScript file, an array literal mixing strings, other primitives and +// an empty object holds each value as written. +const a = ["12", {}]; +console.log(a.length, typeof a[0], typeof a[1]); + +const b = ["12", "", true, null, undefined, [7], {}]; +console.log(b.length, b.map((x) => (x === null ? "null" : typeof x)).join(",")); + +const options = [{}, "fallback"]; +for (const option of options) console.log(typeof option === "string" ? option : JSON.stringify(option)); + +let current = {}; +current = "replaced"; +console.log(current); diff --git a/tests/corpus/6911-structural-constructor-types.ts b/tests/corpus/6911-structural-constructor-types.ts new file mode 100644 index 000000000..e8df74dfa --- /dev/null +++ b/tests/corpus/6911-structural-constructor-types.ts @@ -0,0 +1,59 @@ +// Values typed by a structural constructor type: `new () => unknown`, a type +// that also names static members, abstract constructor types and arrays of +// constructors. The class value keeps its identity; `new` through it builds +// an instance of the class it holds, and static reads see that class's +// statics. +type DescribedClass = { readonly schema: unknown; new (): unknown }; +class Item { + static readonly schema = { name: true }; + value = 42; +} +class Other { + static readonly schema = { name: false, extra: 1 }; + value = 7; +} +function classOf(flag: boolean): DescribedClass { + return flag ? Item : Other; +} +console.log(JSON.stringify(classOf(true).schema), JSON.stringify(classOf(false).schema)); +const C = classOf(true); +const made = new C(); +console.log(made instanceof Item, (made as Item).value, C === Item); + +type Ctor = new () => unknown; +const ctors: Ctor[] = [Item, Other]; +console.log(ctors.length, ctors.map((c) => (new c() as { value: number }).value).join(",")); + +class Point { + readonly x: number; + readonly y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + static origin = "0,0"; +} +type PointLike = { readonly origin: string; new (x: number, y: number): { x: number; y: number } }; +const P: PointLike = Point; +const p = new P(3, 4); +console.log(p.x + p.y, P.origin); + +abstract class Shape { + abstract area(): number; + describe(): string { + return `area ${this.area()}`; + } +} +class Square extends Shape { + area(): number { + return 4; + } +} +type ShapeCtor = abstract new () => Shape; +const sc: ShapeCtor = Square; +console.log(sc === Square, new (sc as new () => Shape)().describe()); + +function make(c: new () => T): T { + return new c(); +} +console.log((make(Item) as Item).value, (make(Other) as Other).value); diff --git a/tests/corpus/6920-zlib-callback-error-result.ts b/tests/corpus/6920-zlib-callback-error-result.ts new file mode 100644 index 000000000..44803333b --- /dev/null +++ b/tests/corpus/6920-zlib-callback-error-result.ts @@ -0,0 +1,247 @@ +// zlib callbacks receive undefined as the result when they fail, as in +// Node: the result reads as undefined in logs, templates, typeof and +// comparisons, member reads throw Node's TypeError, and the same callbacks +// read the Buffer as before when the operation succeeds. This holds for +// callback literals and for function values. +import * as zlib from "node:zlib"; + +const good = zlib.deflateSync("hello callbacks"); +const bad = Buffer.from("not deflate data"); + +function report(label: string, run: () => void): void { + try { + run(); + } catch (e) { + console.log(label, "threw", (e as Error).name, (e as Error).message); + } +} + +function check(label: string, input: Buffer, next: () => void): void { + zlib.inflate(input, (err, out) => { + console.log(label, "error", err?.code ?? null); + console.log(label, "value", out); + console.log(label, "template", `${out}`, String(out), out + ""); + console.log(label, "typeof", typeof out, out === undefined, out !== undefined); + console.log(label, "optional", out?.length, out?.toString()); + console.log(label, "isBuffer", Buffer.isBuffer(out)); + console.log(label, out ? "truthy" : "falsy"); + report(label + " length", () => console.log(label, "length", out.length)); + report(label + " toString", () => console.log(label, "text", out.toString())); + report(label + " inflateSync", () => console.log(label, "again", zlib.inflateSync(out).length)); + setTimeout(() => { + console.log(label, "later", typeof out, out?.byteLength); + next(); + }); + }); +} + +function guarded(next: () => void): void { + // A callback that checks the error first reads the Buffer only on success. + zlib.gunzip(bad, (err, out) => { + if (err) { + console.log("guarded error", err.code, err.errno); + zlib.gzip("guarded text", (err2, packed) => { + if (err2) throw err2; + zlib.gunzip(packed, (err3, plain) => { + console.log("guarded round trip", err3, plain.toString()); + next(); + }); + }); + return; + } + console.log("guarded value", out.toString()); + }); +} + +function expressions(): void { + // Expression bodies. + zlib.unzip(bad, (err, out) => + zlib.deflateRaw("raw", (err2, raw) => console.log("expression", err?.code, out, err2, raw.length > 0)), + ); +} + +function everyFunction(next: () => void): void { + // Each callback function, with and without options. maxOutputLength makes + // the compressors fail; a non-matching format makes the decompressors fail. + const text = "every function ".repeat(20); + const opts = { maxOutputLength: 8 }; + zlib.deflate(text, opts, (err, out) => { + console.log("deflate", err?.code, typeof out, out?.length); + zlib.deflateRaw(text, opts, (err, out) => { + console.log("deflateRaw", err?.code, typeof out, out?.length); + zlib.gzip(text, opts, (err, out) => { + console.log("gzip", err?.code, typeof out, out?.length); + zlib.inflateRaw(bad, (err, out) => { + console.log("inflateRaw", err?.code, typeof out, out?.length); + zlib.inflate(zlib.deflateSync(text), opts, (err, out) => { + console.log("inflate options", err?.code, typeof out, out?.length); + zlib.gunzip(zlib.gzipSync(text), opts, (err, out) => { + console.log("gunzip options", err?.code, typeof out, out?.length); + zlib.unzip(bad, {}, (err, out) => { + console.log("unzip options", err?.code, typeof out, out?.length); + zlib.inflateRaw(zlib.deflateRawSync(text), {}, (err, out) => { + console.log("inflateRaw options", err, out.toString() === text); + zlib.deflate(text, {}, (err, out) => { + console.log("deflate options", err, zlib.inflateSync(out).toString() === text); + next(); + }); + }); + }); + }); + }); + }); + }); + }); + }); +} + +function folded(input: Buffer, next: () => void): void { + // Conditions on the error parameter. + zlib.inflateRaw(input, (err, out) => { + if (err !== null) console.log("folded error", err.code, typeof out); + else console.log("folded value", out.length); + if (!err) console.log("folded ok", out.toString()); + console.log("folded both", err == null ? out.length : typeof out, err && err.code); + if (err) return; + console.log("folded after", out.byteLength); + }); + setTimeout(next, 10); +} + +// Function values: declarations, const arrows and forwarded parameters +// receive undefined too, and a direct call still passes its arguments. +const steps: (() => void)[] = []; +function step(): void { + const run = steps.shift(); + if (run) run(); +} +function named(err: Error | null, out: Buffer): void { + if (err) console.log("named error", (err as NodeJS.ErrnoException).code, typeof out, out === undefined); + else console.log("named value", out.toString()); + console.log("named both", err !== null, typeof out, `${out}`, out?.length); + step(); +} +const arrow = (err: Error | null, out: Buffer): void => { + console.log("arrow", err?.message ?? null, typeof out, String(out)); + report("arrow length", () => console.log("arrow length", out.length)); + step(); +}; +function captures(err: Error | null, out: Buffer): void { + setTimeout(() => { + console.log("captured", err !== null, typeof out, out?.length); + step(); + }); +} +function forward(input: Buffer, cb: (err: Error | null, out: Buffer) => void): void { + zlib.inflate(input, cb); +} +function forwardGzip(text: string, cb: (err: Error | null, out: Buffer) => void): void { + zlib.gzip(text, { maxOutputLength: 4 }, cb); +} + +function forwardLiteral(input: Buffer, cb: (err: Error | null, out: Buffer) => void): void { + // The literal passes its result on to a typed callback. + zlib.inflate(input, (err, out) => cb(err, out)); +} +function inflated(input: Buffer): Promise { + return new Promise((resolve, reject) => + zlib.inflate(input, (err, out) => (err ? reject(err) : resolve(out))), + ); +} + +function sink(err: Error | null, out: Buffer): void { + console.log("sink", err, typeof out, out?.length); + step(); +} +function nestedForward(): void { + // On the outer error path, the nested callback forwards its own result. + zlib.inflate(bad, (err, out) => { + console.log("outer", err?.code, typeof out); + zlib.inflate(good, (err2, out2) => sink(err2, out2)); + }); +} +let attempts = 0; +function namedRetry(): void { + // A named callback that calls itself with an error and a Buffer. + zlib.inflate(bad, function retry(err, out) { + attempts++; + if (err) { + if (attempts < 3) return retry(new Error("again"), Buffer.from("x")); + console.log("gave up", attempts, err.message, out.length); + step(); + return; + } + console.log("retry value", out.length); + step(); + }); +} + +function shown(label: string, out: Buffer): void { + console.log(label, typeof out, out.toString()); +} +function fallbackResult(): void { + // The body assigns a fallback Buffer to its result, then passes the + // result on to typed functions. + zlib.inflate(bad, (err, out) => { + if (!out) out = Buffer.from("fallback"); + console.log("fallback", err?.code, typeof out, out.toString()); + shown("fallback passed", out); + zlib.inflate(good, (err2, out2) => { + if (err2) out2 = Buffer.alloc(0); + console.log("fallback nested", err2, out2.length); + shown("fallback nested passed", out2); + zlib.inflate(bad, (err3, out3) => { + if (err3) out3 = Buffer.from("inner fallback"); + sink(null, out3); + }); + }); + }); +} + +function functionValues(next: () => void): void { + steps.push( + () => zlib.inflate(good, named), + () => zlib.inflate(bad, named), + () => zlib.inflate(good, arrow), + () => zlib.inflate(bad, arrow), + () => forward(bad, named), + () => forwardLiteral(bad, named), + () => forwardLiteral(good, arrow), + () => + inflated(bad).then( + (out) => console.log("promise value", out.length), + (e) => { + console.log("promise rejected", (e as NodeJS.ErrnoException).code); + step(); + }, + ), + () => + void inflated(good).then((out) => { + console.log("promise value", out.toString()); + step(); + }), + () => forwardGzip("text that does not fit", arrow), + () => zlib.inflate(bad, captures), + () => zlib.inflate(good, captures), + nestedForward, + namedRetry, + fallbackResult, + () => { + named(new Error("direct"), Buffer.from("both")); + }, + next, + ); + step(); +} + +check("good", good, () => + check("bad", bad, () => + guarded(() => + everyFunction(() => + folded(zlib.deflateRawSync("folded text"), () => + folded(bad, () => functionValues(expressions)), + ), + ), + ), + ), +); diff --git a/tests/corpus/6931-fs-url-paths.ts b/tests/corpus/6931-fs-url-paths.ts new file mode 100644 index 000000000..effe64207 --- /dev/null +++ b/tests/corpus/6931-fs-url-paths.ts @@ -0,0 +1,118 @@ +// fs functions given file: URL paths: the URL converts as fileURLToPath +// converts it, so names with spaces and non-ASCII characters reach the same +// file, and URLs with another scheme, a host or an encoded separator throw +// Node's errors. +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { pathToFileURL } from "node:url"; + +const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "fs-url-"))); +const at = (name: string): URL => pathToFileURL(path.join(dir, name)); +// Arguments after a URL path are evaluated before the URL converts. +function logged(name: string): URL { + console.log("evaluated", name); + return at(name); +} +function mode(): number { + console.log("evaluated mode"); + return 0o600; +} +// A later argument that reassigns an earlier argument's variable runs after +// that argument was read. +let uid = -1; +function bumpUid(): number { + uid = 123456; + return -1; +} + +function clean(text: string): string { + return text.split(dir).join(""); +} + +function attempt(label: string, run: () => void): void { + try { + run(); + console.log(label, "returned"); + } catch (error) { + if (error instanceof Error) { + const code = (error as NodeJS.ErrnoException).code; + console.log(label, error.name, code, clean(error.message)); + } + } +} + +const file = at("a b ü.txt"); +fs.writeFileSync(file, "hello"); +console.log(fs.readFileSync(file, "utf8")); +console.log(fs.readFileSync(file).length); +console.log(fs.readFileSync(file, { encoding: "utf8" })); +console.log(fs.readFileSync(file, "base64")); +fs.appendFileSync(file, " world"); +fs.writeFileSync(at("bytes.bin"), new Uint8Array([1, 2, 3])); +fs.writeFileSync(at("mode.txt"), "m", { mode: 0o600 }); +console.log(fs.readFileSync(path.join(dir, "a b ü.txt"), "utf8")); +console.log(fs.statSync(file).size, fs.lstatSync(file).isFile(), fs.statSync(at("bytes.bin")).size); +console.log(fs.statSync(at("missing"), { throwIfNoEntry: false }) === undefined); +fs.accessSync(file); +fs.accessSync(file, fs.constants.R_OK); +fs.chmodSync(file, 0o644); +console.log((fs.statSync(file).mode & 0o777).toString(8)); + +fs.mkdirSync(at("sub/deep"), { recursive: true }); +fs.mkdirSync(at("plain")); +console.log(fs.readdirSync(at("sub"))); +console.log(fs.readdirSync(at("sub"), { withFileTypes: true }).map((entry) => entry.name)); +fs.copyFileSync(file, at("sub/copy.txt")); +fs.renameSync(at("sub/copy.txt"), at("sub/moved.txt")); +console.log(fs.readFileSync(at("sub/moved.txt"), "utf8")); +fs.truncateSync(at("sub/moved.txt"), 4); +console.log(fs.readFileSync(at("sub/moved.txt"), "utf8")); +const fd = fs.openSync(at("sub/moved.txt"), "r"); +console.log(typeof fd); +fs.closeSync(fd); +console.log(clean(fs.realpathSync(at("sub/../sub/moved.txt")))); +console.log(clean(fs.realpathSync.native(at("sub")))); +const temp = fs.mkdtempSync(at("tmp-")); +console.log(path.dirname(temp) === dir, path.basename(temp).length); +fs.rmdirSync(pathToFileURL(temp)); +const opened = fs.opendirSync(at("sub")); +console.log(opened.path === path.join(dir, "sub")); +opened.closeSync(); +fs.unlinkSync(at("sub/moved.txt")); +fs.rmSync(at("sub"), { recursive: true, force: true }); +fs.rmSync(at("plain"), { recursive: true }); +console.log(fs.readdirSync(dir).sort()); + +attempt("scheme", () => fs.readFileSync(new URL("http://example.com/a.txt"), "utf8")); +attempt("host", () => fs.statSync(new URL("file://example.com/a.txt"))); +attempt("separator", () => fs.readFileSync(new URL("file:///tmp/a%2Fb"), "utf8")); +attempt("lowercase separator", () => fs.unlinkSync(new URL("file:///tmp/a%2fb"))); +attempt("null byte", () => fs.readFileSync(new URL("file:///tmp/a%00b"))); +attempt("missing", () => fs.readFileSync(at("missing.txt"), "utf8")); +attempt("both bad", () => fs.copyFileSync(new URL("http://a/"), new URL("file://b/c"))); +attempt("rename", () => fs.renameSync(at("missing.txt"), at("other.txt"))); +attempt("mkdir", () => { + fs.mkdirSync(new URL("ftp://a/b"), { recursive: true }); +}); +attempt("later effects", () => fs.copyFileSync(new URL("http://a/"), logged("dest.txt"))); +attempt("later mode", () => fs.chmodSync(new URL("http://a/"), mode())); +attempt("chmod", () => fs.chmodSync(new URL("data:text/plain,a"), 0o600)); +attempt("chown order", () => fs.chownSync(file, uid, bumpUid())); +console.log("uid", uid); +uid = -1; +attempt("chown order scheme", () => fs.chownSync(new URL("http://a/"), uid, bumpUid())); +console.log("uid", uid); +// A later argument with effects inside a type-only wrapper runs once. +let modeCalls = 0; +function countedMode(): number { + modeCalls++; + return 0o600; +} +attempt("satisfies", () => fs.chmodSync(file, countedMode() satisfies number)); +attempt("as", () => fs.chmodSync(file, countedMode() as number)); +attempt("non-null", () => fs.chmodSync(file, countedMode()!)); +console.log("mode calls", modeCalls); + +fs.rmSync(dir, { recursive: true, force: true }); +console.log("done", fs.existsSync(dir)); diff --git a/tests/corpus/6933-tls-sni-callback.ts b/tests/corpus/6933-tls-sni-callback.ts new file mode 100644 index 000000000..8c9442806 --- /dev/null +++ b/tests/corpus/6933-tls-sni-callback.ts @@ -0,0 +1,97 @@ +// tls.createServer with an SNICallback: the callback receives each +// ClientHello's servername and answers a SecureContext synchronously or +// later, or none, so the default cert/key pair serves; an error answer +// fails that handshake. Clients that trust the CA see the certificate the +// answer chose: alt.localhost verifies only with the alt context, and a +// name served the default localhost pair fails Node's hostname check. +import { readFileSync } from "node:fs"; +import * as net from "node:net"; +import * as tls from "node:tls"; + +const certs = "tests/fixtures/server/certs"; +const ca = readFileSync(`${certs}/ca.pem`); +const cert = readFileSync(`${certs}/localhost.pem`); +const key = readFileSync(`${certs}/localhost-key.pem`); +const altContext = tls.createSecureContext({ + cert: readFileSync(`${certs}/alt.pem`), + key: readFileSync(`${certs}/alt-key.pem`), +}); + +const server = tls.createServer( + { + cert, + key, + SNICallback: (servername: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => { + console.log("sni", servername); + if (servername === "alt.localhost") setTimeout(() => cb(null, altContext), 5); + else if (servername === "fail.localhost") cb(new Error(`no certificate for ${servername}`)); + else cb(null, undefined); + }, + }, + (socket) => { + socket.end("hello"); + }, +); + +// The callback is read where it appears, effects included: a later cert +// initializer that reassigns its binding does not change the callback the +// server uses. +type Sni = (servername: string, cb: (err: Error | null, ctx?: tls.SecureContext) => void) => void; +let chosen: Sni = (servername, cb) => { + console.log("first callback", servername); + cb(null, altContext); +}; +const ordered = tls.createServer( + { + SNICallback: (console.log("read callback"), chosen), + cert: (console.log("read cert"), + (chosen = (servername, cb) => { + console.log("second callback", servername); + cb(null, undefined); + }), + cert), + key, + }, + (socket) => { + socket.end("ordered"); + }, +); + +function attempt(port: number, servername: string, next: () => void): void { + let done = false; + const finish = (line: string): void => { + if (done) return; + done = true; + console.log(servername, line); + next(); + }; + const socket = tls.connect({ port, host: "127.0.0.1", servername, ca }, () => { + let text = ""; + socket.on("data", (data: Buffer) => { + text += data.toString(); + }); + socket.on("end", () => finish(`authorized ${socket.authorized} ${text}`)); + }); + socket.on("error", (error: Error) => { + finish(`error ${(error as NodeJS.ErrnoException).code}`); + }); +} + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + attempt(port, "localhost", () => + attempt(port, "alt.localhost", () => + attempt(port, "other.localhost", () => + attempt(port, "fail.localhost", () => + server.close(() => { + console.log("closed"); + ordered.listen(0, "127.0.0.1", () => { + const orderedPort = (ordered.address() as net.AddressInfo).port; + attempt(orderedPort, "alt.localhost", () => ordered.close(() => console.log("ordered closed"))); + }); + }), + ), + ), + ), + ); +}); diff --git a/tests/corpus/6934-tls-handshake-disconnect.ts b/tests/corpus/6934-tls-handshake-disconnect.ts new file mode 100644 index 000000000..1f13c6684 --- /dev/null +++ b/tests/corpus/6934-tls-handshake-disconnect.ts @@ -0,0 +1,27 @@ +// A server that closes the connection before the TLS handshake completes: +// tls.connect and https.get fail with ECONNRESET and Node's "Client network +// socket disconnected before secure TLS connection was established". +import * as https from "node:https"; +import * as net from "node:net"; +import * as tls from "node:tls"; + +const server = net.createServer((socket) => { + socket.end(); +}); + +server.listen(0, "127.0.0.1", () => { + const port = (server.address() as net.AddressInfo).port; + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + console.log("tls connected"); + }); + client.on("error", (error: Error) => { + console.log("tls", (error as NodeJS.ErrnoException).code, error.message); + const request = https.get({ host: "127.0.0.1", port, rejectUnauthorized: false }, () => { + console.log("https response"); + }); + request.on("error", (err: Error) => { + console.log("https", (err as NodeJS.ErrnoException).code, err.message); + server.close(); + }); + }); +}); diff --git a/tests/corpus/6942-js-buffer-view-of-declared-field.js b/tests/corpus/6942-js-buffer-view-of-declared-field.js new file mode 100644 index 000000000..521e39b76 --- /dev/null +++ b/tests/corpus/6942-js-buffer-view-of-declared-field.js @@ -0,0 +1,39 @@ +// A Buffer view over a typed array that a JavaScript class keeps in a +// declared field without an initializer: the field holds a dynamic value, +// and the view shares the array's storage. + +class Units { + text; + chars; + constructor(text, onDemand) { + this.text = text; + if (onDemand) this.chars = new Uint16Array(text.length + 4); + else this.chars = toUnits(text); + } + load(from, to) { + Buffer.from(this.chars.buffer, from * 2, (to - from) * 2).write( + this.text.substring(from, to), + "utf16le", + ); + return this.chars[from]; + } +} + +function toUnits(text) { + const units = new Uint16Array(text.length + 4); + for (let i = 0; i < text.length; i++) units[i] = text.charCodeAt(i); + return units; +} + +const lazy = new Units("hello", true); +console.log(lazy.chars[1], lazy.load(1, 3), lazy.chars[2], lazy.chars[3]); +const view = Buffer.from(lazy.chars.buffer, 2, 4); +view[0] = 0x41; +console.log(lazy.chars[1], view.length, view.toString("utf16le")); +const eager = new Units("yo", false); +console.log(eager.chars[0], eager.load(0, 2)); +try { + Buffer.from(lazy.chars.buffer, 64, 2); +} catch (error) { + console.log(error.name); +} diff --git a/tests/corpus/6943-js-class-branch-bags.js b/tests/corpus/6943-js-class-branch-bags.js new file mode 100644 index 000000000..b3f91f8d8 --- /dev/null +++ b/tests/corpus/6943-js-class-branch-bags.js @@ -0,0 +1,67 @@ +// Untyped reads of class instances in a hierarchy: own properties added at +// runtime shadow methods and accessors, prototype data answers while an +// instance has no own properties, and computed keys and key probes see the +// same members, for the base class and for each subclass. + +class Shape { + constructor(name) { + this.name = name; + this.sides = 0; + } + area() { + return 0; + } + get label() { + return `${this.name}/${this.sides}`; + } +} +class Square extends Shape { + constructor(size) { + super("square"); + this.sides = 4; + this.size = size; + } + area() { + return this.size * this.size; + } +} +class Tile extends Square { + constructor(size, color) { + super(size); + this.color = color; + } +} +Shape.prototype.kind = "shape"; +Square.prototype.corner = "right"; + +function read(value, key) { + return value[key]; +} +function describe(value) { + const parts = []; + for (const key of ["name", "sides", "size", "color", "kind", "corner", "label", "missing"]) + parts.push(`${key}=${String(read(value, key))}`); + parts.push(`in:${"area" in value},${"kind" in value},${"extra" in value}`); + parts.push(`own:${Object.hasOwn(value, "kind")},${Object.hasOwn(value, "size")}`); + return parts.join(" "); +} + +const shapes = [new Shape("dot"), new Square(3), new Tile(2, "red")]; +for (const shape of shapes) console.log(describe(shape), shape.area(), shape.label); + +const untyped = shapes.map((shape) => /** @type {any} */ (shape)); +untyped[1].area = () => -1; +untyped[2].kind = "tile"; +untyped[2].extra = 7; +try { + untyped[0].label = "plain"; +} catch (error) { + console.log(error.name); +} +Object.defineProperty(untyped[0], "label", { value: "plain", enumerable: true }); +for (const shape of untyped) { + console.log(describe(shape), typeof shape.area, shape.area(), shape.kind, shape.extra); + console.log(read(shape, "area") === Shape.prototype.area, read(shape, "label")); +} +const area = untyped[2].area; +console.log(area.call(new Tile(5, "blue")), Object.keys(untyped[2]).join(",")); diff --git a/tests/corpus/6944-js-super-call-in-if-condition.js b/tests/corpus/6944-js-super-call-in-if-condition.js new file mode 100644 index 000000000..8fdc8022b --- /dev/null +++ b/tests/corpus/6944-js-super-call-in-if-condition.js @@ -0,0 +1,41 @@ +// A derived constructor whose super() call leads an if condition's comma +// chain, as minifiers join `super(...); if (test) ...`: the base and the +// derived fields initialize first, then the test decides. + +let count = 0; +class Base { + constructor(name) { + this.name = name; + this.created = count++; + } +} + +class Checked extends Base { + limit = 3; + constructor(name, size) { + if ((super(name), size > this.limit)) throw new RangeError(`${name}: ${size} > ${this.limit}`); + else this.size = size; + } +} + +class Tagged extends Base { + constructor(name, tag) { + if ((super(name), (this.tag = tag), tag === undefined)) this.tag = "none"; + } +} + +class Plain extends Base { + constructor(name, flag) { + if ((super(name), flag)) this.flag = "set"; + this.done = true; + } +} + +const made = [new Checked("a", 2), new Tagged("b", "x"), new Tagged("c"), new Plain("d", 1)]; +for (const value of made) console.log(JSON.stringify(value)); +try { + new Checked("e", 9); +} catch (error) { + console.log(error.name, error.message, count); +} +console.log(new Plain("f", 0).flag, count); diff --git a/tests/corpus/6950-string-field-fast-paths.ts b/tests/corpus/6950-string-field-fast-paths.ts new file mode 100644 index 000000000..4e9d52b38 --- /dev/null +++ b/tests/corpus/6950-string-field-fast-paths.ts @@ -0,0 +1,48 @@ +// Field parsing on ASCII text: Number() on digit strings (signs, zeros, +// 15 and 16 digits, whitespace and other grammar), indexOf from the start +// and from other positions, slice bounds, and split results of every +// size, including more pieces than one batch and results that grow. +const numbers = [ + "0", "-0", "+0", "7", "-7", "+42", "007", "-007", "123456789012345", "-123456789012345", + "1234567890123456", "9007199254740993", "-", "+", "", " 12", "12 ", "\t3\n", "1e3", + "1.5", "0x1f", "-0x1f", "12px", "1_000", "Infinity", "-Infinity", "+-1", "--1", +]; +for (const text of numbers) { + const value = Number(text); + console.log(JSON.stringify(text), value, Object.is(value, -0)); +} + +const line = "/route/17?request=123 200 45 3200"; +console.log(line.indexOf("?"), line.indexOf(" "), line.indexOf("x"), line.indexOf("")); +console.log(line.indexOf("?", -5), line.indexOf("?", NaN), line.indexOf("?", 10), line.indexOf(" ", 30)); +console.log(line.indexOf("", 100), line.indexOf("", -1), "".indexOf(""), "".indexOf("a")); +console.log(line.indexOf("é"), "aé?".indexOf("?"), "aé?".indexOf("é")); +console.log(line.slice(0, line.indexOf("?")), line.slice(-4), line.slice(5, 2), line.slice(-100, 3)); +console.log(line.slice(NaN, Infinity) === line, line.slice(2, -2), "héllo".slice(1, 3)); + +const fields = line.split(" "); +console.log(fields.length, fields.join("|")); +fields.push("extra"); +fields[0] = fields[0]!.slice(0, 6); +console.log(fields.length, fields.join("|")); + +const many = Array.from({ length: 150 }, (_, i) => String(i * 7)).join(","); +const pieces = many.split(","); +console.log(pieces.length, pieces[0], pieces[63], pieces[64], pieces[149]); +let sum = 0; +for (const piece of pieces) sum += Number(piece); +console.log(sum); +console.log("a,b,,c,".split(","), ",".split(","), "".split(","), "abc".split(",", 0)); +console.log("a b c d".split(" ", 2), "x--y--z".split("--"), "héllo wörld".split(" ")); + +const rows = "/a?x=1 200 5 10\n/b 404 7 20\n/a?y=2 500 9 30\n"; +const totals = new Map(); +for (const row of rows.split("\n")) { + const parts = row.split(" "); + if (parts.length !== 4) continue; + const raw = parts[0]!; + const query = raw.indexOf("?"); + const route = query < 0 ? raw : raw.slice(0, query); + totals.set(route, (totals.get(route) ?? 0) + Number(parts[1]) + Number(parts[3])); +} +console.log([...totals.entries()].map(([route, total]) => route + "=" + total).join(";")); diff --git a/tests/corpus/6951-unknown-cast-class-methods.ts b/tests/corpus/6951-unknown-cast-class-methods.ts new file mode 100644 index 000000000..7585a8752 --- /dev/null +++ b/tests/corpus/6951-unknown-cast-class-methods.ts @@ -0,0 +1,40 @@ +// Methods of a class instance read through `unknown` cast to a structural +// type: the read finds the prototype method, as Node's property lookup does. + +class Greeter { + label = "greeter"; + readonly name: string; + constructor(name: string) { + this.name = name; + } + describe(): string { + return `${this.label}:${this.name}`; + } + shout(): string { + return this.describe().toUpperCase(); + } +} + +class LoudGreeter extends Greeter { + extra(): number { + return 42; + } +} + +type Describable = { describe?: () => string; shout?: () => string; label?: string }; + +function inspect(value: unknown): void { + const shaped = value as Describable; + console.log(typeof shaped.describe, typeof shaped.shout, shaped.label); + console.log(shaped.describe?.() ?? "no describe"); + console.log(shaped.shout?.() ?? "no shout"); +} + +inspect(new Greeter("ada")); +inspect(new LoudGreeter("grace")); +inspect({ label: "plain", describe: () => "own function" }); +inspect({ label: "bare" }); + +const loud: unknown = new LoudGreeter("lin"); +const withExtra = loud as { extra?: () => number; missing?: () => number }; +console.log(withExtra.extra?.(), withExtra.missing?.() ?? "no missing"); diff --git a/tests/corpus/6960-replace-char-blocks.ts b/tests/corpus/6960-replace-char-blocks.ts new file mode 100644 index 000000000..af95a8a67 --- /dev/null +++ b/tests/corpus/6960-replace-char-blocks.ts @@ -0,0 +1,63 @@ +// Chains of global one-character replacements over subjects of every +// length up to 70: matches at each position (block edges included), runs +// of matches, non-ASCII neighbors, empty and long replacements, chains of +// up to ten characters, and lone surrogates meeting at a seam. +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function strip(text: string): string { + return text.replace(/a/g, "").replace(/e/g, "").replace(/i/g, ""); +} +function widen(text: string): string { + return text.replace(/x/g, "[a long replacement text that grows the result]").replace(/y/g, "é"); +} +function many(text: string): string { + return text + .replace(/0/g, "zero") + .replace(/1/g, "one") + .replace(/2/g, "two") + .replace(/3/g, "three") + .replace(/4/g, "four") + .replace(/5/g, "five") + .replace(/6/g, "six") + .replace(/7/g, "seven") + .replace(/8/g, "eight") + .replace(/9/g, "nine"); +} + +let total = 0; +let digest = 0; +function note(text: string): void { + total += text.length; + for (let i = 0; i < text.length; i++) digest = (digest * 31 + text.charCodeAt(i)) % 1000000007; +} +for (let length = 0; length <= 70; length++) { + const base = "abcdefghij".repeat(8).slice(0, length); + note(escapeHtml(base)); + note(strip(base)); + for (let at = 0; at < length; at++) { + const marked = base.slice(0, at) + "&" + base.slice(at + 1); + note(escapeHtml(marked)); + const pair = base.slice(0, at) + '<"' + base.slice(at + 2); + note(escapeHtml(pair)); + note(widen(base.slice(0, at) + "x" + base.slice(at + 1))); + } + note(escapeHtml("&".repeat(length))); + note(escapeHtml('<>"&'.repeat(length).slice(0, length))); + note(many("0123456789".repeat(8).slice(0, length))); + note(strip("aei".repeat(length))); +} +console.log(total, digest); + +console.log(escapeHtml("héllo & wörld <ü> \"ç\" & 中文 ")); +console.log(escapeHtml("ünïcödé ".repeat(5) + "&<>\"" + " ñ".repeat(9))); +console.log(widen("xyxyxy"), widen("é x é y é"), strip("aeiaeiaei"), many("90210")); +console.log(escapeHtml("x".repeat(15) + "&" + "y".repeat(16) + "<" + "z".repeat(17) + ">")); + +// A lone high surrogate before a replaced character and a lone low one in +// its replacement form a pair at the seam. +const high = "\uD83D"; +const joined = (high + "&").replace(/&/g, "\uDE00"); +console.log(joined.length, joined.codePointAt(0), joined === "😀"); +const longJoined = ("abcdefghijklmnop" + high + "&qrstuvwxyz").replace(/&/g, "\uDE00"); +console.log(longJoined.length, longJoined.codePointAt(16), longJoined.slice(16, 18) === "😀"); diff --git a/tests/corpus/6987-array-view-named-properties.ts b/tests/corpus/6987-array-view-named-properties.ts new file mode 100644 index 000000000..ee357d072 --- /dev/null +++ b/tests/corpus/6987-array-view-named-properties.ts @@ -0,0 +1,114 @@ +// Named properties written to a typed array through an `unknown` view stay +// on the array: later views read them, key walks list them after the +// indices, and `for...in` over the typed array visits them. Holes are +// skipped by `for...in`. + +type Bag = Record; + +const numbers: number[] = [1, 2, 3]; +const numbersView = numbers as unknown as Bag; +numbersView.extra = "named"; +console.log(numbersView.extra, (numbers as unknown as Bag).extra); +console.log(Object.keys(numbersView), JSON.stringify(numbers), numbers.length); +console.log("extra" in numbersView, Object.hasOwn(numbersView, "extra")); + +numbers.push(4); +numbers[0] = 10; +console.log((numbers as unknown as Bag).extra, numbers.join(",")); +console.log((numbers.slice() as unknown as Bag).extra); + +(numbers as unknown as Bag).extra = "again"; +(numbers as unknown as Bag).count = 2; +console.log(Object.entries(numbers as unknown as Bag)); +for (const key in numbers) console.log("numbers key", key); + +delete (numbers as unknown as Bag).extra; +console.log((numbers as unknown as Bag).extra, Object.keys(numbers as unknown as Bag)); + +const words: string[] = ["a", "b"]; +(words as unknown as Bag).label = { nested: [1, 2] }; +const label = (words as unknown as Bag).label as { nested: number[] }; +console.log(label.nested.length, words.length); +for (const key in words) console.log("words key", key); + +interface Point { + x: number; + y: number; +} +const points: Point[] = [{ x: 1, y: 2 }]; +(points as unknown as Bag).origin = "zero"; +console.log((points as unknown as Bag).origin, points[0]!.x); +for (const key in points) console.log("points key", key); + +class Item { + name: string; + constructor(name: string) { + this.name = name; + } +} +const items: Item[] = [new Item("first")]; +(items as unknown as Bag).kind = "items"; +console.log((items as unknown as Bag).kind, items[0]!.name); + +const flags: boolean[] = [true, false]; +Object.defineProperty(flags as unknown as Bag, "hidden", { value: 1, enumerable: false }); +(flags as unknown as Bag).shown = 2; +console.log((flags as unknown as Bag).hidden, (flags as unknown as Bag).shown); +for (const key in flags) console.log("flags key", key); + +const holes: (number | undefined)[] = [1, , 3, undefined]; +for (const key in holes) console.log("holes key", key); +(holes as unknown as Bag).tail = true; +for (const key in holes) console.log("holes key after", key); + +const offsets: number[] = [1, 2]; +offsets[-1] = 5; +offsets[1.5] = 6; +(offsets as unknown as Bag).name = "offsets"; +console.log(offsets[-1], offsets[1.5], (offsets as unknown as Bag).name); +for (const key in offsets) console.log("offsets key", key); + +const shrinking: number[] = [1, 2, 3, 4]; +(shrinking as unknown as Bag).after = "kept"; +for (const key in shrinking) { + console.log("shrinking key", key); + if (key === "1") shrinking.length = 2; +} + +const match = /b(?c)?/.exec("abc"); +if (match) for (const key in match) console.log("match key", key); + +// A numeric property deleted through a view is gone from the array. +const numeric: number[] = [1, 2]; +numeric[-1] = 5; +delete (numeric as unknown as Bag)["-1"]; +console.log(numeric[-1], "-1" in (numeric as unknown as Bag)); +for (const key in numeric) console.log("numeric key", key); + +// A numeric-looking key written through a view is deleted through a view too. +const viewNumeric: string[] = ["a"]; +(viewNumeric as unknown as Bag)["-2"] = "view"; +console.log((viewNumeric as unknown as Bag)["-2"], "-2" in (viewNumeric as unknown as Bag)); +delete (viewNumeric as unknown as Bag)["-2"]; +console.log((viewNumeric as unknown as Bag)["-2"], "-2" in (viewNumeric as unknown as Bag)); +for (const key in viewNumeric) console.log("view numeric key", key); + +// A named key deleted before its turn is not visited. +const visited: number[] = [1]; +(visited as unknown as Bag).first = 1; +(visited as unknown as Bag).second = 2; +(visited as unknown as Bag).third = 3; +for (const key in visited) { + console.log("visited key", key); + if (key === "first") delete (visited as unknown as Bag).second; +} + +// A non-configurable numeric-looking property refuses the delete and stays. +const fixed: string[] = ["a"]; +Object.defineProperty(fixed as unknown as Bag, "-3", { value: "kept", enumerable: true }); +try { + delete (fixed as unknown as Bag)["-3"]; +} catch (e) { + console.log((e as Error).name); +} +console.log((fixed as unknown as Bag)["-3"], "-3" in (fixed as unknown as Bag)); diff --git a/tests/corpus/6990-record-writes-from-js/helpers.js b/tests/corpus/6990-record-writes-from-js/helpers.js new file mode 100644 index 000000000..6f43a90ce --- /dev/null +++ b/tests/corpus/6990-record-writes-from-js/helpers.js @@ -0,0 +1,13 @@ +// Untyped helpers that add, replace and remove properties of the objects +// they receive. +export function write(target, key, value) { + target[key] = value; +} + +export function remove(target, key) { + delete target[key]; +} + +export function keysOf(target) { + return Object.keys(target).join(","); +} diff --git a/tests/corpus/6990-record-writes-from-js/main.ts b/tests/corpus/6990-record-writes-from-js/main.ts new file mode 100644 index 000000000..2ca26b3e2 --- /dev/null +++ b/tests/corpus/6990-record-writes-from-js/main.ts @@ -0,0 +1,29 @@ +// A typed object literal passed to untyped JavaScript keeps the properties +// that code adds: JSON.stringify, Object.keys, later untyped reads and +// console.log see them, in JavaScript's own-key order (integer keys first). +import { keysOf, remove, write } from "./helpers.js"; + +const numbered = { 0: "zero", 1.5: "x" }; +write(numbered, 2, "two"); +console.log(JSON.stringify(numbered), keysOf(numbered), Object.keys(numbered)); + +const point = { x: 1, y: 2 }; +write(point, "label", "origin"); +write(point, "x", 10); +console.log(JSON.stringify(point), point.x, keysOf(point)); +write(point, "label", "moved"); +console.log(JSON.stringify(point)); +remove(point, "label"); +console.log(JSON.stringify(point), keysOf(point)); + +const config = { name: "app", retries: 3 }; +write(config, "debug", true); +write(config, "tags", ["a", "b"]); +console.log(JSON.stringify(config), keysOf(config)); +console.log(config); + +// Many integer keys added in descending order serialize in ascending order. +const many = { name: "many" }; +for (let i = 3000; i > 0; i--) write(many, i, i); +const text = JSON.stringify(many); +console.log(text.length, text.slice(0, 40), text.slice(-30)); diff --git a/tests/corpus/6991-inspect-class-name-instanceof.ts b/tests/corpus/6991-inspect-class-name-instanceof.ts new file mode 100644 index 000000000..5354048c9 --- /dev/null +++ b/tests/corpus/6991-inspect-class-name-instanceof.ts @@ -0,0 +1,31 @@ +// util.inspect prints a class instance with its class's name, also when +// the program tests `instanceof` on unknown values (which gives the class +// a runtime prototype object). + +class Leaf { + x = 1; + twin: Leaf | undefined = undefined; +} +class Branch extends Leaf { + label = "b"; + describe(): string { + return `branch ${this.label}`; + } +} +class Plain { + y = 2; +} + +const solo = new Leaf(); +const branch = new Branch(); +console.log(solo); +console.log(branch, branch.describe()); +console.log([solo, branch], { solo }); +console.log(new Plain()); + +const boxed: unknown[] = [solo, branch, new Plain(), 3]; +console.log(boxed.map((v) => (v instanceof Leaf ? "L" : String(v instanceof Plain))).join(",")); +console.log(boxed.map((v) => (v instanceof Branch ? "B" : "-")).join(",")); + +solo.twin = branch; +console.log(solo); diff --git a/tests/corpus/7000-umd-package-entry/main.ts b/tests/corpus/7000-umd-package-entry/main.ts new file mode 100644 index 000000000..4b8b3e246 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/main.ts @@ -0,0 +1,10 @@ +// A package whose main entry is TypeScript's UMD emit: each module's body is +// a factory the wrapper calls with the module's own require and exports, so +// its requires and export assignments sit inside a function. Node runs the +// CommonJS branch, and the named imports read the factory's exports. +import { Shape, area, describe, label, unitCount } from "umd-shapes"; + +console.log(label, unitCount); +console.log(area({ kind: Shape.Square, size: 3 }), area({ kind: Shape.Circle, size: 1 }).toFixed(4)); +console.log(describe({ kind: Shape.Square, size: 2 })); +console.log(Shape.Circle, Shape[Shape.Square]); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js new file mode 100644 index 000000000..0ea89b34e --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/impl/measure.js @@ -0,0 +1,22 @@ +(function (factory) { + if (typeof module === "object" && typeof module.exports === "object") { + var v = factory(require, exports); + if (v !== undefined) module.exports = v; + } + else if (typeof define === "function" && define.amd) { + define(["require", "exports"], factory); + } +})(function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.units = void 0; + exports.square = square; + exports.circle = circle; + exports.units = ["cm", "m"]; + function square(size) { + return size * size; + } + function circle(radius) { + return Math.PI * radius * radius; + } +}); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts new file mode 100644 index 000000000..0982da9ef --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.d.ts @@ -0,0 +1,12 @@ +export declare enum Shape { + Square = 0, + Circle = 1 +} +export interface Figure { + kind: Shape; + size: number; +} +export declare const label: string; +export declare const unitCount: number; +export declare function area(figure: Figure): number; +export declare function describe(figure: Figure): string; diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js new file mode 100644 index 000000000..26162fbd9 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/lib/main.js @@ -0,0 +1,29 @@ +(function (factory) { + if (typeof module === "object" && typeof module.exports === "object") { + var v = factory(require, exports); + if (v !== undefined) module.exports = v; + } + else if (typeof define === "function" && define.amd) { + define(["require", "exports", "./impl/measure"], factory); + } +})(function (require, exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.unitCount = exports.label = exports.Shape = void 0; + exports.area = area; + exports.describe = describe; + const measure = require("./impl/measure"); + var Shape; + (function (Shape) { + Shape[Shape["Square"] = 0] = "Square"; + Shape[Shape["Circle"] = 1] = "Circle"; + })(Shape || (exports.Shape = Shape = {})); + exports.label = "shapes"; + exports.unitCount = measure.units.length; + function area(figure) { + return figure.kind === Shape.Square ? measure.square(figure.size) : measure.circle(figure.size); + } + function describe(figure) { + return `${Shape[figure.kind]} of ${figure.size} ${measure.units[0]}: ${area(figure)}`; + } +}); diff --git a/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json new file mode 100644 index 000000000..e827ecc41 --- /dev/null +++ b/tests/corpus/7000-umd-package-entry/node_modules/umd-shapes/package.json @@ -0,0 +1 @@ +{ "name": "umd-shapes", "version": "1.0.0", "main": "./lib/main.js", "types": "./lib/main.d.ts" } diff --git a/tests/corpus/array-exact-capacity-builders.ts b/tests/corpus/array-exact-capacity-builders.ts new file mode 100644 index 000000000..da3a587ed --- /dev/null +++ b/tests/corpus/array-exact-capacity-builders.ts @@ -0,0 +1,65 @@ +// Arrays whose final length is known while they are built: map results, +// new Array(n) filled by index, concat results, spread literals and +// Array.from({ length: n }). Holes, lengths and later appends behave as +// in JavaScript. +class Item { + id: number; + constructor(id: number) { + this.id = id; + } +} +const items: Item[] = []; +for (let i = 0; i < 9; i++) items.push(new Item(i)); +const ids = (xs: Item[]): string => xs.map((x) => x.id).join(","); + +const doubled = items.map((x) => new Item(x.id * 2)); +console.log(doubled.length, ids(doubled)); +doubled.push(new Item(99)); +console.log(doubled.length, ids(doubled)); + +const filled: Item[] = new Array(5); +for (let i = 0; i < filled.length; i++) filled[i] = items[i]!; +console.log(filled.length, ids(filled)); +const partial: number[] = new Array(6); +partial[1] = 10; +partial[4] = 40; +console.log(partial.length, 0 in partial, 1 in partial, JSON.stringify(partial)); +partial.push(7); +console.log(partial.length, JSON.stringify(partial)); + +const wide: number[] = []; +wide.length = 200000; +wide[150000] = 1; +console.log(wide.length, wide[150000], 5 in wide); +const empty: number[] = []; +empty.length = 0; +empty.push(3); +console.log(empty.length, empty[0]); + +const left = items.slice(0, 3); +const right = items.slice(3, 7); +const joined = left.concat(right, items.slice(8)); +console.log(joined.length, ids(joined)); +const holey: number[] = [1, 2, 3]; +holey.length = 5; +const withHoles = holey.concat([6, 7]); +console.log(withHoles.length, 3 in withHoles, 4 in withHoles, JSON.stringify(withHoles)); + +const spread = [new Item(-1), ...left, new Item(-2), ...right, new Item(-3)]; +console.log(spread.length, ids(spread)); +const spreadHoles = [...holey, 9]; +console.log(spreadHoles.length, 3 in spreadHoles, JSON.stringify(spreadHoles)); +const self = [...left, ...left]; +console.log(self.length, ids(self)); + +const zeros = Array.from({ length: 7 }, () => 0); +console.log(zeros.length, zeros.join("")); +const counted = Array.from({ length: 4 }, (_, i) => i * i); +console.log(counted.join(",")); +const none = Array.from({ length: 0 }, () => 1); +console.log(none.length); + +const sparseSource: number[] = []; +sparseSource[3] = 30; +const mapped = sparseSource.map((x) => x + 1); +console.log(mapped.length, 0 in mapped, 3 in mapped, mapped[3]); diff --git a/tests/coverage-fixtures/dynamic-tier/hot.js b/tests/coverage-fixtures/dynamic-tier/hot.js new file mode 100644 index 000000000..af2afac07 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/hot.js @@ -0,0 +1,36 @@ +// Untyped JavaScript whose functions loop over their arguments: the build +// names them (SC6101). `scale` runs once and stays quiet. +function scale(points, factor) { + return { points, factor }; +} + +function centroid(points) { + let x = 0; + let y = 0; + for (const p of points) { + x += p.x; + y += p.y; + } + return { x: x / points.length, y: y / points.length }; +} + +function distance(a, b) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.sqrt(dx * dx + dy * dy); +} + +function spread(points) { + const c = centroid(points); + let total = 0; + for (let i = 0; i < points.length; i++) total += distance(points[i], c); + return total; +} + +const points = [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 0, y: 3 }, +]; +const scaled = scale(points, 2); +console.log(scaled.factor, spread(points).toFixed(3)); diff --git a/tests/coverage-fixtures/dynamic-tier/package/main.ts b/tests/coverage-fixtures/dynamic-tier/package/main.ts new file mode 100644 index 000000000..3acd390da --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/package/main.ts @@ -0,0 +1,13 @@ +// A program whose own hot function uses an `any` value, and which calls an +// untyped npm package: the warning names the function and summarizes the +// package's dynamic-tier code. +import { tally } from "untyped-lib"; + +function sumAmounts(rows: any): number { + let total = 0; + for (const row of rows) total += row.amount; + return total; +} + +const rows = [{ amount: 2 }, { amount: 5 }]; +console.log(sumAmounts(rows), tally(["a", "b", "a"]).a); diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts new file mode 100644 index 000000000..c8871efd0 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.d.ts @@ -0,0 +1 @@ +export function tally(values: any): any; diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js new file mode 100644 index 000000000..5695b6df2 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/index.js @@ -0,0 +1,6 @@ +// Counts each value: untyped JavaScript in a package. +exports.tally = function tally(values) { + const counts = {}; + for (const v of values) counts[v] = (counts[v] || 0) + 1; + return counts; +}; diff --git a/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json new file mode 100644 index 000000000..a81ea84d4 --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/package/node_modules/untyped-lib/package.json @@ -0,0 +1 @@ +{"name": "untyped-lib", "version": "1.0.0", "main": "index.js", "types": "index.d.ts"} diff --git a/tests/coverage-fixtures/dynamic-tier/quiet.ts b/tests/coverage-fixtures/dynamic-tier/quiet.ts new file mode 100644 index 000000000..1b0a212ea --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/quiet.ts @@ -0,0 +1,23 @@ +// Untyped values read once (a configuration at startup, a loop's +// initializer, a for...of iterable): dynamic, but not hot, so the build +// names nothing. The loops repeat only typed work. +const config: any = JSON.parse('{"scale": 3, "label": "sum"}'); +const scale: number = config.scale; +const label: string = config.label; + +function sum(values: number[]): number { + let total = 0; + for (const v of values) total += v * scale; + return total; +} + +// Untyped values read once, in a loop's initializer and in a for...of +// iterable: the loops repeat only typed work. +function offsets(raw: any): number { + let total = 0; + for (let i: number = raw.start; i < 3; i++) total += i; + for (const n of raw.extra as number[]) total += n; + return total; +} + +console.log(label, sum([1, 2, 3]), offsets({ start: 1, extra: [4, 5] })); diff --git a/tests/coverage-fixtures/dynamic-tier/typed-hot.ts b/tests/coverage-fixtures/dynamic-tier/typed-hot.ts new file mode 100644 index 000000000..e72a7ca7a --- /dev/null +++ b/tests/coverage-fixtures/dynamic-tier/typed-hot.ts @@ -0,0 +1,21 @@ +// TypeScript functions that loop over untyped values: an `any` parameter +// and an untyped JSON.parse result (SC6101 names both). +function regionTotals(items: any): Map { + const out = new Map(); + for (const item of items) out.set(item.region, (out.get(item.region) ?? 0) + item.amount); + return out; +} + +function countRegions(text: string): number { + const parsed = JSON.parse(text); + let n = 0; + for (let i = 0; i < parsed.length; i++) if (parsed[i].region !== "") n++; + return n; +} + +const rows = [ + { amount: 3, region: "eu" }, + { amount: 4, region: "us" }, +]; +for (const [region, total] of regionTotals(rows)) console.log(region, total); +console.log(countRegions(JSON.stringify(rows))); diff --git a/tests/diagnostics/fs-url-path-fences.ts b/tests/diagnostics/fs-url-path-fences.ts new file mode 100644 index 000000000..080c561e1 --- /dev/null +++ b/tests/diagnostics/fs-url-path-fences.ts @@ -0,0 +1,15 @@ +// URL paths whose conversion would run in a different order than Node's. +import * as fs from "node:fs"; + +const url = new URL("file:///tmp/fs-url-path-fences"); +const encoding: BufferEncoding = process.argv.length > 9 ? "latin1" : "utf8"; +const recursive = process.argv.length > 9; + +// Node validates the string source before converting the URL destination. +fs.copyFileSync("/tmp/source", url); +// Node validates the options before the path. +console.log(fs.readFileSync(url, encoding)); +fs.mkdirSync(url, { recursive }); +// A literal's members are lowered one by one, so they cannot be evaluated +// ahead of the conversion as one value. +console.log(fs.statSync(url, { throwIfNoEntry: process.argv.length > 9 })); diff --git a/tests/diagnostics/tls-sni-callback-fences.ts b/tests/diagnostics/tls-sni-callback-fences.ts new file mode 100644 index 000000000..20b6ce271 --- /dev/null +++ b/tests/diagnostics/tls-sni-callback-fences.ts @@ -0,0 +1,13 @@ +// tls.createServer SNICallback forms that have no lowering. +import { readFileSync } from "node:fs"; +import * as tls from "node:tls"; + +const cert = readFileSync("cert.pem"); +const key = readFileSync("key.pem"); +type Answer = (err: Error | null, ctx?: tls.SecureContext) => void; +function makeSni(): (servername: string, cb: Answer) => void { + return (_servername, cb) => cb(null, undefined); +} + +// SNICallback with options the runtime options walk reads. +tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); diff --git a/tests/fixtures/node-types/fs-url-path-fences.ts b/tests/fixtures/node-types/fs-url-path-fences.ts new file mode 100644 index 000000000..4ae142189 --- /dev/null +++ b/tests/fixtures/node-types/fs-url-path-fences.ts @@ -0,0 +1,9 @@ +import { existsSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; + +const url = new URL("file:///tmp/node-types-url-fences"); +// existsSync answers false for a URL it cannot convert. +console.log(existsSync(url)); +// The fs.promises functions reject for a URL they cannot convert. +void readFile(url, "utf8"); +void stat(url); diff --git a/tests/fixtures/node-types/fs-url-paths.ts b/tests/fixtures/node-types/fs-url-paths.ts new file mode 100644 index 000000000..6d33d708d --- /dev/null +++ b/tests/fixtures/node-types/fs-url-paths.ts @@ -0,0 +1,10 @@ +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const dir = mkdtempSync(join(tmpdir(), "node-types-url-")); +const file = pathToFileURL(join(dir, "a b.txt")); +writeFileSync(file, "hello"); +console.log(readFileSync(file, "utf8"), readFileSync(file).length, statSync(file).size); +rmSync(pathToFileURL(dir), { recursive: true }); diff --git a/tests/fixtures/node-types/stream-construction.ts b/tests/fixtures/node-types/stream-construction.ts index a28643f7c..dfe4a2320 100644 --- a/tests/fixtures/node-types/stream-construction.ts +++ b/tests/fixtures/node-types/stream-construction.ts @@ -42,17 +42,21 @@ function gunzipThrough(payload: Buffer): Promise { }); } +// Resolves after 'close', which follows 'end' once the descriptor is +// closed: the result never races the child processes started next. function readFileStream(file: string): Promise { return new Promise((resolve) => { const stream = fs.createReadStream(file); let text = ""; - const release = () => console.log("released"); + const release = () => { + console.log("released"); + resolve(text); + }; stream.on("close", release); stream.on("error", release); stream.on("data", (chunk) => { text += chunk.toString(); }); - stream.on("end", () => resolve(text)); }); } diff --git a/tests/harness/__snapshots__/coverage-dynamic-tier.txt b/tests/harness/__snapshots__/coverage-dynamic-tier.txt new file mode 100644 index 000000000..8072021e3 --- /dev/null +++ b/tests/harness/__snapshots__/coverage-dynamic-tier.txt @@ -0,0 +1,13 @@ +scriptc coverage tests/coverage-fixtures/dynamic-tier/hot.js + + statements analyzed 18 + compile statically 18 (100%) + in dynamic tier 14 (operate on values typed only at runtime; --explain-dynamic lists them) + + fully static — all analyzed statements compile to native code. + + untyped and likely hot 3 functions that likely run often use untyped values, which runs slower than typed code: + centroid (hot.js:7): untyped JavaScript file, in a loop + spread (hot.js:23): untyped JavaScript file, in a loop + distance (hot.js:17): untyped JavaScript file, called in a loop + hint: add types to compile these statically; see scriptc coverage --explain-dynamic \ No newline at end of file diff --git a/tests/harness/__snapshots__/fs-url-path-fences.ts.txt b/tests/harness/__snapshots__/fs-url-path-fences.ts.txt new file mode 100644 index 000000000..1a53f8df5 --- /dev/null +++ b/tests/harness/__snapshots__/fs-url-path-fences.ts.txt @@ -0,0 +1,35 @@ +fs-url-path-fences.ts:9:1 - error SC2020: 'copyFileSync with a string path before a URL path' is part of the standard library types but has no scriptc lowering yet + + 8 | // Node validates the string source before converting the URL destination. + 9 | fs.copyFileSync("/tmp/source", url); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 10 | // Node validates the options before the path. + + hint: convert the URL first: fileURLToPath(url) from node:url + +fs-url-path-fences.ts:11:34 - error SC2020: 'readFileSync with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet + + 10 | // Node validates the options before the path. + 11 | console.log(fs.readFileSync(url, encoding)); + | ^~~~~~~~ + 12 | fs.mkdirSync(url, { recursive }); + + hint: convert the URL first: fileURLToPath(url) from node:url + +fs-url-path-fences.ts:12:19 - error SC2020: 'mkdirSync with a URL path and options that are not literals' is part of the standard library types but has no scriptc lowering yet + + 11 | console.log(fs.readFileSync(url, encoding)); + 12 | fs.mkdirSync(url, { recursive }); + | ^~~~~~~~~~~~~ + 13 | // A literal's members are lowered one by one, so they cannot be evaluated + + hint: convert the URL first: fileURLToPath(url) from node:url + +fs-url-path-fences.ts:15:30 - error SC2020: 'statSync with a URL path and a later literal that has effects' is part of the standard library types but has no scriptc lowering yet + + 14 | // ahead of the conversion as one value. + 15 | console.log(fs.statSync(url, { throwIfNoEntry: process.argv.length > 9 })); + | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 16 | + + hint: convert the URL first: fileURLToPath(url) from node:url \ 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 d1e9395ed..bbbed7fe2 100644 --- a/tests/harness/__snapshots__/index-signature-values.ts.txt +++ b/tests/harness/__snapshots__/index-signature-values.ts.txt @@ -7,15 +7,6 @@ index-signature-values.ts:8:7 - error SC2006: values of type '{ [s: symbol]: num hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering -index-signature-values.ts:9:13 - error SC2004: uses of 'bySymbol' inherit the blocker on its declaration - - 8 | const bySymbol: { [s: symbol]: number } = {}; - 9 | console.log(bySymbol); - | ^~~~~~~~ - 10 | - - 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 (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 = {}; @@ -30,13 +21,4 @@ index-signature-values.ts:18:7 - error SC2006: values of type '{ [k: string]: st | ^~~~ 19 | console.log(dual); - hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering - -index-signature-values.ts:19:13 - error SC2004: uses of 'dual' inherit the blocker on its declaration - - 18 | const dual: { [k: string]: string | number; [n: number]: number } = {}; - 19 | console.log(dual); - | ^~~~ - 20 | - - hint: the declaration of 'dual' did not compile — fix the diagnostic reported there and these sites clear with it \ No newline at end of file + hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering \ No newline at end of file diff --git a/tests/harness/__snapshots__/retired-engine-2104-computed-key-destructuring.ts.txt b/tests/harness/__snapshots__/retired-engine-2104-computed-key-destructuring.ts.txt index bb30cf581..b5417ae72 100644 --- a/tests/harness/__snapshots__/retired-engine-2104-computed-key-destructuring.ts.txt +++ b/tests/harness/__snapshots__/retired-engine-2104-computed-key-destructuring.ts.txt @@ -7,18 +7,11 @@ retired-engine-2104-computed-key-destructuring.ts:30:38 - error SC2009: values o 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') -retired-engine-2104-computed-key-destructuring.ts:33:8 - error SC1031: destructuring with computed keys that do not fold to one property name is not supported yet - - 32 | traceS = ""; - 33 | let [{ [order(1)]: y } = order(0)] = [{}]; - | ^~~~~~~~~~~~~ - 34 | console.log(traceS); - -retired-engine-2104-computed-key-destructuring.ts:36:7 - error SC1031: destructuring with computed keys that do not fold to one property name is not supported yet +retired-engine-2104-computed-key-destructuring.ts:36:58 - error SC1031: destructuring declaration from an `any` source whose expression spelling is unsupported (use a variable, ordinary property chain, or ordinary call) is not supported yet 35 | traceS = ""; 36 | let { [order(0)]: { [order(2)]: z } = order(1), ...w } = {} as any; - | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + | ^~~~~~~~~ 37 | console.log(traceS); retired-engine-2104-computed-key-destructuring.ts:39:8 - error SC1031: rest bindings whose packed type has no static mapping (only plain record types pack) is not supported yet diff --git a/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt new file mode 100644 index 000000000..c45608bb9 --- /dev/null +++ b/tests/harness/__snapshots__/tls-sni-callback-fences.ts.txt @@ -0,0 +1,8 @@ +tls-sni-callback-fences.ts:13:54 - error SC2020: 'tls.createServer with SNICallback and options the runtime walk reads' is part of the standard library types but has no scriptc lowering yet + + 12 | // SNICallback with options the runtime options walk reads. + 13 | tls.createServer({ cert, key, ALPNProtocols: ["h2"], SNICallback: makeSni }); + | ^~~~~~~~~~~~~~~~~~~~ + 14 | + + hint: SNICallback lowers with cert and key; ALPNProtocols, minVersion, maxVersion and the validated options are not combined with it yet \ No newline at end of file diff --git a/tests/harness/__snapshots__/utility-types.ts.txt b/tests/harness/__snapshots__/utility-types.ts.txt index 71f4d3083..02595967b 100644 --- a/tests/harness/__snapshots__/utility-types.ts.txt +++ b/tests/harness/__snapshots__/utility-types.ts.txt @@ -7,15 +7,6 @@ utility-types.ts:15:7 - error SC2006: values of type '{ [s: symbol]: string; }' hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering -utility-types.ts:16:13 - error SC2004: uses of 'bySymbol' inherit the blocker on its declaration - - 15 | const bySymbol: { [s: symbol]: string } = {}; - 16 | console.log(bySymbol); - | ^~~~~~~~ - 17 | - - hint: the declaration of 'bySymbol' did not compile — fix the diagnostic reported there and these sites clear with it - utility-types.ts:18:7 - error SC2006: values of type '{ [k: string]: Date; }' cannot be compiled: this index signature is outside the supported shape (string or number keys; values limited to numbers, strings, booleans, records, classes, arrays, unions, functions, Maps, Sets, RegExps, Promises, or 'unknown') 17 | @@ -23,13 +14,4 @@ utility-types.ts:18:7 - error SC2006: values of type '{ [k: string]: Date; }' ca | ^~~~~~~ 19 | const boot = dateBag["boot"]; - hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering - -utility-types.ts:19:14 - error SC2004: uses of 'dateBag' inherit the blocker on its declaration - - 18 | const dateBag: { [k: string]: Date } = {}; - 19 | const boot = dateBag["boot"]; - | ^~~~~~~ - 20 | console.log(boot); - - 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 + hint: string- and number-keyed index signatures over the supported value types compile directly; symbol keys and dual signatures with unequal value types have no lowering \ No newline at end of file diff --git a/tests/harness/corpus-costs/6211-worker-message-batches.ts.json b/tests/harness/corpus-costs/6211-worker-message-batches.ts.json new file mode 100644 index 000000000..af8e672d4 --- /dev/null +++ b/tests/harness/corpus-costs/6211-worker-message-batches.ts.json @@ -0,0 +1 @@ +{ "plain": 3694, "san": 3271 } diff --git a/tests/harness/corpus-costs/6212-worker-message-batch-ports.ts.json b/tests/harness/corpus-costs/6212-worker-message-batch-ports.ts.json new file mode 100644 index 000000000..fb0e48437 --- /dev/null +++ b/tests/harness/corpus-costs/6212-worker-message-batch-ports.ts.json @@ -0,0 +1 @@ +{ "plain": 5538, "san": 3201 } diff --git a/tests/harness/corpus-costs/6721-tls-alpn-protocols.ts.json b/tests/harness/corpus-costs/6721-tls-alpn-protocols.ts.json new file mode 100644 index 000000000..ab6ff1c99 --- /dev/null +++ b/tests/harness/corpus-costs/6721-tls-alpn-protocols.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 60600 } diff --git a/tests/harness/corpus-costs/6722-tls-min-max-version.ts.json b/tests/harness/corpus-costs/6722-tls-min-max-version.ts.json new file mode 100644 index 000000000..c1b1d2d17 --- /dev/null +++ b/tests/harness/corpus-costs/6722-tls-min-max-version.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 4700 } diff --git a/tests/harness/corpus-costs/6723-tls-connect-timeout.ts.json b/tests/harness/corpus-costs/6723-tls-connect-timeout.ts.json new file mode 100644 index 000000000..9ebfb8b9d --- /dev/null +++ b/tests/harness/corpus-costs/6723-tls-connect-timeout.ts.json @@ -0,0 +1 @@ +{ "plain": 6200, "san": 60300 } diff --git a/tests/harness/corpus-costs/6820-stream-push-nullable-chunks.ts.json b/tests/harness/corpus-costs/6820-stream-push-nullable-chunks.ts.json new file mode 100644 index 000000000..dd2eae649 --- /dev/null +++ b/tests/harness/corpus-costs/6820-stream-push-nullable-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 3100, "san": 41100 } diff --git a/tests/harness/corpus-costs/6821-stream-write-nullable-chunks.cjs.json b/tests/harness/corpus-costs/6821-stream-write-nullable-chunks.cjs.json new file mode 100644 index 000000000..24b9052ff --- /dev/null +++ b/tests/harness/corpus-costs/6821-stream-write-nullable-chunks.cjs.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 40400 } diff --git a/tests/harness/corpus-costs/6823-recursive-alias-generic-signature.ts.json b/tests/harness/corpus-costs/6823-recursive-alias-generic-signature.ts.json new file mode 100644 index 000000000..cf7a32433 --- /dev/null +++ b/tests/harness/corpus-costs/6823-recursive-alias-generic-signature.ts.json @@ -0,0 +1 @@ +{ "plain": 2300, "san": 30000 } diff --git a/tests/harness/corpus-costs/6860-unary-plus-primitive-unions.ts.json b/tests/harness/corpus-costs/6860-unary-plus-primitive-unions.ts.json new file mode 100644 index 000000000..8211f0716 --- /dev/null +++ b/tests/harness/corpus-costs/6860-unary-plus-primitive-unions.ts.json @@ -0,0 +1 @@ +{ "plain": 2430, "san": 6860 } diff --git a/tests/harness/corpus-costs/6861-unary-plus-optional-values.js.json b/tests/harness/corpus-costs/6861-unary-plus-optional-values.js.json new file mode 100644 index 000000000..376953c70 --- /dev/null +++ b/tests/harness/corpus-costs/6861-unary-plus-optional-values.js.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 5370 } diff --git a/tests/harness/corpus-costs/6920-zlib-callback-error-result.ts.json b/tests/harness/corpus-costs/6920-zlib-callback-error-result.ts.json new file mode 100644 index 000000000..247292295 --- /dev/null +++ b/tests/harness/corpus-costs/6920-zlib-callback-error-result.ts.json @@ -0,0 +1 @@ +{ "plain": 6386, "san": 8023 } diff --git a/tests/harness/corpus-costs/6931-fs-url-paths.ts.json b/tests/harness/corpus-costs/6931-fs-url-paths.ts.json new file mode 100644 index 000000000..458abd256 --- /dev/null +++ b/tests/harness/corpus-costs/6931-fs-url-paths.ts.json @@ -0,0 +1 @@ +{ "plain": 4139, "san": 50584 } diff --git a/tests/harness/corpus-costs/6942-js-buffer-view-of-declared-field.js.json b/tests/harness/corpus-costs/6942-js-buffer-view-of-declared-field.js.json new file mode 100644 index 000000000..be69de5f7 --- /dev/null +++ b/tests/harness/corpus-costs/6942-js-buffer-view-of-declared-field.js.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 2900 } diff --git a/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json b/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json new file mode 100644 index 000000000..9a69600b1 --- /dev/null +++ b/tests/harness/corpus-costs/6943-js-class-branch-bags.js.json @@ -0,0 +1 @@ +{ "plain": 4400, "san": 4900 } diff --git a/tests/harness/corpus-costs/6944-js-super-call-in-if-condition.js.json b/tests/harness/corpus-costs/6944-js-super-call-in-if-condition.js.json new file mode 100644 index 000000000..5904670c9 --- /dev/null +++ b/tests/harness/corpus-costs/6944-js-super-call-in-if-condition.js.json @@ -0,0 +1 @@ +{ "plain": 1700, "san": 2600 } diff --git a/tests/harness/corpus-costs/6950-string-field-fast-paths.ts.json b/tests/harness/corpus-costs/6950-string-field-fast-paths.ts.json new file mode 100644 index 000000000..62a6540b9 --- /dev/null +++ b/tests/harness/corpus-costs/6950-string-field-fast-paths.ts.json @@ -0,0 +1 @@ +{ "plain": 3899, "san": 5479 } diff --git a/tests/harness/corpus-costs/6951-unknown-cast-class-methods.ts.json b/tests/harness/corpus-costs/6951-unknown-cast-class-methods.ts.json new file mode 100644 index 000000000..49300a99a --- /dev/null +++ b/tests/harness/corpus-costs/6951-unknown-cast-class-methods.ts.json @@ -0,0 +1 @@ +{ "plain": 2618, "san": 37588 } diff --git a/tests/harness/corpus-costs/6960-replace-char-blocks.ts.json b/tests/harness/corpus-costs/6960-replace-char-blocks.ts.json new file mode 100644 index 000000000..2db0253c4 --- /dev/null +++ b/tests/harness/corpus-costs/6960-replace-char-blocks.ts.json @@ -0,0 +1 @@ +{ "plain": 2744, "san": 5875 } diff --git a/tests/harness/corpus-costs/6987-array-view-named-properties.ts.json b/tests/harness/corpus-costs/6987-array-view-named-properties.ts.json new file mode 100644 index 000000000..1e9e64ec5 --- /dev/null +++ b/tests/harness/corpus-costs/6987-array-view-named-properties.ts.json @@ -0,0 +1 @@ +{ "plain": 3300, "san": 4400 } diff --git a/tests/harness/corpus-costs/6990-record-writes-from-js/main.ts.json b/tests/harness/corpus-costs/6990-record-writes-from-js/main.ts.json new file mode 100644 index 000000000..b8b8cc39d --- /dev/null +++ b/tests/harness/corpus-costs/6990-record-writes-from-js/main.ts.json @@ -0,0 +1 @@ +{ "plain": 4300, "san": 3700 } diff --git a/tests/harness/corpus-costs/6991-inspect-class-name-instanceof.ts.json b/tests/harness/corpus-costs/6991-inspect-class-name-instanceof.ts.json new file mode 100644 index 000000000..b39c9d973 --- /dev/null +++ b/tests/harness/corpus-costs/6991-inspect-class-name-instanceof.ts.json @@ -0,0 +1 @@ +{ "plain": 4000, "san": 5400 } diff --git a/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json b/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json new file mode 100644 index 000000000..782707a3a --- /dev/null +++ b/tests/harness/corpus-costs/7000-umd-package-entry/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3911, "san": 3835 } diff --git a/tests/harness/corpus-costs/array-exact-capacity-builders.ts.json b/tests/harness/corpus-costs/array-exact-capacity-builders.ts.json new file mode 100644 index 000000000..5a4cc1dfd --- /dev/null +++ b/tests/harness/corpus-costs/array-exact-capacity-builders.ts.json @@ -0,0 +1 @@ +{ "plain": 4230, "san": 6130 } diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index cc91c0922..ca62352b1 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -113,6 +113,48 @@ test("ordinary code reports no divergence", () => { expect(coverage.divergences ?? []).toEqual([]); }); +test("likely-hot functions with untyped values report as a performance warning", async () => { + const { coverage } = analyze(fixture("dynamic-tier/hot.js")); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.divergences ?? []).toEqual([]); + expect((coverage.performance ?? []).map((d) => d.code)).toEqual(["SC6101"]); + // Not a blocker or a divergence: only --fail-on=dynamic fails on it. + expect(coveragePasses(coverage, "divergences")).toBe(true); + expect(coveragePasses(coverage, "dynamic")).toBe(false); + const envelope = coverageEnvelope(coverage, { compilerVersion: "test" }); + expect(envelope.diagnostics.map((d) => [d.code, d.category, d.severity])).toEqual([ + ["SC6101", "performance", "warning"], + ]); + await expect(report(fixture("dynamic-tier/hot.js"))).toMatchFileSnapshot( + "__snapshots__/coverage-dynamic-tier.txt", + ); +}); + +test("the performance warning names the untyped value of each function", () => { + const { coverage } = analyze(fixture("dynamic-tier/typed-hot.ts")); + expect(coverage.performance?.[0]?.message.split("\n")).toEqual([ + "2 functions that likely run often use untyped values, which runs slower than typed code:", + " regionTotals (typed-hot.ts:3): `items` is `any`, in a loop", + " countRegions (typed-hot.ts:9): `parsed` holds an untyped `JSON.parse` result, in a loop", + ]); +}); + +test("untyped setup code that runs once reports no performance warning", () => { + const { coverage } = analyze(fixture("dynamic-tier/quiet.ts")); + expect(coverage.diagnostics).toEqual([]); + expect(coverage.performance).toBeUndefined(); + expect(coveragePasses(coverage, "dynamic")).toBe(true); +}); + +test("dynamic-tier code in npm packages is summarized by package", () => { + const { coverage } = analyze(fixture("dynamic-tier/package/main.ts")); + expect(coverage.performance?.[0]?.message.split("\n")).toEqual([ + "1 function that likely runs often uses untyped values, which runs slower than typed code:", + " sumAmounts (main.ts:6): `rows` is `any`, in a loop", + " untyped code in npm packages: untyped-lib (1 function)", + ]); +}); + test("statements of a signature-blocked function count as not static", () => { const { coverage } = analyze(fixture("blocked-signature.ts")); const un = coverage.unreached?.stats; diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index e1e2bb96c..8af6998ca 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1727,3 +1727,53 @@ console.log(util.inspect(extra)); ); }); }); + +describe("elements on Array.prototype", () => { + // Holes of native arrays read undefined without consulting + // Array.prototype, so storing an element there refuses instead of + // letting later hole reads diverge from Node. Named members stay + // writable. + test("an index write is a named fence (TS lane)", async () => { + const r = await compileAndRun( + "array-prototype-element-ts", + `const proto = Array.prototype as unknown as Record; +proto.label = "named"; +console.log(proto.label); +(Array.prototype as unknown as Record)[1] = "proto"; +console.log("unreachable"); +`, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe("named\n"); + expect(r.stderr).toMatch( + /^Uncaught Error: Elements on Array\.prototype are not supported yet \[SC2020[^\]]*\]\n$/, + ); + }); + + test("an index write and a mutating method are named fences (JS lane)", async () => { + const write = await compileAndRun( + "array-prototype-element-js", + `Array.prototype[0] = "proto"; +console.log("unreachable"); +`, + "cjs", + ); + expect(write.exitCode).toBe(1); + expect(write.stdout).toBe(""); + expect(write.stderr).toMatch( + /^Uncaught Error: Elements on Array\.prototype are not supported yet \[SC2020[^\]]*\]\n$/, + ); + const push = await compileAndRun( + "array-prototype-push-js", + `Array.prototype.push("proto"); +console.log("unreachable"); +`, + "cjs", + ); + expect(push.exitCode).toBe(1); + expect(push.stdout).toBe(""); + expect(push.stderr).toMatch( + /^Uncaught Error: Elements on Array\.prototype are not supported yet \[SC2020[^\]]*\]\n$/, + ); + }); +}); diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index 54845ed05..df82efbcf 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -22,6 +22,9 @@ const fixturesRoot = join(repoRoot, "tests/fixtures"); const pilotRoot = join(fixturesRoot, "npm-static"); const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); const sanitize = process.env["SCRIPTC_SAN"] === "1"; +/* Sanitized builds of the published Effect pilots alone take 290 to 330 s on a 16-vCPU host, most of it in + * the sanitized native link: near or past the suite's 300 s default. */ +const effectBuildTimeout = sanitize ? 900_000 : undefined; // Measured milliseconds per test on a CI runner (scripts/ci-test-costs.mjs). const testCosts = ( JSON.parse(readFileSync(new URL("./npm-static-costs.json", import.meta.url), "utf8")) as { @@ -1135,34 +1138,42 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { expect(nativeRes.exitCode).toBe(nodeRes.exitCode); }, 180_000); - test("the published Effect generator logs and completes statically", async () => { - const entry = join(pilotRoot, "effect-gen-cli.ts"); - const binary = await buildStatic(entry, ["effect"]); - const [reference, native] = await Promise.all([ - runBinary(process.execPath, [entry]), - runBinary(binary, []), - ]); - const log = - /^timestamp=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z level=INFO fiber=#0 message="Hello, world!"\n$/; - expect(reference.stdout.toString()).toMatch(log); - expect(native.stdout.toString()).toMatch(log); - expect(comparableStderr(native.stderr)).toEqual(reference.stderr); - expect(native.exitCode).toBe(reference.exitCode); - expect(native.exitCode).toBe(0); - }); + test( + "the published Effect generator logs and completes statically", + async () => { + const entry = join(pilotRoot, "effect-gen-cli.ts"); + const binary = await buildStatic(entry, ["effect"]); + const [reference, native] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(binary, []), + ]); + const log = + /^timestamp=\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z level=INFO fiber=#0 message="Hello, world!"\n$/; + expect(reference.stdout.toString()).toMatch(log); + expect(native.stdout.toString()).toMatch(log); + expect(comparableStderr(native.stderr)).toEqual(reference.stderr); + expect(native.exitCode).toBe(reference.exitCode); + expect(native.exitCode).toBe(0); + }, + effectBuildTimeout, + ); - test("the published Effect generator has deterministic native results", async () => { - const entry = join(pilotRoot, "effect-gen-result-cli.ts"); - const binary = await buildStatic(entry, ["effect"]); - const [reference, native] = await Promise.all([ - runBinary(process.execPath, [entry]), - runBinary(binary, []), - ]); - expect(reference.stdout.toString()).toBe("Hello, world!\n42\n"); - expect(native.stdout).toEqual(reference.stdout); - expect(comparableStderr(native.stderr)).toEqual(reference.stderr); - expect(native.exitCode).toBe(reference.exitCode); - }); + test( + "the published Effect generator has deterministic native results", + async () => { + const entry = join(pilotRoot, "effect-gen-result-cli.ts"); + const binary = await buildStatic(entry, ["effect"]); + const [reference, native] = await Promise.all([ + runBinary(process.execPath, [entry]), + runBinary(binary, []), + ]); + expect(reference.stdout.toString()).toBe("Hello, world!\n42\n"); + expect(native.stdout).toEqual(reference.stdout); + expect(comparableStderr(native.stderr)).toEqual(reference.stderr); + expect(native.exitCode).toBe(reference.exitCode); + }, + effectBuildTimeout, + ); test("inferred variadic factories preserve checked callable results", async () => { const entry = join(pilotRoot, "variadic-factory-cli.ts"); diff --git a/tests/harness/npm.test.ts b/tests/harness/npm.test.ts index 9ab93f287..2df0d87ef 100644 --- a/tests/harness/npm.test.ts +++ b/tests/harness/npm.test.ts @@ -72,7 +72,7 @@ const KNOWN: Record = { "node24-esm-metadata": { run: "SC1090" }, "node24-event-emitter": { run: "SC2020" }, "node24-file-url-conversion": { build: "SC1090" }, - "node24-fs-descriptors": { run: "SC1090" }, + "node24-fs-descriptors": { run: "SC2020" }, "node24-mime": { run: "SC2020" }, "node24-mime-errors": { run: "SC1090" }, "node24-no-error-assertions": { run: "SC2020" }, diff --git a/tests/harness/project-config.test.ts b/tests/harness/project-config.test.ts index c7a5202f2..dabb9b0bf 100644 --- a/tests/harness/project-config.test.ts +++ b/tests/harness/project-config.test.ts @@ -764,6 +764,35 @@ test("node-types: exec's numeric ExecException.code remains a named fence", asyn } }); +test("node-types: fs functions take URL paths under @types/node's PathLike", async () => { + const dir = outDirFor("node-fs-url-paths"); + const result = await compile(join(nodeTypesDir, "fs-url-paths.ts"), { + outPath: join(dir, "fs-url-paths"), + outDir: dir, + sanitize, + }); + expect(result.ok, !result.ok ? JSON.stringify(result.diagnostics, null, 2) : "").toBe(true); + if (!result.ok) return; + const { stdout } = await execFileAsync(result.binaryPath); + expect(stdout).toBe("hello 5 5\n"); +}); + +test("node-types: existsSync and fs.promises URL paths remain named fences", async () => { + const dir = outDirFor("node-fs-url-path-fences"); + const result = await compile(join(nodeTypesDir, "fs-url-path-fences.ts"), { + outPath: join(dir, "fs-url-path-fences"), + outDir: dir, + sanitize, + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.message])).toEqual([ + ["SC2020", expect.stringContaining("'existsSync with a URL path'")], + ["SC2020", expect.stringContaining("'fs.promises.readFile with a URL path'")], + ["SC2020", expect.stringContaining("'fs.promises.stat with a URL path'")], + ]); +}); + test("node-types: path and os lower statically under @types/node's shapes", async () => { const outDir = outDirFor("node-path-os"); const result = await compile(join(nodeTypesDir, "path-os.ts"), { diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 722c3fede..1cc920bd2 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -185,6 +185,10 @@ test( return result.stdout; }; const entry = join(root, "packages/compiler/src/native/cli.ts"); + // The compiler's own source has untyped functions that SC6101 reports + // (diagnostics/dynamic-tier.ts). The contracts compare stderr exactly, + // so its self-builds leave that performance note out. + const quiet = "--no-dynamic-tier-warning"; const ffi = join(directory, ".scriptc/distribution-seed/compiler.ffi.json"); const rebuilt = executable("scriptc-rebuilt"); const checkProgram = async (compiler: string, source: string, extra: string[] = []) => { @@ -541,7 +545,18 @@ test( const self = await bootstrapStep("production CLI rebuilds itself", () => invoke( seed, - ["build", entry, "-o", rebuilt, "--strip", "--keep-llvm", "--emit-ir", "--ffi", ffi], + [ + "build", + entry, + "-o", + rebuilt, + "--strip", + "--keep-llvm", + "--emit-ir", + "--ffi", + ffi, + quiet, + ], "scriptc: warning: --emit-ir is deprecated; use --emit=ir for IR as the primary output\n", ), ); @@ -554,7 +569,16 @@ test( const seedLlvm = join(directory, "cli.ll"); const rebuiltLlvm = join(directory, "rebuilt.ll"); await bootstrapStep("rebuilt compiler emits itself", () => - invoke(rebuilt, ["build", entry, "--emit=llvm", "-o", rebuiltLlvm, "--ffi", ffi]), + invoke(rebuilt, [ + "build", + entry, + "--emit=llvm", + "-o", + rebuiltLlvm, + "--ffi", + ffi, + quiet, + ]), ); // Executable output adds a runtime ABI check; textual LLVM emission // omits it. Compare all other output without changing either mode. @@ -585,7 +609,7 @@ test( const rebuildFrontend = async () => { const lowered = join(directory, "cli.ir.json"); const output = await bootstrapStep("sanitized native frontend lowers the compiler", () => - invoke(seed, ["build", entry, "--emit=ir", "-o", lowered, "--ffi", ffi]), + invoke(seed, ["build", entry, "--emit=ir", "-o", lowered, "--ffi", ffi, quiet]), ); expect(output.trim()).toBe(lowered); await compareArtifacts({ ir: true, llvm: false }); @@ -605,6 +629,7 @@ test( ffi, "--sanitize", "--optimization=dev", + quiet, ], "", { SCRIPTC_BOOTSTRAP_IR_INPUT: seedIr },